diff --git a/go.mod b/go.mod
index 07edd0a97..d1cefcf78 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
 	github.com/gin-contrib/sessions v0.0.3
 	github.com/gin-gonic/gin v1.6.3
 	github.com/go-fed/activity v1.0.0
+	github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5
 	github.com/go-pg/pg/extra/pgdebug v0.2.0
 	github.com/go-pg/pg/v10 v10.8.0
 	github.com/golang/mock v1.4.4 // indirect
diff --git a/internal/apimodule/apimodule.go b/internal/api/apimodule.go
similarity index 65%
rename from internal/apimodule/apimodule.go
rename to internal/api/apimodule.go
index 6d7dbdb83..d0bcc612a 100644
--- a/internal/apimodule/apimodule.go
+++ b/internal/api/apimodule.go
@@ -16,18 +16,22 @@
    along with this program.  If not, see .
 */
 
-// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface.
-package apimodule
+package api
 
 import (
-	"github.com/superseriousbusiness/gotosocial/internal/db"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
-// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set
+// ClientModule represents a chunk of code (usually contained in a single package) that adds a set
 // of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
 // A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
-type ClientAPIModule interface {
+type ClientModule interface {
+	Route(s router.Router) error
+}
+
+// FederationModule represents a chunk of code (usually contained in a single package) that adds a set
+// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
+// Unlike ClientAPIModule, federation API module is not intended to be interacted with by clients directly -- it is primarily a server-to-server interface.
+type FederationModule interface {
 	Route(s router.Router) error
-	CreateTables(db db.DB) error
 }
diff --git a/internal/apimodule/account/account.go b/internal/api/client/account/account.go
similarity index 62%
rename from internal/apimodule/account/account.go
rename to internal/api/client/account/account.go
index a836afcdb..dce810202 100644
--- a/internal/apimodule/account/account.go
+++ b/internal/api/client/account/account.go
@@ -19,20 +19,15 @@
 package account
 
 import (
-	"fmt"
 	"net/http"
 	"strings"
 
 	"github.com/gin-gonic/gin"
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 
-	"github.com/superseriousbusiness/gotosocial/internal/media"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
@@ -51,23 +46,17 @@ const (
 
 // Module implements the ClientAPIModule interface for account-related actions
 type Module struct {
-	config         *config.Config
-	db             db.DB
-	oauthServer    oauth.Server
-	mediaHandler   media.Handler
-	mastoConverter mastotypes.Converter
-	log            *logrus.Logger
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
 }
 
 // New returns a new account module
-func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
 	return &Module{
-		config:         config,
-		db:             db,
-		oauthServer:    oauthServer,
-		mediaHandler:   mediaHandler,
-		mastoConverter: mastoConverter,
-		log:            log,
+		config:    config,
+		processor: processor,
+		log:       log,
 	}
 }
 
@@ -79,27 +68,6 @@ func (m *Module) Route(r router.Router) error {
 	return nil
 }
 
-// CreateTables creates the required tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-	}
-
-	for _, m := range models {
-		if err := db.CreateTable(m); err != nil {
-			return fmt.Errorf("error creating table: %s", err)
-		}
-	}
-	return nil
-}
-
 func (m *Module) muxHandler(c *gin.Context) {
 	ru := c.Request.RequestURI
 	switch c.Request.Method {
diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go
new file mode 100644
index 000000000..d0560bcb6
--- /dev/null
+++ b/internal/api/client/account/account_test.go
@@ -0,0 +1,40 @@
+package account_test
+
+import (
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type AccountStandardTestSuite struct {
+	// standard suite interfaces
+	suite.Suite
+	config    *config.Config
+	db        db.DB
+	log       *logrus.Logger
+	tc        typeutils.TypeConverter
+	storage   storage.Storage
+	federator federation.Federator
+	processor message.Processor
+
+	// standard suite models
+	testTokens       map[string]*oauth.Token
+	testClients      map[string]*oauth.Client
+	testApplications map[string]*gtsmodel.Application
+	testUsers        map[string]*gtsmodel.User
+	testAccounts     map[string]*gtsmodel.Account
+	testAttachments  map[string]*gtsmodel.MediaAttachment
+	testStatuses     map[string]*gtsmodel.Status
+
+	// module being tested
+	accountModule *account.Module
+}
diff --git a/internal/apimodule/account/accountcreate.go b/internal/api/client/account/accountcreate.go
similarity index 59%
rename from internal/apimodule/account/accountcreate.go
rename to internal/api/client/account/accountcreate.go
index fb21925b8..b53d8c412 100644
--- a/internal/apimodule/account/accountcreate.go
+++ b/internal/api/client/account/accountcreate.go
@@ -20,18 +20,14 @@ package account
 
 import (
 	"errors"
-	"fmt"
 	"net"
 	"net/http"
 
 	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/util"
-	"github.com/superseriousbusiness/oauth2/v4"
 )
 
 // AccountCreatePOSTHandler handles create account requests, validates them,
@@ -39,7 +35,7 @@ import (
 // It should be served as a POST at /api/v1/accounts
 func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 	l := m.log.WithField("func", "accountCreatePOSTHandler")
-	authed, err := oauth.MustAuth(c, true, true, false, false)
+	authed, err := oauth.Authed(c, true, true, false, false)
 	if err != nil {
 		l.Debugf("couldn't auth: %s", err)
 		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
@@ -47,7 +43,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 	}
 
 	l.Trace("parsing request form")
-	form := &mastotypes.AccountCreateRequest{}
+	form := &model.AccountCreateRequest{}
 	if err := c.ShouldBind(form); err != nil || form == nil {
 		l.Debugf("could not parse form from request: %s", err)
 		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
@@ -55,7 +51,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 	}
 
 	l.Tracef("validating form %+v", form)
-	if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil {
+	if err := validateCreateAccount(form, m.config.AccountsConfig); err != nil {
 		l.Debugf("error validating form: %s", err)
 		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
 		return
@@ -70,7 +66,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 		return
 	}
 
-	ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application)
+	form.IP = signUpIP
+
+	ti, err := m.processor.AccountCreate(authed, form)
 	if err != nil {
 		l.Errorf("internal server error while creating new account: %s", err)
 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -80,41 +78,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
 	c.JSON(http.StatusOK, ti)
 }
 
-// accountCreate does the dirty work of making an account and user in the database.
-// It then returns a token to the caller, for use with the new account, as per the
-// spec here: https://docs.joinmastodon.org/methods/accounts/
-func (m *Module) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) {
-	l := m.log.WithField("func", "accountCreate")
-
-	// don't store a reason if we don't require one
-	reason := form.Reason
-	if !m.config.AccountsConfig.ReasonRequired {
-		reason = ""
-	}
-
-	l.Trace("creating new username and account")
-	user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.ID)
-	if err != nil {
-		return nil, fmt.Errorf("error creating new signup in the database: %s", err)
-	}
-
-	l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, app.ID)
-	accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID)
-	if err != nil {
-		return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)
-	}
-
-	return &mastotypes.Token{
-		AccessToken: accessToken.GetAccess(),
-		TokenType:   "Bearer",
-		Scope:       accessToken.GetScope(),
-		CreatedAt:   accessToken.GetAccessCreateAt().Unix(),
-	}, nil
-}
-
 // validateCreateAccount checks through all the necessary prerequisites for creating a new account,
 // according to the provided account create request. If the account isn't eligible, an error will be returned.
-func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error {
+func validateCreateAccount(form *model.AccountCreateRequest, c *config.AccountsConfig) error {
 	if !c.OpenRegistration {
 		return errors.New("registration is not open for this server")
 	}
@@ -143,13 +109,5 @@ func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.Acco
 		return err
 	}
 
-	if err := database.IsEmailAvailable(form.Email); err != nil {
-		return err
-	}
-
-	if err := database.IsUsernameAvailable(form.Username); err != nil {
-		return err
-	}
-
 	return nil
 }
diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go
new file mode 100644
index 000000000..da86ee940
--- /dev/null
+++ b/internal/api/client/account/accountcreate_test.go
@@ -0,0 +1,388 @@
+// /*
+//    GoToSocial
+//    Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU Affero General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU Affero General Public License for more details.
+
+//    You should have received a copy of the GNU Affero General Public License
+//    along with this program.  If not, see .
+// */
+
+package account_test
+
+// import (
+// 	"bytes"
+// 	"encoding/json"
+// 	"fmt"
+// 	"io"
+// 	"io/ioutil"
+// 	"mime/multipart"
+// 	"net/http"
+// 	"net/http/httptest"
+// 	"os"
+// 	"testing"
+
+// 	"github.com/gin-gonic/gin"
+// 	"github.com/google/uuid"
+// 	"github.com/stretchr/testify/assert"
+// 	"github.com/stretchr/testify/suite"
+// 	"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+// 	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+// 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+// 	"github.com/superseriousbusiness/gotosocial/testrig"
+
+// 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+// 	"golang.org/x/crypto/bcrypt"
+// )
+
+// type AccountCreateTestSuite struct {
+// 	AccountStandardTestSuite
+// }
+
+// func (suite *AccountCreateTestSuite) SetupSuite() {
+// 	suite.testTokens = testrig.NewTestTokens()
+// 	suite.testClients = testrig.NewTestClients()
+// 	suite.testApplications = testrig.NewTestApplications()
+// 	suite.testUsers = testrig.NewTestUsers()
+// 	suite.testAccounts = testrig.NewTestAccounts()
+// 	suite.testAttachments = testrig.NewTestAttachments()
+// 	suite.testStatuses = testrig.NewTestStatuses()
+// }
+
+// func (suite *AccountCreateTestSuite) SetupTest() {
+// 	suite.config = testrig.NewTestConfig()
+// 	suite.db = testrig.NewTestDB()
+// 	suite.storage = testrig.NewTestStorage()
+// 	suite.log = testrig.NewTestLog()
+// 	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+// 	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+// 	suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
+// 	testrig.StandardDBSetup(suite.db)
+// 	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+// }
+
+// func (suite *AccountCreateTestSuite) TearDownTest() {
+// 	testrig.StandardDBTeardown(suite.db)
+// 	testrig.StandardStorageTeardown(suite.storage)
+// }
+
+// // TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
+// // and at the end of it a new user and account should be added into the database.
+// //
+// // This is the handler served at /api/v1/accounts as POST
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
+
+// 	t := suite.testTokens["local_account_1"]
+// 	oauthToken := oauth.TokenToOauthToken(t)
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+// 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+
+// 	// 1. we should have OK from our call to the function
+// 	suite.EqualValues(http.StatusOK, recorder.Code)
+
+// 	// 2. we should have a token in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	t := &model.Token{}
+// 	err = json.Unmarshal(b, t)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
+
+// 	// check new account
+
+// 	// 1. we should be able to get the new account from the db
+// 	acct := >smodel.Account{}
+// 	err = suite.db.GetLocalAccountByUsername("test_user", acct)
+// 	assert.NoError(suite.T(), err)
+// 	assert.NotNil(suite.T(), acct)
+// 	// 2. reason should be set
+// 	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
+// 	// 3. display name should be equal to username by default
+// 	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
+// 	// 4. domain should be nil because this is a local account
+// 	assert.Nil(suite.T(), nil, acct.Domain)
+// 	// 5. id should be set and parseable as a uuid
+// 	assert.NotNil(suite.T(), acct.ID)
+// 	_, err = uuid.Parse(acct.ID)
+// 	assert.Nil(suite.T(), err)
+// 	// 6. private and public key should be set
+// 	assert.NotNil(suite.T(), acct.PrivateKey)
+// 	assert.NotNil(suite.T(), acct.PublicKey)
+
+// 	// check new user
+
+// 	// 1. we should be able to get the new user from the db
+// 	usr := >smodel.User{}
+// 	err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
+// 	assert.Nil(suite.T(), err)
+// 	assert.NotNil(suite.T(), usr)
+
+// 	// 2. user should have account id set to account we got above
+// 	assert.Equal(suite.T(), acct.ID, usr.AccountID)
+
+// 	// 3. id should be set and parseable as a uuid
+// 	assert.NotNil(suite.T(), usr.ID)
+// 	_, err = uuid.Parse(usr.ID)
+// 	assert.Nil(suite.T(), err)
+
+// 	// 4. locale should be equal to what we requested
+// 	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
+
+// 	// 5. created by application id should be equal to the app id
+// 	assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
+
+// 	// 6. password should be matcheable to what we set above
+// 	err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
+// 	assert.Nil(suite.T(), err)
+// }
+
+// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
+// // only registered applications can create accounts, and we don't provide one here.
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+
+// 	// 1. we should have forbidden from our call to the function because we didn't auth
+// 	suite.EqualValues(http.StatusForbidden, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+// 	suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+// 	// set a weak password
+// 	ctx.Request.Form.Set("password", "weak")
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+// 	suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+// 	// set an invalid locale
+// 	ctx.Request.Form.Set("locale", "neverneverland")
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+// 	suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+
+// 	// close registrations
+// 	suite.config.AccountsConfig.OpenRegistration = false
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+// 	suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+
+// 	// remove reason
+// 	ctx.Request.Form.Set("reason", "")
+
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+// 	suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// 	ctx.Request.Form = suite.newUserFormHappyPath
+
+// 	// remove reason
+// 	ctx.Request.Form.Set("reason", "just cuz")
+
+// 	suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// 	// check response
+// 	suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	b, err := ioutil.ReadAll(result.Body)
+// 	assert.NoError(suite.T(), err)
+// 	assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
+// }
+
+// /*
+// 	TESTING: AccountUpdateCredentialsPATCHHandler
+// */
+
+// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
+
+// 	// put test local account in db
+// 	err := suite.db.Put(suite.testAccountLocal)
+// 	assert.NoError(suite.T(), err)
+
+// 	// attach avatar to request
+// 	aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
+// 	assert.NoError(suite.T(), err)
+// 	body := &bytes.Buffer{}
+// 	writer := multipart.NewWriter(body)
+
+// 	part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
+// 	assert.NoError(suite.T(), err)
+
+// 	_, err = io.Copy(part, aviFile)
+// 	assert.NoError(suite.T(), err)
+
+// 	err = aviFile.Close()
+// 	assert.NoError(suite.T(), err)
+
+// 	err = writer.Close()
+// 	assert.NoError(suite.T(), err)
+
+// 	// setup
+// 	recorder := httptest.NewRecorder()
+// 	ctx, _ := gin.CreateTestContext(recorder)
+// 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
+// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// 	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
+// 	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
+// 	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
+
+// 	// check response
+
+// 	// 1. we should have OK because our request was valid
+// 	suite.EqualValues(http.StatusOK, recorder.Code)
+
+// 	// 2. we should have an error message in the result body
+// 	result := recorder.Result()
+// 	defer result.Body.Close()
+// 	// TODO: implement proper checks here
+// 	//
+// 	// b, err := ioutil.ReadAll(result.Body)
+// 	// assert.NoError(suite.T(), err)
+// 	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
+// }
+
+// func TestAccountCreateTestSuite(t *testing.T) {
+// 	suite.Run(t, new(AccountCreateTestSuite))
+// }
diff --git a/internal/apimodule/account/accountget.go b/internal/api/client/account/accountget.go
similarity index 69%
rename from internal/apimodule/account/accountget.go
rename to internal/api/client/account/accountget.go
index 5003be139..5ca17a167 100644
--- a/internal/apimodule/account/accountget.go
+++ b/internal/api/client/account/accountget.go
@@ -22,8 +22,7 @@ import (
 	"net/http"
 
 	"github.com/gin-gonic/gin"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 )
 
 // AccountGETHandler serves the account information held by the server in response to a GET
@@ -31,25 +30,21 @@ import (
 //
 // See: https://docs.joinmastodon.org/methods/accounts/
 func (m *Module) AccountGETHandler(c *gin.Context) {
+	authed, err := oauth.Authed(c, false, false, false, false)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+		return
+	}
+
 	targetAcctID := c.Param(IDKey)
 	if targetAcctID == "" {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"})
 		return
 	}
 
-	targetAccount := >smodel.Account{}
-	if err := m.db.GetByID(targetAcctID, targetAccount); err != nil {
-		if _, ok := err.(db.ErrNoEntries); ok {
-			c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
-			return
-		}
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount)
+	acctInfo, err := m.processor.AccountGet(authed, targetAcctID)
 	if err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
 		return
 	}
 
diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go
new file mode 100644
index 000000000..406769fe7
--- /dev/null
+++ b/internal/api/client/account/accountupdate.go
@@ -0,0 +1,71 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package account
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
+// It should be served as a PATCH at /api/v1/accounts/update_credentials
+//
+// TODO: this can be optimized massively by building up a picture of what we want the new account
+// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
+// which is not gonna make the database very happy when lots of requests are going through.
+// This way it would also be safer because the update won't happen until *all* the fields are validated.
+// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
+func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
+	l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
+	authed, err := oauth.Authed(c, true, false, false, true)
+	if err != nil {
+		l.Debugf("couldn't auth: %s", err)
+		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+		return
+	}
+	l.Tracef("retrieved account %+v", authed.Account.ID)
+
+	l.Trace("parsing request form")
+	form := &model.UpdateCredentialsRequest{}
+	if err := c.ShouldBind(form); err != nil || form == nil {
+		l.Debugf("could not parse form from request: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	// if everything on the form is nil, then nothing has been set and we shouldn't continue
+	if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
+		l.Debugf("could not parse form from request")
+		c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
+		return
+	}
+
+	acctSensitive, err := m.processor.AccountUpdate(authed, form)
+	if err != nil {
+		l.Debugf("could not update account: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
+	c.JSON(http.StatusOK, acctSensitive)
+}
diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go
new file mode 100644
index 000000000..ba7faa794
--- /dev/null
+++ b/internal/api/client/account/accountupdate_test.go
@@ -0,0 +1,106 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package account_test
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type AccountUpdateTestSuite struct {
+	AccountStandardTestSuite
+}
+
+func (suite *AccountUpdateTestSuite) SetupSuite() {
+	suite.testTokens = testrig.NewTestTokens()
+	suite.testClients = testrig.NewTestClients()
+	suite.testApplications = testrig.NewTestApplications()
+	suite.testUsers = testrig.NewTestUsers()
+	suite.testAccounts = testrig.NewTestAccounts()
+	suite.testAttachments = testrig.NewTestAttachments()
+	suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *AccountUpdateTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *AccountUpdateTestSuite) TearDownTest() {
+	testrig.StandardDBTeardown(suite.db)
+	testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
+
+	requestBody, w, err := testrig.CreateMultipartFormData("header", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
+		"display_name": "updated zork display name!!!",
+		"locked":       "true",
+	})
+	if err != nil {
+		panic(err)
+	}
+
+	// setup
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+	ctx.Set(oauth.SessionAuthorizedToken, oauth.TokenToOauthToken(suite.testTokens["local_account_1"]))
+	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), bytes.NewReader(requestBody.Bytes())) // the endpoint we're hitting
+	ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
+	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
+
+	// check response
+
+	// 1. we should have OK because our request was valid
+	suite.EqualValues(http.StatusOK, recorder.Code)
+
+	// 2. we should have no error message in the result body
+	result := recorder.Result()
+	defer result.Body.Close()
+
+	b, err := ioutil.ReadAll(result.Body)
+	assert.NoError(suite.T(), err)
+
+	fmt.Println(string(b))
+
+	// TODO write more assertions allee
+}
+
+func TestAccountUpdateTestSuite(t *testing.T) {
+	suite.Run(t, new(AccountUpdateTestSuite))
+}
diff --git a/internal/apimodule/account/accountverify.go b/internal/api/client/account/accountverify.go
similarity index 75%
rename from internal/apimodule/account/accountverify.go
rename to internal/api/client/account/accountverify.go
index 9edf1e73a..4c62ff705 100644
--- a/internal/apimodule/account/accountverify.go
+++ b/internal/api/client/account/accountverify.go
@@ -30,21 +30,19 @@ import (
 // It should be served as a GET at /api/v1/accounts/verify_credentials
 func (m *Module) AccountVerifyGETHandler(c *gin.Context) {
 	l := m.log.WithField("func", "accountVerifyGETHandler")
-	authed, err := oauth.MustAuth(c, true, false, false, true)
+	authed, err := oauth.Authed(c, true, false, false, true)
 	if err != nil {
 		l.Debugf("couldn't auth: %s", err)
 		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
 		return
 	}
 
-	l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID)
-	acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account)
+	acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID)
 	if err != nil {
-		l.Tracef("could not convert account into mastosensitive account: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		l.Debugf("error getting account from processor: %s", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
 		return
 	}
 
-	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
 	c.JSON(http.StatusOK, acctSensitive)
 }
diff --git a/internal/apimodule/account/test/accountverify_test.go b/internal/api/client/account/accountverify_test.go
similarity index 97%
rename from internal/apimodule/account/test/accountverify_test.go
rename to internal/api/client/account/accountverify_test.go
index 223a0c145..85b0dce50 100644
--- a/internal/apimodule/account/test/accountverify_test.go
+++ b/internal/api/client/account/accountverify_test.go
@@ -16,4 +16,4 @@
    along with this program.  If not, see .
 */
 
-package account
+package account_test
diff --git a/internal/apimodule/admin/admin.go b/internal/api/client/admin/admin.go
similarity index 52%
rename from internal/apimodule/admin/admin.go
rename to internal/api/client/admin/admin.go
index 2ebe9c7a7..7ce5311eb 100644
--- a/internal/apimodule/admin/admin.go
+++ b/internal/api/client/admin/admin.go
@@ -19,43 +19,35 @@
 package admin
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
 const (
 	// BasePath is the base API path for this module
-	BasePath  = "/api/v1/admin"
+	BasePath = "/api/v1/admin"
 	// EmojiPath is used for posting/deleting custom emojis
 	EmojiPath = BasePath + "/custom_emojis"
 )
 
 // Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
 type Module struct {
-	config         *config.Config
-	db             db.DB
-	mediaHandler   media.Handler
-	mastoConverter mastotypes.Converter
-	log            *logrus.Logger
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
 }
 
 // New returns a new admin module
-func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
 	return &Module{
-		config:         config,
-		db:             db,
-		mediaHandler:   mediaHandler,
-		mastoConverter: mastoConverter,
-		log:            log,
+		config:    config,
+		processor: processor,
+		log:       log,
 	}
 }
 
@@ -64,25 +56,3 @@ func (m *Module) Route(r router.Router) error {
 	r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler)
 	return nil
 }
-
-// CreateTables creates the necessary tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-		>smodel.Emoji{},
-	}
-
-	for _, m := range models {
-		if err := db.CreateTable(m); err != nil {
-			return fmt.Errorf("error creating table: %s", err)
-		}
-	}
-	return nil
-}
diff --git a/internal/apimodule/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go
similarity index 60%
rename from internal/apimodule/admin/emojicreate.go
rename to internal/api/client/admin/emojicreate.go
index 49e5492dd..0e60db65f 100644
--- a/internal/apimodule/admin/emojicreate.go
+++ b/internal/api/client/admin/emojicreate.go
@@ -19,15 +19,13 @@
 package admin
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
-	"io"
 	"net/http"
 
 	"github.com/gin-gonic/gin"
 	"github.com/sirupsen/logrus"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/media"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/util"
@@ -42,7 +40,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
 	})
 
 	// make sure we're authed with an admin account
-	authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
+	authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything*
 	if err != nil {
 		l.Debugf("couldn't auth: %s", err)
 		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
@@ -56,7 +54,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
 
 	// extract the media create form from the request context
 	l.Tracef("parsing request form: %+v", c.Request.Form)
-	form := &mastotypes.EmojiCreateRequest{}
+	form := &model.EmojiCreateRequest{}
 	if err := c.ShouldBind(form); err != nil {
 		l.Debugf("error parsing form %+v: %s", c.Request.Form, err)
 		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)})
@@ -71,51 +69,17 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
 		return
 	}
 
-	// open the emoji and extract the bytes from it
-	f, err := form.Image.Open()
+	mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form)
 	if err != nil {
-		l.Debugf("error opening emoji: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)})
-		return
-	}
-	buf := new(bytes.Buffer)
-	size, err := io.Copy(buf, f)
-	if err != nil {
-		l.Debugf("error reading emoji: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)})
-		return
-	}
-	if size == 0 {
-		l.Debug("could not read provided emoji: size 0 bytes")
-		c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"})
-		return
-	}
-
-	// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
-	emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
-	if err != nil {
-		l.Debugf("error reading emoji: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)})
-		return
-	}
-
-	mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji)
-	if err != nil {
-		l.Debugf("error converting emoji to mastotype: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)})
-		return
-	}
-
-	if err := m.db.Put(emoji); err != nil {
-		l.Debugf("database error while processing emoji: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)})
+		l.Debugf("error creating emoji: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
 		return
 	}
 
 	c.JSON(http.StatusOK, mastoEmoji)
 }
 
-func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error {
+func validateCreateEmoji(form *model.EmojiCreateRequest) error {
 	// check there actually is an image attached and it's not size 0
 	if form.Image == nil || form.Image.Size == 0 {
 		return errors.New("no emoji given")
diff --git a/internal/apimodule/app/app.go b/internal/api/client/app/app.go
similarity index 54%
rename from internal/apimodule/app/app.go
rename to internal/api/client/app/app.go
index 518192758..d1e732a8c 100644
--- a/internal/apimodule/app/app.go
+++ b/internal/api/client/app/app.go
@@ -19,15 +19,12 @@
 package app
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
@@ -36,19 +33,17 @@ const BasePath = "/api/v1/apps"
 
 // Module implements the ClientAPIModule interface for requests relating to registering/removing applications
 type Module struct {
-	server         oauth.Server
-	db             db.DB
-	mastoConverter mastotypes.Converter
-	log            *logrus.Logger
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
 }
 
 // New returns a new auth module
-func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
 	return &Module{
-		server:         srv,
-		db:             db,
-		mastoConverter: mastoConverter,
-		log:            log,
+		config:    config,
+		processor: processor,
+		log:       log,
 	}
 }
 
@@ -57,21 +52,3 @@ func (m *Module) Route(s router.Router) error {
 	s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)
 	return nil
 }
-
-// CreateTables creates the necessary tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
-	models := []interface{}{
-		&oauth.Client{},
-		&oauth.Token{},
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Application{},
-	}
-
-	for _, m := range models {
-		if err := db.CreateTable(m); err != nil {
-			return fmt.Errorf("error creating table: %s", err)
-		}
-	}
-	return nil
-}
diff --git a/internal/apimodule/app/test/app_test.go b/internal/api/client/app/app_test.go
similarity index 97%
rename from internal/apimodule/app/test/app_test.go
rename to internal/api/client/app/app_test.go
index d45b04e74..42760a2db 100644
--- a/internal/apimodule/app/test/app_test.go
+++ b/internal/api/client/app/app_test.go
@@ -16,6 +16,6 @@
    along with this program.  If not, see .
 */
 
-package app
+package app_test
 
 // TODO: write tests
diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go
new file mode 100644
index 000000000..fd42482d4
--- /dev/null
+++ b/internal/api/client/app/appcreate.go
@@ -0,0 +1,79 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package app
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AppsPOSTHandler should be served at https://example.org/api/v1/apps
+// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
+func (m *Module) AppsPOSTHandler(c *gin.Context) {
+	l := m.log.WithField("func", "AppsPOSTHandler")
+	l.Trace("entering AppsPOSTHandler")
+
+	authed, err := oauth.Authed(c, false, false, false, false)
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
+		return
+	}
+
+	form := &model.ApplicationCreateRequest{}
+	if err := c.ShouldBind(form); err != nil {
+		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
+		return
+	}
+
+	// permitted length for most fields
+	formFieldLen := 64
+	// redirect can be a bit bigger because we probably need to encode data in the redirect uri
+	formRedirectLen := 512
+
+	// check lengths of fields before proceeding so the user can't spam huge entries into the database
+	if len(form.ClientName) > formFieldLen {
+		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)})
+		return
+	}
+	if len(form.Website) > formFieldLen {
+		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)})
+		return
+	}
+	if len(form.RedirectURIs) > formRedirectLen {
+		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)})
+		return
+	}
+	if len(form.Scopes) > formFieldLen {
+		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)})
+		return
+	}
+
+	mastoApp, err := m.processor.AppCreate(authed, form)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
+	c.JSON(http.StatusOK, mastoApp)
+}
diff --git a/internal/apimodule/auth/auth.go b/internal/api/client/auth/auth.go
similarity index 74%
rename from internal/apimodule/auth/auth.go
rename to internal/api/client/auth/auth.go
index 341805b40..793c19f4e 100644
--- a/internal/apimodule/auth/auth.go
+++ b/internal/api/client/auth/auth.go
@@ -19,38 +19,39 @@
 package auth
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
 const (
 	// AuthSignInPath is the API path for users to sign in through
-	AuthSignInPath     = "/auth/sign_in"
+	AuthSignInPath = "/auth/sign_in"
 	// OauthTokenPath is the API path to use for granting token requests to users with valid credentials
-	OauthTokenPath     = "/oauth/token"
+	OauthTokenPath = "/oauth/token"
 	// OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
 	OauthAuthorizePath = "/oauth/authorize"
 )
 
 // Module implements the ClientAPIModule interface for
 type Module struct {
-	server oauth.Server
+	config *config.Config
 	db     db.DB
+	server oauth.Server
 	log    *logrus.Logger
 }
 
 // New returns a new auth module
-func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule {
 	return &Module{
-		server: srv,
+		config: config,
 		db:     db,
+		server: server,
 		log:    log,
 	}
 }
@@ -68,21 +69,3 @@ func (m *Module) Route(s router.Router) error {
 	s.AttachMiddleware(m.OauthTokenMiddleware)
 	return nil
 }
-
-// CreateTables creates the necessary tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
-	models := []interface{}{
-		&oauth.Client{},
-		&oauth.Token{},
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Application{},
-	}
-
-	for _, m := range models {
-		if err := db.CreateTable(m); err != nil {
-			return fmt.Errorf("error creating table: %s", err)
-		}
-	}
-	return nil
-}
diff --git a/internal/apimodule/auth/test/auth_test.go b/internal/api/client/auth/auth_test.go
similarity index 96%
rename from internal/apimodule/auth/test/auth_test.go
rename to internal/api/client/auth/auth_test.go
index 2c272e985..7ec788a0e 100644
--- a/internal/apimodule/auth/test/auth_test.go
+++ b/internal/api/client/auth/auth_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package auth
+package auth_test
 
 import (
 	"context"
@@ -28,7 +28,7 @@ import (
 	"github.com/stretchr/testify/suite"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"golang.org/x/crypto/bcrypt"
 )
@@ -103,7 +103,7 @@ func (suite *AuthTestSuite) SetupTest() {
 
 	log := logrus.New()
 	log.SetLevel(logrus.TraceLevel)
-	db, err := db.New(context.Background(), suite.config, log)
+	db, err := db.NewPostgresService(context.Background(), suite.config, log)
 	if err != nil {
 		logrus.Panicf("error creating database connection: %s", err)
 	}
diff --git a/internal/apimodule/auth/authorize.go b/internal/api/client/auth/authorize.go
similarity index 97%
rename from internal/apimodule/auth/authorize.go
rename to internal/api/client/auth/authorize.go
index 4bc1991ac..d5f8ee214 100644
--- a/internal/apimodule/auth/authorize.go
+++ b/internal/api/client/auth/authorize.go
@@ -27,8 +27,8 @@ import (
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 )
 
 // AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
@@ -178,7 +178,7 @@ func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
 	s := sessions.Default(c)
 
 	// first make sure they've filled out the authorize form with the required values
-	form := &mastotypes.OAuthAuthorize{}
+	form := &model.OAuthAuthorize{}
 	if err := c.ShouldBind(form); err != nil {
 		return err
 	}
diff --git a/internal/apimodule/auth/middleware.go b/internal/api/client/auth/middleware.go
similarity index 96%
rename from internal/apimodule/auth/middleware.go
rename to internal/api/client/auth/middleware.go
index 1d9a85993..c42ba77fc 100644
--- a/internal/apimodule/auth/middleware.go
+++ b/internal/api/client/auth/middleware.go
@@ -20,7 +20,7 @@ package auth
 
 import (
 	"github.com/gin-gonic/gin"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 )
 
@@ -30,7 +30,7 @@ import (
 // If user or account can't be found, then the handler won't *fail*, in case the server wants to allow
 // public requests that don't have a Bearer token set (eg., for public instance information and so on).
 func (m *Module) OauthTokenMiddleware(c *gin.Context) {
-	l := m.log.WithField("func", "ValidatePassword")
+	l := m.log.WithField("func", "OauthTokenMiddleware")
 	l.Trace("entering OauthTokenMiddleware")
 
 	ti, err := m.server.ValidationBearerToken(c.Request)
diff --git a/internal/apimodule/auth/signin.go b/internal/api/client/auth/signin.go
similarity index 98%
rename from internal/apimodule/auth/signin.go
rename to internal/api/client/auth/signin.go
index 44de0891c..79d9b300e 100644
--- a/internal/apimodule/auth/signin.go
+++ b/internal/api/client/auth/signin.go
@@ -24,7 +24,7 @@ import (
 
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"golang.org/x/crypto/bcrypt"
 )
 
diff --git a/internal/apimodule/auth/token.go b/internal/api/client/auth/token.go
similarity index 100%
rename from internal/apimodule/auth/token.go
rename to internal/api/client/auth/token.go
diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go
similarity index 85%
rename from internal/apimodule/fileserver/fileserver.go
rename to internal/api/client/fileserver/fileserver.go
index 7651c8cc1..63d323a01 100644
--- a/internal/apimodule/fileserver/fileserver.go
+++ b/internal/api/client/fileserver/fileserver.go
@@ -23,12 +23,12 @@ import (
 	"net/http"
 
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
 )
 
 const (
@@ -39,25 +39,23 @@ const (
 	// MediaSizeKey is the url key for the desired media size--original/small/static
 	MediaSizeKey = "media_size"
 	// FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg
-	FileNameKey  = "file_name"
+	FileNameKey = "file_name"
 )
 
 // FileServer implements the RESTAPIModule interface.
 // The goal here is to serve requested media files if the gotosocial server is configured to use local storage.
 type FileServer struct {
 	config      *config.Config
-	db          db.DB
-	storage     storage.Storage
+	processor   message.Processor
 	log         *logrus.Logger
 	storageBase string
 }
 
 // New returns a new fileServer module
-func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
 	return &FileServer{
 		config:      config,
-		db:          db,
-		storage:     storage,
+		processor:   processor,
 		log:         log,
 		storageBase: config.StorageConfig.ServeBasePath,
 	}
diff --git a/internal/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go
new file mode 100644
index 000000000..9823eb387
--- /dev/null
+++ b/internal/api/client/fileserver/servefile.go
@@ -0,0 +1,94 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package fileserver
+
+import (
+	"bytes"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
+//
+// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
+// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
+func (m *FileServer) ServeFile(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func":        "ServeFile",
+		"request_uri": c.Request.RequestURI,
+		"user_agent":  c.Request.UserAgent(),
+		"origin_ip":   c.ClientIP(),
+	})
+	l.Trace("received request")
+
+	authed, err := oauth.Authed(c, false, false, false, false)
+	if err != nil {
+		c.String(http.StatusNotFound, "404 page not found")
+		return
+	}
+
+	// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
+	// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
+	// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
+	accountID := c.Param(AccountIDKey)
+	if accountID == "" {
+		l.Debug("missing accountID from request")
+		c.String(http.StatusNotFound, "404 page not found")
+		return
+	}
+
+	mediaType := c.Param(MediaTypeKey)
+	if mediaType == "" {
+		l.Debug("missing mediaType from request")
+		c.String(http.StatusNotFound, "404 page not found")
+		return
+	}
+
+	mediaSize := c.Param(MediaSizeKey)
+	if mediaSize == "" {
+		l.Debug("missing mediaSize from request")
+		c.String(http.StatusNotFound, "404 page not found")
+		return
+	}
+
+	fileName := c.Param(FileNameKey)
+	if fileName == "" {
+		l.Debug("missing fileName from request")
+		c.String(http.StatusNotFound, "404 page not found")
+		return
+	}
+
+	content, err := m.processor.MediaGet(authed, &model.GetContentRequestForm{
+		AccountID: accountID,
+		MediaType: mediaType,
+		MediaSize: mediaSize,
+		FileName:  fileName,
+	})
+	if err != nil {
+		l.Debug(err)
+		c.String(http.StatusNotFound, "404 page not found")
+		return
+	}
+
+	c.DataFromReader(http.StatusOK, content.ContentLength, content.ContentType, bytes.NewReader(content.Content), nil)
+}
diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/api/client/fileserver/servefile_test.go
similarity index 80%
rename from internal/apimodule/fileserver/test/servefile_test.go
rename to internal/api/client/fileserver/servefile_test.go
index 516e3528c..09fd8ea43 100644
--- a/internal/apimodule/fileserver/test/servefile_test.go
+++ b/internal/api/client/fileserver/servefile_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package test
+package fileserver_test
 
 import (
 	"context"
@@ -30,27 +30,31 @@ import (
 	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type ServeFileTestSuite struct {
 	// standard suite interfaces
 	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
+	config       *config.Config
+	db           db.DB
+	log          *logrus.Logger
+	storage      storage.Storage
+	federator    federation.Federator
+	tc           typeutils.TypeConverter
+	processor    message.Processor
+	mediaHandler media.Handler
+	oauthServer  oauth.Server
 
 	// standard suite models
 	testTokens       map[string]*oauth.Token
@@ -74,12 +78,14 @@ func (suite *ServeFileTestSuite) SetupSuite() {
 	suite.db = testrig.NewTestDB()
 	suite.log = testrig.NewTestLog()
 	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.tc = testrig.NewTestTypeConverter(suite.db)
 	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
 	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
 
 	// setup module being tested
-	suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer)
+	suite.fileServer = fileserver.New(suite.config, suite.processor, suite.log).(*fileserver.FileServer)
 }
 
 func (suite *ServeFileTestSuite) TearDownSuite() {
@@ -126,11 +132,11 @@ func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {
 		},
 		gin.Param{
 			Key:   fileserver.MediaTypeKey,
-			Value: media.MediaAttachment,
+			Value: string(media.Attachment),
 		},
 		gin.Param{
 			Key:   fileserver.MediaSizeKey,
-			Value: media.MediaOriginal,
+			Value: string(media.Original),
 		},
 		gin.Param{
 			Key:   fileserver.FileNameKey,
diff --git a/internal/apimodule/media/media.go b/internal/api/client/media/media.go
similarity index 71%
rename from internal/apimodule/media/media.go
rename to internal/api/client/media/media.go
index 8fb9f16ec..2826783d6 100644
--- a/internal/apimodule/media/media.go
+++ b/internal/api/client/media/media.go
@@ -23,12 +23,11 @@ import (
 	"net/http"
 
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
@@ -37,21 +36,17 @@ const BasePath = "/api/v1/media"
 
 // Module implements the ClientAPIModule interface for media
 type Module struct {
-	mediaHandler   media.Handler
-	config         *config.Config
-	db             db.DB
-	mastoConverter mastotypes.Converter
-	log            *logrus.Logger
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
 }
 
 // New returns a new auth module
-func New(db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
 	return &Module{
-		mediaHandler:   mediaHandler,
-		config:         config,
-		db:             db,
-		mastoConverter: mastoConverter,
-		log:            log,
+		config:    config,
+		processor: processor,
+		log:       log,
 	}
 }
 
diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go
new file mode 100644
index 000000000..db57e2052
--- /dev/null
+++ b/internal/api/client/media/mediacreate.go
@@ -0,0 +1,91 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package media
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// MediaCreatePOSTHandler handles requests to create/upload media attachments
+func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
+	l := m.log.WithField("func", "statusCreatePOSTHandler")
+	authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything*
+	if err != nil {
+		l.Debugf("couldn't auth: %s", err)
+		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+		return
+	}
+
+	// extract the media create form from the request context
+	l.Tracef("parsing request form: %s", c.Request.Form)
+	form := &model.AttachmentRequest{}
+	if err := c.ShouldBind(form); err != nil || form == nil {
+		l.Debugf("could not parse form from request: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+		return
+	}
+
+	// Give the fields on the request form a first pass to make sure the request is superficially valid.
+	l.Tracef("validating form %+v", form)
+	if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
+		l.Debugf("error validating form: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	mastoAttachment, err := m.processor.MediaCreate(authed, form)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusAccepted, mastoAttachment)
+}
+
+func validateCreateMedia(form *model.AttachmentRequest, config *config.MediaConfig) error {
+	// check there actually is a file attached and it's not size 0
+	if form.File == nil || form.File.Size == 0 {
+		return errors.New("no attachment given")
+	}
+
+	// a very superficial check to see if no size limits are exceeded
+	// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
+	maxSize := config.MaxVideoSize
+	if config.MaxImageSize > maxSize {
+		maxSize = config.MaxImageSize
+	}
+	if form.File.Size > int64(maxSize) {
+		return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
+	}
+
+	if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
+		return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
+	}
+
+	// TODO: validate focus here
+
+	return nil
+}
diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go
similarity index 82%
rename from internal/apimodule/media/test/mediacreate_test.go
rename to internal/api/client/media/mediacreate_test.go
index 30bbb117a..e86c66021 100644
--- a/internal/apimodule/media/test/mediacreate_test.go
+++ b/internal/api/client/media/mediacreate_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package test
+package media_test
 
 import (
 	"bytes"
@@ -32,28 +32,32 @@ import (
 	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
-	mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
+	mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type MediaCreateTestSuite struct {
 	// standard suite interfaces
 	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
+	config       *config.Config
+	db           db.DB
+	log          *logrus.Logger
+	storage      storage.Storage
+	federator    federation.Federator
+	tc           typeutils.TypeConverter
+	mediaHandler media.Handler
+	oauthServer  oauth.Server
+	processor    message.Processor
 
 	// standard suite models
 	testTokens       map[string]*oauth.Token
@@ -77,12 +81,14 @@ func (suite *MediaCreateTestSuite) SetupSuite() {
 	suite.db = testrig.NewTestDB()
 	suite.log = testrig.NewTestLog()
 	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+	suite.tc = testrig.NewTestTypeConverter(suite.db)
 	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
 	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
 
 	// setup module being tested
-	suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module)
+	suite.mediaModule = mediamodule.New(suite.config, suite.processor, suite.log).(*mediamodule.Module)
 }
 
 func (suite *MediaCreateTestSuite) TearDownSuite() {
@@ -158,26 +164,26 @@ func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful()
 	assert.NoError(suite.T(), err)
 	fmt.Println(string(b))
 
-	attachmentReply := &mastomodel.Attachment{}
+	attachmentReply := &model.Attachment{}
 	err = json.Unmarshal(b, attachmentReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
 	assert.Equal(suite.T(), "image", attachmentReply.Type)
-	assert.EqualValues(suite.T(), mastomodel.MediaMeta{
-		Original: mastomodel.MediaDimensions{
+	assert.EqualValues(suite.T(), model.MediaMeta{
+		Original: model.MediaDimensions{
 			Width:  1920,
 			Height: 1080,
 			Size:   "1920x1080",
 			Aspect: 1.7777778,
 		},
-		Small: mastomodel.MediaDimensions{
+		Small: model.MediaDimensions{
 			Width:  256,
 			Height: 144,
 			Size:   "256x144",
 			Aspect: 1.7777778,
 		},
-		Focus: mastomodel.MediaFocus{
+		Focus: model.MediaFocus{
 			X: -0.5,
 			Y: 0.5,
 		},
diff --git a/internal/apimodule/status/status.go b/internal/api/client/status/status.go
similarity index 62%
rename from internal/apimodule/status/status.go
rename to internal/api/client/status/status.go
index 73a1b5847..ba9295623 100644
--- a/internal/apimodule/status/status.go
+++ b/internal/api/client/status/status.go
@@ -19,27 +19,22 @@
 package status
 
 import (
-	"fmt"
 	"net/http"
 	"strings"
 
 	"github.com/gin-gonic/gin"
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
 const (
 	// IDKey is for status UUIDs
-	IDKey          = "id"
+	IDKey = "id"
 	// BasePath is the base path for serving the status API
-	BasePath       = "/api/v1/statuses"
+	BasePath = "/api/v1/statuses"
 	// BasePathWithID is just the base path with the ID key in it.
 	// Use this anywhere you need to know the ID of the status being queried.
 	BasePathWithID = BasePath + "/:" + IDKey
@@ -48,54 +43,48 @@ const (
 	ContextPath = BasePathWithID + "/context"
 
 	// FavouritedPath is for seeing who's faved a given status
-	FavouritedPath  = BasePathWithID + "/favourited_by"
+	FavouritedPath = BasePathWithID + "/favourited_by"
 	// FavouritePath is for posting a fave on a status
-	FavouritePath   = BasePathWithID + "/favourite"
+	FavouritePath = BasePathWithID + "/favourite"
 	// UnfavouritePath is for removing a fave from a status
 	UnfavouritePath = BasePathWithID + "/unfavourite"
 
 	// RebloggedPath is for seeing who's boosted a given status
 	RebloggedPath = BasePathWithID + "/reblogged_by"
 	// ReblogPath is for boosting/reblogging a given status
-	ReblogPath    = BasePathWithID + "/reblog"
+	ReblogPath = BasePathWithID + "/reblog"
 	// UnreblogPath is for undoing a boost/reblog of a given status
-	UnreblogPath  = BasePathWithID + "/unreblog"
+	UnreblogPath = BasePathWithID + "/unreblog"
 
 	// BookmarkPath is for creating a bookmark on a given status
-	BookmarkPath   = BasePathWithID + "/bookmark"
+	BookmarkPath = BasePathWithID + "/bookmark"
 	// UnbookmarkPath is for removing a bookmark from a given status
 	UnbookmarkPath = BasePathWithID + "/unbookmark"
 
 	// MutePath is for muting a given status so that notifications will no longer be received about it.
-	MutePath   = BasePathWithID + "/mute"
+	MutePath = BasePathWithID + "/mute"
 	// UnmutePath is for undoing an existing mute
 	UnmutePath = BasePathWithID + "/unmute"
 
 	// PinPath is for pinning a status to an account profile so that it's the first thing people see
-	PinPath   = BasePathWithID + "/pin"
+	PinPath = BasePathWithID + "/pin"
 	// UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy
 	UnpinPath = BasePathWithID + "/unpin"
 )
 
 // Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses
 type Module struct {
-	config         *config.Config
-	db             db.DB
-	mediaHandler   media.Handler
-	mastoConverter mastotypes.Converter
-	distributor    distributor.Distributor
-	log            *logrus.Logger
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
 }
 
 // New returns a new account module
-func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
 	return &Module{
-		config:         config,
-		db:             db,
-		mediaHandler:   mediaHandler,
-		mastoConverter: mastoConverter,
-		distributor:    distributor,
-		log:            log,
+		config:    config,
+		processor: processor,
+		log:       log,
 	}
 }
 
@@ -105,41 +94,12 @@ func (m *Module) Route(r router.Router) error {
 	r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
 
 	r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
-	r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler)
+	r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)
 
 	r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
 	return nil
 }
 
-// CreateTables populates necessary tables in the given DB
-func (m *Module) CreateTables(db db.DB) error {
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Block{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.StatusFave{},
-		>smodel.StatusBookmark{},
-		>smodel.StatusMute{},
-		>smodel.StatusPin{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-		>smodel.Emoji{},
-		>smodel.Tag{},
-		>smodel.Mention{},
-	}
-
-	for _, m := range models {
-		if err := db.CreateTable(m); err != nil {
-			return fmt.Errorf("error creating table: %s", err)
-		}
-	}
-	return nil
-}
-
 // muxHandler is a little workaround to overcome the limitations of Gin
 func (m *Module) muxHandler(c *gin.Context) {
 	m.log.Debug("entering mux handler")
diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go
new file mode 100644
index 000000000..0f77820a1
--- /dev/null
+++ b/internal/api/client/status/status_test.go
@@ -0,0 +1,58 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status_test
+
+import (
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type StatusStandardTestSuite struct {
+	// standard suite interfaces
+	suite.Suite
+	config    *config.Config
+	db        db.DB
+	log       *logrus.Logger
+	tc        typeutils.TypeConverter
+	federator federation.Federator
+	processor message.Processor
+	storage   storage.Storage
+
+	// standard suite models
+	testTokens       map[string]*oauth.Token
+	testClients      map[string]*oauth.Client
+	testApplications map[string]*gtsmodel.Application
+	testUsers        map[string]*gtsmodel.User
+	testAccounts     map[string]*gtsmodel.Account
+	testAttachments  map[string]*gtsmodel.MediaAttachment
+	testStatuses     map[string]*gtsmodel.Status
+
+	// module being tested
+	statusModule *status.Module
+}
diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go
new file mode 100644
index 000000000..02080b042
--- /dev/null
+++ b/internal/api/client/status/statuscreate.go
@@ -0,0 +1,130 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// StatusCreatePOSTHandler deals with the creation of new statuses
+func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
+	l := m.log.WithField("func", "statusCreatePOSTHandler")
+	authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything*
+	if err != nil {
+		l.Debugf("couldn't auth: %s", err)
+		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+		return
+	}
+
+	// First check this user/account is permitted to post new statuses.
+	// There's no point continuing otherwise.
+	if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+		l.Debugf("couldn't auth: %s", err)
+		c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+		return
+	}
+
+	// extract the status create form from the request context
+	l.Tracef("parsing request form: %s", c.Request.Form)
+	form := &model.AdvancedStatusCreateForm{}
+	if err := c.ShouldBind(form); err != nil || form == nil {
+		l.Debugf("could not parse form from request: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+		return
+	}
+
+	// Give the fields on the request form a first pass to make sure the request is superficially valid.
+	l.Tracef("validating form %+v", form)
+	if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
+		l.Debugf("error validating form: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	mastoStatus, err := m.processor.StatusCreate(authed, form)
+	if err != nil {
+		l.Debugf("error processing status create: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	c.JSON(http.StatusOK, mastoStatus)
+}
+
+func validateCreateStatus(form *model.AdvancedStatusCreateForm, config *config.StatusesConfig) error {
+	// validate that, structurally, we have a valid status/post
+	if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
+		return errors.New("no status, media, or poll provided")
+	}
+
+	if form.MediaIDs != nil && form.Poll != nil {
+		return errors.New("can't post media + poll in same status")
+	}
+
+	// validate status
+	if form.Status != "" {
+		if len(form.Status) > config.MaxChars {
+			return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
+		}
+	}
+
+	// validate media attachments
+	if len(form.MediaIDs) > config.MaxMediaFiles {
+		return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
+	}
+
+	// validate poll
+	if form.Poll != nil {
+		if form.Poll.Options == nil {
+			return errors.New("poll with no options")
+		}
+		if len(form.Poll.Options) > config.PollMaxOptions {
+			return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
+		}
+		for _, p := range form.Poll.Options {
+			if len(p) > config.PollOptionMaxChars {
+				return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
+			}
+		}
+	}
+
+	// validate spoiler text/cw
+	if form.SpoilerText != "" {
+		if len(form.SpoilerText) > config.CWMaxChars {
+			return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
+		}
+	}
+
+	// validate post language
+	if form.Language != "" {
+		if err := util.ValidateLanguage(form.Language); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go
similarity index 79%
rename from internal/apimodule/status/test/statuscreate_test.go
rename to internal/api/client/status/statuscreate_test.go
index d143ac9a7..fb9b48f8a 100644
--- a/internal/apimodule/status/test/statuscreate_test.go
+++ b/internal/api/client/status/statuscreate_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package status
+package status_test
 
 import (
 	"encoding/json"
@@ -28,95 +28,46 @@ import (
 	"testing"
 
 	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type StatusCreateTestSuite struct {
-	// standard suite interfaces
-	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
-	distributor    distributor.Distributor
-
-	// standard suite models
-	testTokens       map[string]*oauth.Token
-	testClients      map[string]*oauth.Client
-	testApplications map[string]*gtsmodel.Application
-	testUsers        map[string]*gtsmodel.User
-	testAccounts     map[string]*gtsmodel.Account
-	testAttachments  map[string]*gtsmodel.MediaAttachment
-
-	// module being tested
-	statusModule *status.Module
+	StatusStandardTestSuite
 }
 
-/*
-	TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
 func (suite *StatusCreateTestSuite) SetupSuite() {
-	// setup standard items
-	suite.config = testrig.NewTestConfig()
-	suite.db = testrig.NewTestDB()
-	suite.log = testrig.NewTestLog()
-	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
-	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
-	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-	suite.distributor = testrig.NewTestDistributor()
-
-	// setup module being tested
-	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusCreateTestSuite) TearDownSuite() {
-	testrig.StandardDBTeardown(suite.db)
-	testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusCreateTestSuite) SetupTest() {
-	testrig.StandardDBSetup(suite.db)
-	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
 	suite.testTokens = testrig.NewTestTokens()
 	suite.testClients = testrig.NewTestClients()
 	suite.testApplications = testrig.NewTestApplications()
 	suite.testUsers = testrig.NewTestUsers()
 	suite.testAccounts = testrig.NewTestAccounts()
 	suite.testAttachments = testrig.NewTestAttachments()
+	suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusCreateTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
 }
 
-// TearDownTest drops tables to make sure there's no data in the db
 func (suite *StatusCreateTestSuite) TearDownTest() {
 	testrig.StandardDBTeardown(suite.db)
+	testrig.StandardStorageTeardown(suite.storage)
 }
 
-/*
-	ACTUAL TESTS
-*/
-
-/*
-	TESTING: StatusCreatePOSTHandler
-*/
-
 // Post a new status with some custom visibility settings
 func (suite *StatusCreateTestSuite) TestPostNewStatus() {
 
@@ -152,16 +103,16 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
 	assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
 	assert.True(suite.T(), statusReply.Sensitive)
-	assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
+	assert.Equal(suite.T(), model.VisibilityPrivate, statusReply.Visibility)
 	assert.Len(suite.T(), statusReply.Tags, 1)
-	assert.Equal(suite.T(), mastomodel.Tag{
+	assert.Equal(suite.T(), model.Tag{
 		Name: "helloworld",
 		URL:  "http://localhost:8080/tags/helloworld",
 	}, statusReply.Tags[0])
@@ -197,7 +148,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
@@ -241,7 +192,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
 	defer result.Body.Close()
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b))
+	assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
 }
 
 // Post a reply to the status of a local user that allows replies.
@@ -271,14 +222,14 @@ func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), "", statusReply.SpoilerText)
 	assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
 	assert.False(suite.T(), statusReply.Sensitive)
-	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
 	assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
 	assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
 	assert.Len(suite.T(), statusReply.Mentions, 1)
@@ -313,14 +264,14 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
 
 	fmt.Println(string(b))
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), "", statusReply.SpoilerText)
 	assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
 	assert.False(suite.T(), statusReply.Sensitive)
-	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
 
 	// there should be one media attachment
 	assert.Len(suite.T(), statusReply.MediaAttachments, 1)
@@ -331,7 +282,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
 	assert.NoError(suite.T(), err)
 
 	// convert it to a masto attachment
-	gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment)
+	gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment)
 	assert.NoError(suite.T(), err)
 
 	// compare it with what we have now
diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go
new file mode 100644
index 000000000..e55416522
--- /dev/null
+++ b/internal/api/client/status/statusdelete.go
@@ -0,0 +1,60 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusDELETEHandler verifies and handles deletion of a status
+func (m *Module) StatusDELETEHandler(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func":        "StatusDELETEHandler",
+		"request_uri": c.Request.RequestURI,
+		"user_agent":  c.Request.UserAgent(),
+		"origin_ip":   c.ClientIP(),
+	})
+	l.Debugf("entering function")
+
+	authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+	if err != nil {
+		l.Debug("not authed so can't delete status")
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+		return
+	}
+
+	targetStatusID := c.Param(IDKey)
+	if targetStatusID == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+		return
+	}
+
+	mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID)
+	if err != nil {
+		l.Debugf("error processing status delete: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go
new file mode 100644
index 000000000..888589a8a
--- /dev/null
+++ b/internal/api/client/status/statusfave.go
@@ -0,0 +1,60 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavePOSTHandler handles fave requests against a given status ID
+func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func":        "StatusFavePOSTHandler",
+		"request_uri": c.Request.RequestURI,
+		"user_agent":  c.Request.UserAgent(),
+		"origin_ip":   c.ClientIP(),
+	})
+	l.Debugf("entering function")
+
+	authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+	if err != nil {
+		l.Debug("not authed so can't fave status")
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+		return
+	}
+
+	targetStatusID := c.Param(IDKey)
+	if targetStatusID == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+		return
+	}
+
+	mastoStatus, err := m.processor.StatusFave(authed, targetStatusID)
+	if err != nil {
+		l.Debugf("error processing status fave: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/api/client/status/statusfave_test.go
similarity index 67%
rename from internal/apimodule/status/test/statusfave_test.go
rename to internal/api/client/status/statusfave_test.go
index 9ccf58948..2f779baed 100644
--- a/internal/apimodule/status/test/statusfave_test.go
+++ b/internal/api/client/status/statusfave_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package status
+package status_test
 
 import (
 	"encoding/json"
@@ -28,75 +28,19 @@ import (
 	"testing"
 
 	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type StatusFaveTestSuite struct {
-	// standard suite interfaces
-	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
-	distributor    distributor.Distributor
-
-	// standard suite models
-	testTokens       map[string]*oauth.Token
-	testClients      map[string]*oauth.Client
-	testApplications map[string]*gtsmodel.Application
-	testUsers        map[string]*gtsmodel.User
-	testAccounts     map[string]*gtsmodel.Account
-	testAttachments  map[string]*gtsmodel.MediaAttachment
-	testStatuses     map[string]*gtsmodel.Status
-
-	// module being tested
-	statusModule *status.Module
+	StatusStandardTestSuite
 }
 
-/*
-	TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
 func (suite *StatusFaveTestSuite) SetupSuite() {
-	// setup standard items
-	suite.config = testrig.NewTestConfig()
-	suite.db = testrig.NewTestDB()
-	suite.log = testrig.NewTestLog()
-	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
-	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
-	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-	suite.distributor = testrig.NewTestDistributor()
-
-	// setup module being tested
-	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusFaveTestSuite) TearDownSuite() {
-	testrig.StandardDBTeardown(suite.db)
-	testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusFaveTestSuite) SetupTest() {
-	testrig.StandardDBSetup(suite.db)
-	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
 	suite.testTokens = testrig.NewTestTokens()
 	suite.testClients = testrig.NewTestClients()
 	suite.testApplications = testrig.NewTestApplications()
@@ -106,16 +50,23 @@ func (suite *StatusFaveTestSuite) SetupTest() {
 	suite.testStatuses = testrig.NewTestStatuses()
 }
 
-// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusFaveTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
 func (suite *StatusFaveTestSuite) TearDownTest() {
 	testrig.StandardDBTeardown(suite.db)
 	testrig.StandardStorageTeardown(suite.storage)
 }
 
-/*
-	ACTUAL TESTS
-*/
-
 // fave a status
 func (suite *StatusFaveTestSuite) TestPostFave() {
 
@@ -152,14 +103,14 @@ func (suite *StatusFaveTestSuite) TestPostFave() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
 	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
 	assert.True(suite.T(), statusReply.Sensitive)
-	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
 	assert.True(suite.T(), statusReply.Favourited)
 	assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
 }
@@ -193,13 +144,13 @@ func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
 	suite.statusModule.StatusFavePOSTHandler(ctx)
 
 	// check response
-	suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses
+	suite.EqualValues(http.StatusBadRequest, recorder.Code)
 
 	result := recorder.Result()
 	defer result.Body.Close()
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b))
+	assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
 }
 
 func TestStatusFaveTestSuite(t *testing.T) {
diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go
new file mode 100644
index 000000000..799acb7d2
--- /dev/null
+++ b/internal/api/client/status/statusfavedby.go
@@ -0,0 +1,60 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
+func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func":        "statusGETHandler",
+		"request_uri": c.Request.RequestURI,
+		"user_agent":  c.Request.UserAgent(),
+		"origin_ip":   c.ClientIP(),
+	})
+	l.Debugf("entering function")
+
+	authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+	if err != nil {
+		l.Errorf("error authing status faved by request: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+		return
+	}
+
+	targetStatusID := c.Param(IDKey)
+	if targetStatusID == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+		return
+	}
+
+	mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID)
+	if err != nil {
+		l.Debugf("error processing status faved by request: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	c.JSON(http.StatusOK, mastoAccounts)
+}
diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go
similarity index 62%
rename from internal/apimodule/status/test/statusfavedby_test.go
rename to internal/api/client/status/statusfavedby_test.go
index 169543a81..7b72df7bc 100644
--- a/internal/apimodule/status/test/statusfavedby_test.go
+++ b/internal/api/client/status/statusfavedby_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package status
+package status_test
 
 import (
 	"encoding/json"
@@ -28,71 +28,19 @@ import (
 	"testing"
 
 	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type StatusFavedByTestSuite struct {
-	// standard suite interfaces
-	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
-	distributor    distributor.Distributor
-
-	// standard suite models
-	testTokens       map[string]*oauth.Token
-	testClients      map[string]*oauth.Client
-	testApplications map[string]*gtsmodel.Application
-	testUsers        map[string]*gtsmodel.User
-	testAccounts     map[string]*gtsmodel.Account
-	testAttachments  map[string]*gtsmodel.MediaAttachment
-	testStatuses     map[string]*gtsmodel.Status
-
-	// module being tested
-	statusModule *status.Module
+	StatusStandardTestSuite
 }
 
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
 func (suite *StatusFavedByTestSuite) SetupSuite() {
-	// setup standard items
-	suite.config = testrig.NewTestConfig()
-	suite.db = testrig.NewTestDB()
-	suite.log = testrig.NewTestLog()
-	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
-	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
-	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-	suite.distributor = testrig.NewTestDistributor()
-
-	// setup module being tested
-	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusFavedByTestSuite) TearDownSuite() {
-	testrig.StandardDBTeardown(suite.db)
-	testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusFavedByTestSuite) SetupTest() {
-	testrig.StandardDBSetup(suite.db)
-	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
 	suite.testTokens = testrig.NewTestTokens()
 	suite.testClients = testrig.NewTestClients()
 	suite.testApplications = testrig.NewTestApplications()
@@ -102,16 +50,23 @@ func (suite *StatusFavedByTestSuite) SetupTest() {
 	suite.testStatuses = testrig.NewTestStatuses()
 }
 
-// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusFavedByTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
 func (suite *StatusFavedByTestSuite) TearDownTest() {
 	testrig.StandardDBTeardown(suite.db)
 	testrig.StandardStorageTeardown(suite.storage)
 }
 
-/*
-	ACTUAL TESTS
-*/
-
 func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
 	t := suite.testTokens["local_account_2"]
 	oauthToken := oauth.TokenToOauthToken(t)
@@ -146,7 +101,7 @@ func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	accts := []mastomodel.Account{}
+	accts := []model.Account{}
 	err = json.Unmarshal(b, &accts)
 	assert.NoError(suite.T(), err)
 
diff --git a/internal/api/client/status/statusget.go b/internal/api/client/status/statusget.go
new file mode 100644
index 000000000..c6239cb36
--- /dev/null
+++ b/internal/api/client/status/statusget.go
@@ -0,0 +1,60 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusGETHandler is for handling requests to just get one status based on its ID
+func (m *Module) StatusGETHandler(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func":        "statusGETHandler",
+		"request_uri": c.Request.RequestURI,
+		"user_agent":  c.Request.UserAgent(),
+		"origin_ip":   c.ClientIP(),
+	})
+	l.Debugf("entering function")
+
+	authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+	if err != nil {
+		l.Errorf("error authing status faved by request: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+		return
+	}
+
+	targetStatusID := c.Param(IDKey)
+	if targetStatusID == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+		return
+	}
+
+	mastoStatus, err := m.processor.StatusGet(authed, targetStatusID)
+	if err != nil {
+		l.Debugf("error processing status get: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/api/client/status/statusget_test.go
similarity index 62%
rename from internal/apimodule/status/test/statusget_test.go
rename to internal/api/client/status/statusget_test.go
index ce817d247..b31acebca 100644
--- a/internal/apimodule/status/test/statusget_test.go
+++ b/internal/api/client/status/statusget_test.go
@@ -16,98 +16,47 @@
    along with this program.  If not, see .
 */
 
-package status
+package status_test
 
 import (
 	"testing"
 
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type StatusGetTestSuite struct {
-	// standard suite interfaces
-	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
-	distributor    distributor.Distributor
-
-	// standard suite models
-	testTokens       map[string]*oauth.Token
-	testClients      map[string]*oauth.Client
-	testApplications map[string]*gtsmodel.Application
-	testUsers        map[string]*gtsmodel.User
-	testAccounts     map[string]*gtsmodel.Account
-	testAttachments  map[string]*gtsmodel.MediaAttachment
-
-	// module being tested
-	statusModule *status.Module
+	StatusStandardTestSuite
 }
 
-/*
-	TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
 func (suite *StatusGetTestSuite) SetupSuite() {
-	// setup standard items
-	suite.config = testrig.NewTestConfig()
-	suite.db = testrig.NewTestDB()
-	suite.log = testrig.NewTestLog()
-	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
-	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
-	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-	suite.distributor = testrig.NewTestDistributor()
-
-	// setup module being tested
-	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusGetTestSuite) TearDownSuite() {
-	testrig.StandardDBTeardown(suite.db)
-	testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusGetTestSuite) SetupTest() {
-	testrig.StandardDBSetup(suite.db)
-	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
 	suite.testTokens = testrig.NewTestTokens()
 	suite.testClients = testrig.NewTestClients()
 	suite.testApplications = testrig.NewTestApplications()
 	suite.testUsers = testrig.NewTestUsers()
 	suite.testAccounts = testrig.NewTestAccounts()
 	suite.testAttachments = testrig.NewTestAttachments()
+	suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusGetTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
 }
 
-// TearDownTest drops tables to make sure there's no data in the db
 func (suite *StatusGetTestSuite) TearDownTest() {
 	testrig.StandardDBTeardown(suite.db)
+	testrig.StandardStorageTeardown(suite.storage)
 }
 
-/*
-	ACTUAL TESTS
-*/
-
-/*
-	TESTING: StatusGetPOSTHandler
-*/
-
 // Post a new status with some custom visibility settings
 func (suite *StatusGetTestSuite) TestPostNewStatus() {
 
@@ -143,16 +92,16 @@ func (suite *StatusGetTestSuite) TestPostNewStatus() {
 	// b, err := ioutil.ReadAll(result.Body)
 	// assert.NoError(suite.T(), err)
 
-	// statusReply := &mastomodel.Status{}
+	// statusReply := &mastotypes.Status{}
 	// err = json.Unmarshal(b, statusReply)
 	// assert.NoError(suite.T(), err)
 
 	// assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
 	// assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
 	// assert.True(suite.T(), statusReply.Sensitive)
-	// assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
+	// assert.Equal(suite.T(), mastotypes.VisibilityPrivate, statusReply.Visibility)
 	// assert.Len(suite.T(), statusReply.Tags, 1)
-	// assert.Equal(suite.T(), mastomodel.Tag{
+	// assert.Equal(suite.T(), mastotypes.Tag{
 	// 	Name: "helloworld",
 	// 	URL:  "http://localhost:8080/tags/helloworld",
 	// }, statusReply.Tags[0])
diff --git a/internal/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go
new file mode 100644
index 000000000..94fd662de
--- /dev/null
+++ b/internal/api/client/status/statusunfave.go
@@ -0,0 +1,60 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package status
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
+func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func":        "StatusUnfavePOSTHandler",
+		"request_uri": c.Request.RequestURI,
+		"user_agent":  c.Request.UserAgent(),
+		"origin_ip":   c.ClientIP(),
+	})
+	l.Debugf("entering function")
+
+	authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+	if err != nil {
+		l.Debug("not authed so can't unfave status")
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+		return
+	}
+
+	targetStatusID := c.Param(IDKey)
+	if targetStatusID == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+		return
+	}
+
+	mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID)
+	if err != nil {
+		l.Debugf("error processing status unfave: %s", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go
similarity index 70%
rename from internal/apimodule/status/test/statusunfave_test.go
rename to internal/api/client/status/statusunfave_test.go
index 5f5277921..44b1dd3a6 100644
--- a/internal/apimodule/status/test/statusunfave_test.go
+++ b/internal/api/client/status/statusunfave_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package status
+package status_test
 
 import (
 	"encoding/json"
@@ -28,75 +28,19 @@ import (
 	"testing"
 
 	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
 type StatusUnfaveTestSuite struct {
-	// standard suite interfaces
-	suite.Suite
-	config         *config.Config
-	db             db.DB
-	log            *logrus.Logger
-	storage        storage.Storage
-	mastoConverter mastotypes.Converter
-	mediaHandler   media.Handler
-	oauthServer    oauth.Server
-	distributor    distributor.Distributor
-
-	// standard suite models
-	testTokens       map[string]*oauth.Token
-	testClients      map[string]*oauth.Client
-	testApplications map[string]*gtsmodel.Application
-	testUsers        map[string]*gtsmodel.User
-	testAccounts     map[string]*gtsmodel.Account
-	testAttachments  map[string]*gtsmodel.MediaAttachment
-	testStatuses     map[string]*gtsmodel.Status
-
-	// module being tested
-	statusModule *status.Module
+	StatusStandardTestSuite
 }
 
-/*
-	TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
 func (suite *StatusUnfaveTestSuite) SetupSuite() {
-	// setup standard items
-	suite.config = testrig.NewTestConfig()
-	suite.db = testrig.NewTestDB()
-	suite.log = testrig.NewTestLog()
-	suite.storage = testrig.NewTestStorage()
-	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
-	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
-	suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-	suite.distributor = testrig.NewTestDistributor()
-
-	// setup module being tested
-	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusUnfaveTestSuite) TearDownSuite() {
-	testrig.StandardDBTeardown(suite.db)
-	testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusUnfaveTestSuite) SetupTest() {
-	testrig.StandardDBSetup(suite.db)
-	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
 	suite.testTokens = testrig.NewTestTokens()
 	suite.testClients = testrig.NewTestClients()
 	suite.testApplications = testrig.NewTestApplications()
@@ -106,16 +50,23 @@ func (suite *StatusUnfaveTestSuite) SetupTest() {
 	suite.testStatuses = testrig.NewTestStatuses()
 }
 
-// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusUnfaveTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
 func (suite *StatusUnfaveTestSuite) TearDownTest() {
 	testrig.StandardDBTeardown(suite.db)
 	testrig.StandardStorageTeardown(suite.storage)
 }
 
-/*
-	ACTUAL TESTS
-*/
-
 // unfave a status
 func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
 
@@ -153,14 +104,14 @@ func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
 	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
 	assert.False(suite.T(), statusReply.Sensitive)
-	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
 	assert.False(suite.T(), statusReply.Favourited)
 	assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
 }
@@ -202,14 +153,14 @@ func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
 	b, err := ioutil.ReadAll(result.Body)
 	assert.NoError(suite.T(), err)
 
-	statusReply := &mastomodel.Status{}
+	statusReply := &model.Status{}
 	err = json.Unmarshal(b, statusReply)
 	assert.NoError(suite.T(), err)
 
 	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
 	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
 	assert.True(suite.T(), statusReply.Sensitive)
-	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
 	assert.False(suite.T(), statusReply.Favourited)
 	assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
 }
diff --git a/internal/mastotypes/mastomodel/account.go b/internal/api/model/account.go
similarity index 97%
rename from internal/mastotypes/mastomodel/account.go
rename to internal/api/model/account.go
index bbcf9c90f..efb69d6fd 100644
--- a/internal/mastotypes/mastomodel/account.go
+++ b/internal/api/model/account.go
@@ -16,9 +16,12 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
-import "mime/multipart"
+import (
+	"mime/multipart"
+	"net"
+)
 
 // Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/
 type Account struct {
@@ -86,6 +89,8 @@ type AccountCreateRequest struct {
 	Agreement bool `form:"agreement" binding:"required"`
 	// The language of the confirmation email that will be sent
 	Locale string `form:"locale" binding:"required"`
+	// The IP of the sign up request, will not be parsed from the form but must be added manually
+	IP net.IP `form:"-"`
 }
 
 // UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials.
diff --git a/internal/mastotypes/mastomodel/activity.go b/internal/api/model/activity.go
similarity index 98%
rename from internal/mastotypes/mastomodel/activity.go
rename to internal/api/model/activity.go
index b8dbf2c1b..c1736a8d6 100644
--- a/internal/mastotypes/mastomodel/activity.go
+++ b/internal/api/model/activity.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/
 type Activity struct {
diff --git a/internal/mastotypes/mastomodel/admin.go b/internal/api/model/admin.go
similarity index 99%
rename from internal/mastotypes/mastomodel/admin.go
rename to internal/api/model/admin.go
index 71c2bb309..036218f77 100644
--- a/internal/mastotypes/mastomodel/admin.go
+++ b/internal/api/model/admin.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/
 type AdminAccountInfo struct {
diff --git a/internal/mastotypes/mastomodel/announcement.go b/internal/api/model/announcement.go
similarity index 98%
rename from internal/mastotypes/mastomodel/announcement.go
rename to internal/api/model/announcement.go
index 882d6bb9b..eeb4b8720 100644
--- a/internal/mastotypes/mastomodel/announcement.go
+++ b/internal/api/model/announcement.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/
 type Announcement struct {
diff --git a/internal/mastotypes/mastomodel/announcementreaction.go b/internal/api/model/announcementreaction.go
similarity index 98%
rename from internal/mastotypes/mastomodel/announcementreaction.go
rename to internal/api/model/announcementreaction.go
index 444c57e2c..81118fef0 100644
--- a/internal/mastotypes/mastomodel/announcementreaction.go
+++ b/internal/api/model/announcementreaction.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/
 type AnnouncementReaction struct {
diff --git a/internal/mastotypes/mastomodel/application.go b/internal/api/model/application.go
similarity index 94%
rename from internal/mastotypes/mastomodel/application.go
rename to internal/api/model/application.go
index 6140a0127..a796c88ea 100644
--- a/internal/mastotypes/mastomodel/application.go
+++ b/internal/api/model/application.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/.
 // Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user.
@@ -38,10 +38,10 @@ type Application struct {
 	VapidKey string `json:"vapid_key,omitempty"`
 }
 
-// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps.
+// ApplicationCreateRequest represents a POST request to https://example.org/api/v1/apps.
 // See here: https://docs.joinmastodon.org/methods/apps/
 // And here: https://docs.joinmastodon.org/client/token/
-type ApplicationPOSTRequest struct {
+type ApplicationCreateRequest struct {
 	// A name for your application
 	ClientName string `form:"client_name" binding:"required"`
 	// Where the user should be redirected after authorization.
diff --git a/internal/mastotypes/mastomodel/attachment.go b/internal/api/model/attachment.go
similarity index 99%
rename from internal/mastotypes/mastomodel/attachment.go
rename to internal/api/model/attachment.go
index bda79a8ee..d90247f83 100644
--- a/internal/mastotypes/mastomodel/attachment.go
+++ b/internal/api/model/attachment.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 import "mime/multipart"
 
diff --git a/internal/mastotypes/mastomodel/card.go b/internal/api/model/card.go
similarity index 99%
rename from internal/mastotypes/mastomodel/card.go
rename to internal/api/model/card.go
index d1147e04b..ffa6d53e5 100644
--- a/internal/mastotypes/mastomodel/card.go
+++ b/internal/api/model/card.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Card represents a rich preview card that is generated using OpenGraph tags from a URL. See here: https://docs.joinmastodon.org/entities/card/
 type Card struct {
diff --git a/internal/api/model/content.go b/internal/api/model/content.go
new file mode 100644
index 000000000..4f004f13c
--- /dev/null
+++ b/internal/api/model/content.go
@@ -0,0 +1,41 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package model
+
+// Content wraps everything needed to serve a blob of content (some kind of media) through the API.
+type Content struct {
+	// MIME content type
+	ContentType string
+	// ContentLength in bytes
+	ContentLength int64
+	// Actual content blob
+	Content []byte
+}
+
+// GetContentRequestForm describes a piece of content desired by the caller of the fileserver API.
+type GetContentRequestForm struct {
+	// AccountID of the content owner
+	AccountID string
+	// MediaType of the content (should be convertible to a media.MediaType)
+	MediaType string
+	// MediaSize of the content (should be convertible to a media.MediaSize)
+	MediaSize string
+	// Filename of the content
+	FileName string
+}
diff --git a/internal/mastotypes/mastomodel/context.go b/internal/api/model/context.go
similarity index 98%
rename from internal/mastotypes/mastomodel/context.go
rename to internal/api/model/context.go
index 397522dc7..d0979319b 100644
--- a/internal/mastotypes/mastomodel/context.go
+++ b/internal/api/model/context.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/
 type Context struct {
diff --git a/internal/mastotypes/mastomodel/conversation.go b/internal/api/model/conversation.go
similarity index 98%
rename from internal/mastotypes/mastomodel/conversation.go
rename to internal/api/model/conversation.go
index ed95c124c..b0568c17e 100644
--- a/internal/mastotypes/mastomodel/conversation.go
+++ b/internal/api/model/conversation.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/
 type Conversation struct {
diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/api/model/emoji.go
similarity index 98%
rename from internal/mastotypes/mastomodel/emoji.go
rename to internal/api/model/emoji.go
index c50ca6343..c2834718f 100644
--- a/internal/mastotypes/mastomodel/emoji.go
+++ b/internal/api/model/emoji.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 import "mime/multipart"
 
diff --git a/internal/mastotypes/mastomodel/error.go b/internal/api/model/error.go
similarity index 98%
rename from internal/mastotypes/mastomodel/error.go
rename to internal/api/model/error.go
index 394085724..f145d69f2 100644
--- a/internal/mastotypes/mastomodel/error.go
+++ b/internal/api/model/error.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/
 type Error struct {
diff --git a/internal/mastotypes/mastomodel/featuredtag.go b/internal/api/model/featuredtag.go
similarity index 98%
rename from internal/mastotypes/mastomodel/featuredtag.go
rename to internal/api/model/featuredtag.go
index 0e0bbe802..3df3fe4c9 100644
--- a/internal/mastotypes/mastomodel/featuredtag.go
+++ b/internal/api/model/featuredtag.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/
 type FeaturedTag struct {
diff --git a/internal/mastotypes/mastomodel/field.go b/internal/api/model/field.go
similarity index 98%
rename from internal/mastotypes/mastomodel/field.go
rename to internal/api/model/field.go
index 29b5a1803..2e7662b2b 100644
--- a/internal/mastotypes/mastomodel/field.go
+++ b/internal/api/model/field.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/
 type Field struct {
diff --git a/internal/mastotypes/mastomodel/filter.go b/internal/api/model/filter.go
similarity index 99%
rename from internal/mastotypes/mastomodel/filter.go
rename to internal/api/model/filter.go
index 86d9795a3..519922ba3 100644
--- a/internal/mastotypes/mastomodel/filter.go
+++ b/internal/api/model/filter.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Filter represents a user-defined filter for determining which statuses should not be shown to the user. See https://docs.joinmastodon.org/entities/filter/
 // If whole_word is true , client app should do:
diff --git a/internal/mastotypes/mastomodel/history.go b/internal/api/model/history.go
similarity index 98%
rename from internal/mastotypes/mastomodel/history.go
rename to internal/api/model/history.go
index 235761378..d8b4d6b4f 100644
--- a/internal/mastotypes/mastomodel/history.go
+++ b/internal/api/model/history.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/
 type History struct {
diff --git a/internal/mastotypes/mastomodel/identityproof.go b/internal/api/model/identityproof.go
similarity index 98%
rename from internal/mastotypes/mastomodel/identityproof.go
rename to internal/api/model/identityproof.go
index 7265d46e3..400835fca 100644
--- a/internal/mastotypes/mastomodel/identityproof.go
+++ b/internal/api/model/identityproof.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/
 type IdentityProof struct {
diff --git a/internal/mastotypes/mastomodel/instance.go b/internal/api/model/instance.go
similarity index 99%
rename from internal/mastotypes/mastomodel/instance.go
rename to internal/api/model/instance.go
index 10e626a8e..857a8acc5 100644
--- a/internal/mastotypes/mastomodel/instance.go
+++ b/internal/api/model/instance.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/
 type Instance struct {
diff --git a/internal/mastotypes/mastomodel/list.go b/internal/api/model/list.go
similarity index 98%
rename from internal/mastotypes/mastomodel/list.go
rename to internal/api/model/list.go
index 5b704367b..220cde59e 100644
--- a/internal/mastotypes/mastomodel/list.go
+++ b/internal/api/model/list.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/
 type List struct {
diff --git a/internal/mastotypes/mastomodel/marker.go b/internal/api/model/marker.go
similarity index 98%
rename from internal/mastotypes/mastomodel/marker.go
rename to internal/api/model/marker.go
index 790322313..1e39f1516 100644
--- a/internal/mastotypes/mastomodel/marker.go
+++ b/internal/api/model/marker.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/
 type Marker struct {
diff --git a/internal/mastotypes/mastomodel/mention.go b/internal/api/model/mention.go
similarity index 98%
rename from internal/mastotypes/mastomodel/mention.go
rename to internal/api/model/mention.go
index 81a593d99..a7985af24 100644
--- a/internal/mastotypes/mastomodel/mention.go
+++ b/internal/api/model/mention.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/
 type Mention struct {
diff --git a/internal/mastotypes/mastomodel/notification.go b/internal/api/model/notification.go
similarity index 98%
rename from internal/mastotypes/mastomodel/notification.go
rename to internal/api/model/notification.go
index 26d361b43..c8d080e2a 100644
--- a/internal/mastotypes/mastomodel/notification.go
+++ b/internal/api/model/notification.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/
 type Notification struct {
diff --git a/internal/mastotypes/mastomodel/oauth.go b/internal/api/model/oauth.go
similarity index 98%
rename from internal/mastotypes/mastomodel/oauth.go
rename to internal/api/model/oauth.go
index d93ea079f..250d2218f 100644
--- a/internal/mastotypes/mastomodel/oauth.go
+++ b/internal/api/model/oauth.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // OAuthAuthorize represents a request sent to https://example.org/oauth/authorize
 // See here: https://docs.joinmastodon.org/methods/apps/oauth/
diff --git a/internal/mastotypes/mastomodel/poll.go b/internal/api/model/poll.go
similarity index 99%
rename from internal/mastotypes/mastomodel/poll.go
rename to internal/api/model/poll.go
index bedaebec2..b00e7680a 100644
--- a/internal/mastotypes/mastomodel/poll.go
+++ b/internal/api/model/poll.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/
 type Poll struct {
diff --git a/internal/mastotypes/mastomodel/preferences.go b/internal/api/model/preferences.go
similarity index 98%
rename from internal/mastotypes/mastomodel/preferences.go
rename to internal/api/model/preferences.go
index c28f5d5ab..9e410091e 100644
--- a/internal/mastotypes/mastomodel/preferences.go
+++ b/internal/api/model/preferences.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/
 type Preferences struct {
diff --git a/internal/mastotypes/mastomodel/pushsubscription.go b/internal/api/model/pushsubscription.go
similarity index 99%
rename from internal/mastotypes/mastomodel/pushsubscription.go
rename to internal/api/model/pushsubscription.go
index 4d7535100..f34c63374 100644
--- a/internal/mastotypes/mastomodel/pushsubscription.go
+++ b/internal/api/model/pushsubscription.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/
 type PushSubscription struct {
diff --git a/internal/mastotypes/mastomodel/relationship.go b/internal/api/model/relationship.go
similarity index 99%
rename from internal/mastotypes/mastomodel/relationship.go
rename to internal/api/model/relationship.go
index 1e0bbab46..6e71023e2 100644
--- a/internal/mastotypes/mastomodel/relationship.go
+++ b/internal/api/model/relationship.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/
 type Relationship struct {
diff --git a/internal/mastotypes/mastomodel/results.go b/internal/api/model/results.go
similarity index 98%
rename from internal/mastotypes/mastomodel/results.go
rename to internal/api/model/results.go
index 3fa7c7abb..1b2625a0d 100644
--- a/internal/mastotypes/mastomodel/results.go
+++ b/internal/api/model/results.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/
 type Results struct {
diff --git a/internal/mastotypes/mastomodel/scheduledstatus.go b/internal/api/model/scheduledstatus.go
similarity index 98%
rename from internal/mastotypes/mastomodel/scheduledstatus.go
rename to internal/api/model/scheduledstatus.go
index ff45eaade..deafd22aa 100644
--- a/internal/mastotypes/mastomodel/scheduledstatus.go
+++ b/internal/api/model/scheduledstatus.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/
 type ScheduledStatus struct {
diff --git a/internal/mastotypes/mastomodel/source.go b/internal/api/model/source.go
similarity index 98%
rename from internal/mastotypes/mastomodel/source.go
rename to internal/api/model/source.go
index 0445a1ffb..441af71de 100644
--- a/internal/mastotypes/mastomodel/source.go
+++ b/internal/api/model/source.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Source represents display or publishing preferences of user's own account.
 // Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
diff --git a/internal/mastotypes/mastomodel/status.go b/internal/api/model/status.go
similarity index 90%
rename from internal/mastotypes/mastomodel/status.go
rename to internal/api/model/status.go
index f5cc07a06..faf88ae84 100644
--- a/internal/mastotypes/mastomodel/status.go
+++ b/internal/api/model/status.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/
 type Status struct {
@@ -118,3 +118,21 @@ const (
 	// VisibilityDirect means visible only to tagged recipients
 	VisibilityDirect Visibility = "direct"
 )
+
+type AdvancedStatusCreateForm struct {
+	StatusCreateRequest
+	AdvancedVisibilityFlagsForm
+}
+
+type AdvancedVisibilityFlagsForm struct {
+	// The gotosocial visibility model
+	VisibilityAdvanced *string `form:"visibility_advanced"`
+	// This status will be federated beyond the local timeline(s)
+	Federated *bool `form:"federated"`
+	// This status can be boosted/reblogged
+	Boostable *bool `form:"boostable"`
+	// This status can be replied to
+	Replyable *bool `form:"replyable"`
+	// This status can be liked/faved
+	Likeable *bool `form:"likeable"`
+}
diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/api/model/tag.go
similarity index 98%
rename from internal/mastotypes/mastomodel/tag.go
rename to internal/api/model/tag.go
index 82e6e6618..f009b4cef 100644
--- a/internal/mastotypes/mastomodel/tag.go
+++ b/internal/api/model/tag.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/
 type Tag struct {
diff --git a/internal/mastotypes/mastomodel/token.go b/internal/api/model/token.go
similarity index 98%
rename from internal/mastotypes/mastomodel/token.go
rename to internal/api/model/token.go
index c9ac1f177..611ab214c 100644
--- a/internal/mastotypes/mastomodel/token.go
+++ b/internal/api/model/token.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package model
 
 // Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/
 type Token struct {
diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go
new file mode 100644
index 000000000..693fac7c3
--- /dev/null
+++ b/internal/api/s2s/user/user.go
@@ -0,0 +1,70 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package user
+
+import (
+	"net/http"
+
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
+	"github.com/superseriousbusiness/gotosocial/internal/router"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+const (
+	// UsernameKey is for account usernames.
+	UsernameKey = "username"
+	// UsersBasePath is the base path for serving information about Users eg https://example.org/users
+	UsersBasePath = "/" + util.UsersPath
+	// UsersBasePathWithUsername is just the users base path with the Username key in it.
+	// Use this anywhere you need to know the username of the user being queried.
+	// Eg https://example.org/users/:username
+	UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey
+)
+
+// ActivityPubAcceptHeaders represents the Accept headers mentioned here:
+// https://www.w3.org/TR/activitypub/#retrieving-objects
+var ActivityPubAcceptHeaders = []string{
+	`application/activity+json`,
+	`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`,
+}
+
+// Module implements the FederationAPIModule interface
+type Module struct {
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.FederationModule {
+	return &Module{
+		config:    config,
+		processor: processor,
+		log:       log,
+	}
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+	s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler)
+	return nil
+}
diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go
new file mode 100644
index 000000000..84e35ab68
--- /dev/null
+++ b/internal/api/s2s/user/user_test.go
@@ -0,0 +1,40 @@
+package user_test
+
+import (
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type UserStandardTestSuite struct {
+	// standard suite interfaces
+	suite.Suite
+	config    *config.Config
+	db        db.DB
+	log       *logrus.Logger
+	tc        typeutils.TypeConverter
+	federator federation.Federator
+	processor message.Processor
+	storage   storage.Storage
+
+	// standard suite models
+	testTokens       map[string]*oauth.Token
+	testClients      map[string]*oauth.Client
+	testApplications map[string]*gtsmodel.Application
+	testUsers        map[string]*gtsmodel.User
+	testAccounts     map[string]*gtsmodel.Account
+	testAttachments  map[string]*gtsmodel.MediaAttachment
+	testStatuses     map[string]*gtsmodel.Status
+
+	// module being tested
+	userModule *user.Module
+}
diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go
new file mode 100644
index 000000000..8df137f44
--- /dev/null
+++ b/internal/api/s2s/user/userget.go
@@ -0,0 +1,67 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package user
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+)
+
+// UsersGETHandler should be served at https://example.org/users/:username.
+//
+// The goal here is to return the activitypub representation of an account
+// in the form of a vocab.ActivityStreamsPerson. This should only be served
+// to REMOTE SERVERS that present a valid signature on the GET request, on
+// behalf of a user, otherwise we risk leaking information about users publicly.
+//
+// And of course, the request should be refused if the account or server making the
+// request is blocked.
+func (m *Module) UsersGETHandler(c *gin.Context) {
+	l := m.log.WithFields(logrus.Fields{
+		"func": "UsersGETHandler",
+		"url":  c.Request.RequestURI,
+	})
+
+	requestedUsername := c.Param(UsernameKey)
+	if requestedUsername == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
+		return
+	}
+
+	// make sure this actually an AP request
+	format := c.NegotiateFormat(ActivityPubAcceptHeaders...)
+	if format == "" {
+		c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"})
+		return
+	}
+	l.Tracef("negotiated format: %s", format)
+
+	// make a copy of the context to pass along so we don't break anything
+	cp := c.Copy()
+	user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetAPUser handles auth as well
+	if err != nil {
+		l.Info(err.Error())
+		c.JSON(err.Code(), gin.H{"error": err.Safe()})
+		return
+	}
+
+	c.JSON(http.StatusOK, user)
+}
diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go
new file mode 100644
index 000000000..b45b01b63
--- /dev/null
+++ b/internal/api/s2s/user/userget_test.go
@@ -0,0 +1,155 @@
+package user_test
+
+import (
+	"bytes"
+	"context"
+	"crypto/x509"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/go-fed/activity/streams"
+	"github.com/go-fed/activity/streams/vocab"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type UserGetTestSuite struct {
+	UserStandardTestSuite
+}
+
+func (suite *UserGetTestSuite) SetupSuite() {
+	suite.testTokens = testrig.NewTestTokens()
+	suite.testClients = testrig.NewTestClients()
+	suite.testApplications = testrig.NewTestApplications()
+	suite.testUsers = testrig.NewTestUsers()
+	suite.testAccounts = testrig.NewTestAccounts()
+	suite.testAttachments = testrig.NewTestAttachments()
+	suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *UserGetTestSuite) SetupTest() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.tc = testrig.NewTestTypeConverter(suite.db)
+	suite.storage = testrig.NewTestStorage()
+	suite.log = testrig.NewTestLog()
+	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+	suite.userModule = user.New(suite.config, suite.processor, suite.log).(*user.Module)
+	testrig.StandardDBSetup(suite.db)
+	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *UserGetTestSuite) TearDownTest() {
+	testrig.StandardDBTeardown(suite.db)
+	testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *UserGetTestSuite) TestGetUser() {
+	// the dereference we're gonna use
+	signedRequest := testrig.NewTestDereferenceRequests(suite.testAccounts)["foss_satan_dereference_zork"]
+
+	requestingAccount := suite.testAccounts["remote_account_1"]
+	targetAccount := suite.testAccounts["local_account_1"]
+
+	encodedPublicKey, err := x509.MarshalPKIXPublicKey(requestingAccount.PublicKey)
+	assert.NoError(suite.T(), err)
+	publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+		Type:  "PUBLIC KEY",
+		Bytes: encodedPublicKey,
+	})
+	publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n")
+
+	// for this test we need the client to return the public key of the requester on the 'remote' instance
+	responseBodyString := fmt.Sprintf(`
+	{
+		"@context": [
+			"https://www.w3.org/ns/activitystreams",
+			"https://w3id.org/security/v1"
+		],
+
+		"id": "%s",
+		"type": "Person",
+		"preferredUsername": "%s",
+		"inbox": "%s",
+
+		"publicKey": {
+			"id": "%s",
+			"owner": "%s",
+			"publicKeyPem": "%s"
+		}
+	}`, requestingAccount.URI, requestingAccount.Username, requestingAccount.InboxURI, requestingAccount.PublicKeyURI, requestingAccount.URI, publicKeyString)
+
+	// create a transport controller whose client will just return the response body string we specified above
+	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+		r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString)))
+		return &http.Response{
+			StatusCode: 200,
+			Body:       r,
+		}, nil
+	}))
+	// get this transport controller embedded right in the user module we're testing
+	federator := testrig.NewTestFederator(suite.db, tc)
+	processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
+	userModule := user.New(suite.config, processor, suite.log).(*user.Module)
+
+	// setup request
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(user.UsersBasePathWithUsername, ":username", targetAccount.Username, 1)), nil) // the endpoint we're hitting
+
+	// normally the router would populate these params from the path values,
+	// but because we're calling the function directly, we need to set them manually.
+	ctx.Params = gin.Params{
+		gin.Param{
+			Key:   user.UsernameKey,
+			Value: targetAccount.Username,
+		},
+	}
+
+	// we need these headers for the request to be validated
+	ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
+	ctx.Request.Header.Set("Date", signedRequest.DateHeader)
+	ctx.Request.Header.Set("Digest", signedRequest.DigestHeader)
+
+	// trigger the function being tested
+	userModule.UsersGETHandler(ctx)
+
+	// check response
+	suite.EqualValues(http.StatusOK, recorder.Code)
+
+	result := recorder.Result()
+	defer result.Body.Close()
+	b, err := ioutil.ReadAll(result.Body)
+	assert.NoError(suite.T(), err)
+
+	// should be a Person
+	m := make(map[string]interface{})
+	err = json.Unmarshal(b, &m)
+	assert.NoError(suite.T(), err)
+
+	t, err := streams.ToType(context.Background(), m)
+	assert.NoError(suite.T(), err)
+
+	person, ok := t.(vocab.ActivityStreamsPerson)
+	assert.True(suite.T(), ok)
+
+	// convert person to account
+	// since this account is already known, we should get a pretty full model of it from the conversion
+	a, err := suite.tc.ASRepresentationToAccount(person)
+	assert.NoError(suite.T(), err)
+	assert.EqualValues(suite.T(), targetAccount.Username, a.Username)
+}
+
+func TestUserGetTestSuite(t *testing.T) {
+	suite.Run(t, new(UserGetTestSuite))
+}
diff --git a/internal/apimodule/security/flocblock.go b/internal/api/security/flocblock.go
similarity index 100%
rename from internal/apimodule/security/flocblock.go
rename to internal/api/security/flocblock.go
diff --git a/internal/apimodule/security/security.go b/internal/api/security/security.go
similarity index 78%
rename from internal/apimodule/security/security.go
rename to internal/api/security/security.go
index 8f805bc93..c80b568b3 100644
--- a/internal/apimodule/security/security.go
+++ b/internal/api/security/security.go
@@ -20,9 +20,8 @@ package security
 
 import (
 	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
@@ -33,7 +32,7 @@ type Module struct {
 }
 
 // New returns a new security module
-func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, log *logrus.Logger) api.ClientModule {
 	return &Module{
 		config: config,
 		log:    log,
@@ -45,8 +44,3 @@ func (m *Module) Route(s router.Router) error {
 	s.AttachMiddleware(m.FlocBlock)
 	return nil
 }
-
-// CreateTables doesn't do diddly squat at the moment, it's just for fulfilling the interface
-func (m *Module) CreateTables(db db.DB) error {
-	return nil
-}
diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go
deleted file mode 100644
index 7709697bf..000000000
--- a/internal/apimodule/account/accountupdate.go
+++ /dev/null
@@ -1,260 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package account
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"io"
-	"mime/multipart"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
-// It should be served as a PATCH at /api/v1/accounts/update_credentials
-//
-// TODO: this can be optimized massively by building up a picture of what we want the new account
-// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
-// which is not gonna make the database very happy when lots of requests are going through.
-// This way it would also be safer because the update won't happen until *all* the fields are validated.
-// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
-func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
-	l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
-	authed, err := oauth.MustAuth(c, true, false, false, true)
-	if err != nil {
-		l.Debugf("couldn't auth: %s", err)
-		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-		return
-	}
-	l.Tracef("retrieved account %+v", authed.Account.ID)
-
-	l.Trace("parsing request form")
-	form := &mastotypes.UpdateCredentialsRequest{}
-	if err := c.ShouldBind(form); err != nil || form == nil {
-		l.Debugf("could not parse form from request: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// if everything on the form is nil, then nothing has been set and we shouldn't continue
-	if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
-		l.Debugf("could not parse form from request")
-		c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
-		return
-	}
-
-	if form.Discoverable != nil {
-		if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil {
-			l.Debugf("error updating discoverable: %s", err)
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-	}
-
-	if form.Bot != nil {
-		if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil {
-			l.Debugf("error updating bot: %s", err)
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-	}
-
-	if form.DisplayName != nil {
-		if err := util.ValidateDisplayName(*form.DisplayName); err != nil {
-			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-			return
-		}
-		if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil {
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-	}
-
-	if form.Note != nil {
-		if err := util.ValidateNote(*form.Note); err != nil {
-			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-			return
-		}
-		if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil {
-			l.Debugf("error updating note: %s", err)
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-	}
-
-	if form.Avatar != nil && form.Avatar.Size != 0 {
-		avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID)
-		if err != nil {
-			l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err)
-			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-			return
-		}
-		l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo)
-	}
-
-	if form.Header != nil && form.Header.Size != 0 {
-		headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID)
-		if err != nil {
-			l.Debugf("could not update header for account %s: %s", authed.Account.ID, err)
-			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-			return
-		}
-		l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo)
-	}
-
-	if form.Locked != nil {
-		if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-	}
-
-	if form.Source != nil {
-		if form.Source.Language != nil {
-			if err := util.ValidateLanguage(*form.Source.Language); err != nil {
-				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-				return
-			}
-			if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil {
-				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-				return
-			}
-		}
-
-		if form.Source.Sensitive != nil {
-			if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
-				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-				return
-			}
-		}
-
-		if form.Source.Privacy != nil {
-			if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil {
-				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-				return
-			}
-			if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil {
-				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-				return
-			}
-		}
-	}
-
-	// if form.FieldsAttributes != nil {
-	// 	// TODO: parse fields attributes nicely and update
-	// }
-
-	// fetch the account with all updated values set
-	updatedAccount := >smodel.Account{}
-	if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
-		l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount)
-	if err != nil {
-		l.Tracef("could not convert account into mastosensitive account: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
-	c.JSON(http.StatusOK, acctSensitive)
-}
-
-/*
-	HELPER FUNCTIONS
-*/
-
-// TODO: try to combine the below two functions because this is a lot of code repetition.
-
-// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form,
-// parsing and checking the image, and doing the necessary updates in the database for this to become
-// the account's new avatar image.
-func (m *Module) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
-	var err error
-	if int(avatar.Size) > m.config.MediaConfig.MaxImageSize {
-		err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize)
-		return nil, err
-	}
-	f, err := avatar.Open()
-	if err != nil {
-		return nil, fmt.Errorf("could not read provided avatar: %s", err)
-	}
-
-	// extract the bytes
-	buf := new(bytes.Buffer)
-	size, err := io.Copy(buf, f)
-	if err != nil {
-		return nil, fmt.Errorf("could not read provided avatar: %s", err)
-	}
-	if size == 0 {
-		return nil, errors.New("could not read provided avatar: size 0 bytes")
-	}
-
-	// do the setting
-	avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar)
-	if err != nil {
-		return nil, fmt.Errorf("error processing avatar: %s", err)
-	}
-
-	return avatarInfo, f.Close()
-}
-
-// UpdateAccountHeader does the dirty work of checking the header part of an account update form,
-// parsing and checking the image, and doing the necessary updates in the database for this to become
-// the account's new header image.
-func (m *Module) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
-	var err error
-	if int(header.Size) > m.config.MediaConfig.MaxImageSize {
-		err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize)
-		return nil, err
-	}
-	f, err := header.Open()
-	if err != nil {
-		return nil, fmt.Errorf("could not read provided header: %s", err)
-	}
-
-	// extract the bytes
-	buf := new(bytes.Buffer)
-	size, err := io.Copy(buf, f)
-	if err != nil {
-		return nil, fmt.Errorf("could not read provided header: %s", err)
-	}
-	if size == 0 {
-		return nil, errors.New("could not read provided header: size 0 bytes")
-	}
-
-	// do the setting
-	headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader)
-	if err != nil {
-		return nil, fmt.Errorf("error processing header: %s", err)
-	}
-
-	return headerInfo, f.Close()
-}
diff --git a/internal/apimodule/account/test/accountcreate_test.go b/internal/apimodule/account/test/accountcreate_test.go
deleted file mode 100644
index 81eab467a..000000000
--- a/internal/apimodule/account/test/accountcreate_test.go
+++ /dev/null
@@ -1,551 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package account
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"mime/multipart"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"os"
-	"testing"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/uuid"
-	"github.com/sirupsen/logrus"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-
-	"github.com/superseriousbusiness/gotosocial/internal/media"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
-	"github.com/superseriousbusiness/oauth2/v4"
-	"github.com/superseriousbusiness/oauth2/v4/models"
-	oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
-	"golang.org/x/crypto/bcrypt"
-)
-
-type AccountCreateTestSuite struct {
-	suite.Suite
-	config               *config.Config
-	log                  *logrus.Logger
-	testAccountLocal     *gtsmodel.Account
-	testApplication      *gtsmodel.Application
-	testToken            oauth2.TokenInfo
-	mockOauthServer      *oauth.MockServer
-	mockStorage          *storage.MockStorage
-	mediaHandler         media.Handler
-	mastoConverter       mastotypes.Converter
-	db                   db.DB
-	accountModule        *account.Module
-	newUserFormHappyPath url.Values
-}
-
-/*
-	TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *AccountCreateTestSuite) SetupSuite() {
-	// some of our subsequent entities need a log so create this here
-	log := logrus.New()
-	log.SetLevel(logrus.TraceLevel)
-	suite.log = log
-
-	suite.testAccountLocal = >smodel.Account{
-		ID:       uuid.NewString(),
-		Username: "test_user",
-	}
-
-	// can use this test application throughout
-	suite.testApplication = >smodel.Application{
-		ID:           "weeweeeeeeeeeeeeee",
-		Name:         "a test application",
-		Website:      "https://some-application-website.com",
-		RedirectURI:  "http://localhost:8080",
-		ClientID:     "a-known-client-id",
-		ClientSecret: "some-secret",
-		Scopes:       "read",
-		VapidKey:     "aaaaaa-aaaaaaaa-aaaaaaaaaaa",
-	}
-
-	// can use this test token throughout
-	suite.testToken = &oauthmodels.Token{
-		ClientID:      "a-known-client-id",
-		RedirectURI:   "http://localhost:8080",
-		Scope:         "read",
-		Code:          "123456789",
-		CodeCreateAt:  time.Now(),
-		CodeExpiresIn: time.Duration(10 * time.Minute),
-	}
-
-	// Direct config to local postgres instance
-	c := config.Empty()
-	c.Protocol = "http"
-	c.Host = "localhost"
-	c.DBConfig = &config.DBConfig{
-		Type:            "postgres",
-		Address:         "localhost",
-		Port:            5432,
-		User:            "postgres",
-		Password:        "postgres",
-		Database:        "postgres",
-		ApplicationName: "gotosocial",
-	}
-	c.MediaConfig = &config.MediaConfig{
-		MaxImageSize: 2 << 20,
-	}
-	c.StorageConfig = &config.StorageConfig{
-		Backend:       "local",
-		BasePath:      "/tmp",
-		ServeProtocol: "http",
-		ServeHost:     "localhost",
-		ServeBasePath: "/fileserver/media",
-	}
-	suite.config = c
-
-	// use an actual database for this, because it's just easier than mocking one out
-	database, err := db.New(context.Background(), c, log)
-	if err != nil {
-		suite.FailNow(err.Error())
-	}
-	suite.db = database
-
-	// we need to mock the oauth server because account creation needs it to create a new token
-	suite.mockOauthServer = &oauth.MockServer{}
-	suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
-		l := suite.log.WithField("func", "GenerateUserAccessToken")
-		token := args.Get(0).(oauth2.TokenInfo)
-		l.Infof("received token %+v", token)
-		clientSecret := args.Get(1).(string)
-		l.Infof("received clientSecret %+v", clientSecret)
-		userID := args.Get(2).(string)
-		l.Infof("received userID %+v", userID)
-	}).Return(&models.Token{
-		Access: "we're authorized now!",
-	}, nil)
-
-	suite.mockStorage = &storage.MockStorage{}
-	// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage
-	suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
-
-	// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
-	suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
-
-	suite.mastoConverter = mastotypes.New(suite.config, suite.db)
-
-	// and finally here's the thing we're actually testing!
-	suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module)
-}
-
-func (suite *AccountCreateTestSuite) TearDownSuite() {
-	if err := suite.db.Stop(context.Background()); err != nil {
-		logrus.Panicf("error closing db connection: %s", err)
-	}
-}
-
-// SetupTest creates a db connection and creates necessary tables before each test
-func (suite *AccountCreateTestSuite) SetupTest() {
-	// create all the tables we might need in thie suite
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-	}
-	for _, m := range models {
-		if err := suite.db.CreateTable(m); err != nil {
-			logrus.Panicf("db connection error: %s", err)
-		}
-	}
-
-	// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test
-	suite.newUserFormHappyPath = url.Values{
-		"reason":    []string{"a very good reason that's at least 40 characters i swear"},
-		"username":  []string{"test_user"},
-		"email":     []string{"user@example.org"},
-		"password":  []string{"very-strong-password"},
-		"agreement": []string{"true"},
-		"locale":    []string{"en"},
-	}
-
-	// same with accounts config
-	suite.config.AccountsConfig = &config.AccountsConfig{
-		OpenRegistration: true,
-		RequireApproval:  true,
-		ReasonRequired:   true,
-	}
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *AccountCreateTestSuite) TearDownTest() {
-
-	// remove all the tables we might have used so it's clear for the next test
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-	}
-	for _, m := range models {
-		if err := suite.db.DropTable(m); err != nil {
-			logrus.Panicf("error dropping table: %s", err)
-		}
-	}
-}
-
-/*
-	ACTUAL TESTS
-*/
-
-/*
-	TESTING: AccountCreatePOSTHandler
-*/
-
-// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
-// and at the end of it a new user and account should be added into the database.
-//
-// This is the handler served at /api/v1/accounts as POST
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-
-	// 1. we should have OK from our call to the function
-	suite.EqualValues(http.StatusOK, recorder.Code)
-
-	// 2. we should have a token in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	t := &mastomodel.Token{}
-	err = json.Unmarshal(b, t)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
-
-	// check new account
-
-	// 1. we should be able to get the new account from the db
-	acct := >smodel.Account{}
-	err = suite.db.GetWhere("username", "test_user", acct)
-	assert.NoError(suite.T(), err)
-	assert.NotNil(suite.T(), acct)
-	// 2. reason should be set
-	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
-	// 3. display name should be equal to username by default
-	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
-	// 4. domain should be nil because this is a local account
-	assert.Nil(suite.T(), nil, acct.Domain)
-	// 5. id should be set and parseable as a uuid
-	assert.NotNil(suite.T(), acct.ID)
-	_, err = uuid.Parse(acct.ID)
-	assert.Nil(suite.T(), err)
-	// 6. private and public key should be set
-	assert.NotNil(suite.T(), acct.PrivateKey)
-	assert.NotNil(suite.T(), acct.PublicKey)
-
-	// check new user
-
-	// 1. we should be able to get the new user from the db
-	usr := >smodel.User{}
-	err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
-	assert.Nil(suite.T(), err)
-	assert.NotNil(suite.T(), usr)
-
-	// 2. user should have account id set to account we got above
-	assert.Equal(suite.T(), acct.ID, usr.AccountID)
-
-	// 3. id should be set and parseable as a uuid
-	assert.NotNil(suite.T(), usr.ID)
-	_, err = uuid.Parse(usr.ID)
-	assert.Nil(suite.T(), err)
-
-	// 4. locale should be equal to what we requested
-	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
-
-	// 5. created by application id should be equal to the app id
-	assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
-
-	// 6. password should be matcheable to what we set above
-	err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
-	assert.Nil(suite.T(), err)
-}
-
-// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
-// only registered applications can create accounts, and we don't provide one here.
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-
-	// 1. we should have forbidden from our call to the function because we didn't auth
-	suite.EqualValues(http.StatusForbidden, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-	// set a weak password
-	ctx.Request.Form.Set("password", "weak")
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-	// set an invalid locale
-	ctx.Request.Form.Set("locale", "neverneverland")
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-
-	// close registrations
-	suite.config.AccountsConfig.OpenRegistration = false
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-
-	// remove reason
-	ctx.Request.Form.Set("reason", "")
-
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
-	ctx.Request.Form = suite.newUserFormHappyPath
-
-	// remove reason
-	ctx.Request.Form.Set("reason", "just cuz")
-
-	suite.accountModule.AccountCreatePOSTHandler(ctx)
-
-	// check response
-	suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	b, err := ioutil.ReadAll(result.Body)
-	assert.NoError(suite.T(), err)
-	assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
-}
-
-/*
-	TESTING: AccountUpdateCredentialsPATCHHandler
-*/
-
-func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
-
-	// put test local account in db
-	err := suite.db.Put(suite.testAccountLocal)
-	assert.NoError(suite.T(), err)
-
-	// attach avatar to request
-	aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
-	assert.NoError(suite.T(), err)
-	body := &bytes.Buffer{}
-	writer := multipart.NewWriter(body)
-
-	part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
-	assert.NoError(suite.T(), err)
-
-	_, err = io.Copy(part, aviFile)
-	assert.NoError(suite.T(), err)
-
-	err = aviFile.Close()
-	assert.NoError(suite.T(), err)
-
-	err = writer.Close()
-	assert.NoError(suite.T(), err)
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
-	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
-	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
-
-	// check response
-
-	// 1. we should have OK because our request was valid
-	suite.EqualValues(http.StatusOK, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	// TODO: implement proper checks here
-	//
-	// b, err := ioutil.ReadAll(result.Body)
-	// assert.NoError(suite.T(), err)
-	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
-}
-
-func TestAccountCreateTestSuite(t *testing.T) {
-	suite.Run(t, new(AccountCreateTestSuite))
-}
diff --git a/internal/apimodule/account/test/accountupdate_test.go b/internal/apimodule/account/test/accountupdate_test.go
deleted file mode 100644
index 1c6f528a1..000000000
--- a/internal/apimodule/account/test/accountupdate_test.go
+++ /dev/null
@@ -1,303 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package account
-
-import (
-	"bytes"
-	"context"
-	"fmt"
-	"io"
-	"mime/multipart"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"os"
-	"testing"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/uuid"
-	"github.com/sirupsen/logrus"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-	"github.com/stretchr/testify/suite"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/storage"
-	"github.com/superseriousbusiness/oauth2/v4"
-	"github.com/superseriousbusiness/oauth2/v4/models"
-	oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
-)
-
-type AccountUpdateTestSuite struct {
-	suite.Suite
-	config               *config.Config
-	log                  *logrus.Logger
-	testAccountLocal     *gtsmodel.Account
-	testApplication      *gtsmodel.Application
-	testToken            oauth2.TokenInfo
-	mockOauthServer      *oauth.MockServer
-	mockStorage          *storage.MockStorage
-	mediaHandler         media.Handler
-	mastoConverter       mastotypes.Converter
-	db                   db.DB
-	accountModule        *account.Module
-	newUserFormHappyPath url.Values
-}
-
-/*
-	TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *AccountUpdateTestSuite) SetupSuite() {
-	// some of our subsequent entities need a log so create this here
-	log := logrus.New()
-	log.SetLevel(logrus.TraceLevel)
-	suite.log = log
-
-	suite.testAccountLocal = >smodel.Account{
-		ID:       uuid.NewString(),
-		Username: "test_user",
-	}
-
-	// can use this test application throughout
-	suite.testApplication = >smodel.Application{
-		ID:           "weeweeeeeeeeeeeeee",
-		Name:         "a test application",
-		Website:      "https://some-application-website.com",
-		RedirectURI:  "http://localhost:8080",
-		ClientID:     "a-known-client-id",
-		ClientSecret: "some-secret",
-		Scopes:       "read",
-		VapidKey:     "aaaaaa-aaaaaaaa-aaaaaaaaaaa",
-	}
-
-	// can use this test token throughout
-	suite.testToken = &oauthmodels.Token{
-		ClientID:      "a-known-client-id",
-		RedirectURI:   "http://localhost:8080",
-		Scope:         "read",
-		Code:          "123456789",
-		CodeCreateAt:  time.Now(),
-		CodeExpiresIn: time.Duration(10 * time.Minute),
-	}
-
-	// Direct config to local postgres instance
-	c := config.Empty()
-	c.Protocol = "http"
-	c.Host = "localhost"
-	c.DBConfig = &config.DBConfig{
-		Type:            "postgres",
-		Address:         "localhost",
-		Port:            5432,
-		User:            "postgres",
-		Password:        "postgres",
-		Database:        "postgres",
-		ApplicationName: "gotosocial",
-	}
-	c.MediaConfig = &config.MediaConfig{
-		MaxImageSize: 2 << 20,
-	}
-	c.StorageConfig = &config.StorageConfig{
-		Backend:       "local",
-		BasePath:      "/tmp",
-		ServeProtocol: "http",
-		ServeHost:     "localhost",
-		ServeBasePath: "/fileserver/media",
-	}
-	suite.config = c
-
-	// use an actual database for this, because it's just easier than mocking one out
-	database, err := db.New(context.Background(), c, log)
-	if err != nil {
-		suite.FailNow(err.Error())
-	}
-	suite.db = database
-
-	// we need to mock the oauth server because account creation needs it to create a new token
-	suite.mockOauthServer = &oauth.MockServer{}
-	suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
-		l := suite.log.WithField("func", "GenerateUserAccessToken")
-		token := args.Get(0).(oauth2.TokenInfo)
-		l.Infof("received token %+v", token)
-		clientSecret := args.Get(1).(string)
-		l.Infof("received clientSecret %+v", clientSecret)
-		userID := args.Get(2).(string)
-		l.Infof("received userID %+v", userID)
-	}).Return(&models.Token{
-		Code: "we're authorized now!",
-	}, nil)
-
-	suite.mockStorage = &storage.MockStorage{}
-	// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage
-	suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
-
-	// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
-	suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
-
-	suite.mastoConverter = mastotypes.New(suite.config, suite.db)
-
-	// and finally here's the thing we're actually testing!
-	suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module)
-}
-
-func (suite *AccountUpdateTestSuite) TearDownSuite() {
-	if err := suite.db.Stop(context.Background()); err != nil {
-		logrus.Panicf("error closing db connection: %s", err)
-	}
-}
-
-// SetupTest creates a db connection and creates necessary tables before each test
-func (suite *AccountUpdateTestSuite) SetupTest() {
-	// create all the tables we might need in thie suite
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-	}
-	for _, m := range models {
-		if err := suite.db.CreateTable(m); err != nil {
-			logrus.Panicf("db connection error: %s", err)
-		}
-	}
-
-	// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test
-	suite.newUserFormHappyPath = url.Values{
-		"reason":    []string{"a very good reason that's at least 40 characters i swear"},
-		"username":  []string{"test_user"},
-		"email":     []string{"user@example.org"},
-		"password":  []string{"very-strong-password"},
-		"agreement": []string{"true"},
-		"locale":    []string{"en"},
-	}
-
-	// same with accounts config
-	suite.config.AccountsConfig = &config.AccountsConfig{
-		OpenRegistration: true,
-		RequireApproval:  true,
-		ReasonRequired:   true,
-	}
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *AccountUpdateTestSuite) TearDownTest() {
-
-	// remove all the tables we might have used so it's clear for the next test
-	models := []interface{}{
-		>smodel.User{},
-		>smodel.Account{},
-		>smodel.Follow{},
-		>smodel.FollowRequest{},
-		>smodel.Status{},
-		>smodel.Application{},
-		>smodel.EmailDomainBlock{},
-		>smodel.MediaAttachment{},
-	}
-	for _, m := range models {
-		if err := suite.db.DropTable(m); err != nil {
-			logrus.Panicf("error dropping table: %s", err)
-		}
-	}
-}
-
-/*
-	ACTUAL TESTS
-*/
-
-/*
-	TESTING: AccountUpdateCredentialsPATCHHandler
-*/
-
-func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
-
-	// put test local account in db
-	err := suite.db.Put(suite.testAccountLocal)
-	assert.NoError(suite.T(), err)
-
-	// attach avatar to request form
-	avatarFile, err := os.Open("../../media/test/test-jpeg.jpg")
-	assert.NoError(suite.T(), err)
-	body := &bytes.Buffer{}
-	writer := multipart.NewWriter(body)
-
-	avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
-	assert.NoError(suite.T(), err)
-
-	_, err = io.Copy(avatarPart, avatarFile)
-	assert.NoError(suite.T(), err)
-
-	err = avatarFile.Close()
-	assert.NoError(suite.T(), err)
-
-	// set display name to a new value
-	displayNamePart, err := writer.CreateFormField("display_name")
-	assert.NoError(suite.T(), err)
-
-	_, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah"))
-	assert.NoError(suite.T(), err)
-
-	// set locked to true
-	lockedPart, err := writer.CreateFormField("locked")
-	assert.NoError(suite.T(), err)
-
-	_, err = io.Copy(lockedPart, bytes.NewBufferString("true"))
-	assert.NoError(suite.T(), err)
-
-	// close the request writer, the form is now prepared
-	err = writer.Close()
-	assert.NoError(suite.T(), err)
-
-	// setup
-	recorder := httptest.NewRecorder()
-	ctx, _ := gin.CreateTestContext(recorder)
-	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
-	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
-	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
-	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
-	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
-
-	// check response
-
-	// 1. we should have OK because our request was valid
-	suite.EqualValues(http.StatusOK, recorder.Code)
-
-	// 2. we should have an error message in the result body
-	result := recorder.Result()
-	defer result.Body.Close()
-	// TODO: implement proper checks here
-	//
-	// b, err := ioutil.ReadAll(result.Body)
-	// assert.NoError(suite.T(), err)
-	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
-}
-
-func TestAccountUpdateTestSuite(t *testing.T) {
-	suite.Run(t, new(AccountUpdateTestSuite))
-}
diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go
deleted file mode 100644
index 99b79d470..000000000
--- a/internal/apimodule/app/appcreate.go
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package app
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/uuid"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// AppsPOSTHandler should be served at https://example.org/api/v1/apps
-// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
-func (m *Module) AppsPOSTHandler(c *gin.Context) {
-	l := m.log.WithField("func", "AppsPOSTHandler")
-	l.Trace("entering AppsPOSTHandler")
-
-	form := &mastotypes.ApplicationPOSTRequest{}
-	if err := c.ShouldBind(form); err != nil {
-		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
-		return
-	}
-
-	// permitted length for most fields
-	permittedLength := 64
-	// redirect can be a bit bigger because we probably need to encode data in the redirect uri
-	permittedRedirect := 256
-
-	// check lengths of fields before proceeding so the user can't spam huge entries into the database
-	if len(form.ClientName) > permittedLength {
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)})
-		return
-	}
-	if len(form.Website) > permittedLength {
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)})
-		return
-	}
-	if len(form.RedirectURIs) > permittedRedirect {
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)})
-		return
-	}
-	if len(form.Scopes) > permittedLength {
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)})
-		return
-	}
-
-	// set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/
-	var scopes string
-	if form.Scopes == "" {
-		scopes = "read"
-	} else {
-		scopes = form.Scopes
-	}
-
-	// generate new IDs for this application and its associated client
-	clientID := uuid.NewString()
-	clientSecret := uuid.NewString()
-	vapidKey := uuid.NewString()
-
-	// generate the application to put in the database
-	app := >smodel.Application{
-		Name:         form.ClientName,
-		Website:      form.Website,
-		RedirectURI:  form.RedirectURIs,
-		ClientID:     clientID,
-		ClientSecret: clientSecret,
-		Scopes:       scopes,
-		VapidKey:     vapidKey,
-	}
-
-	// chuck it in the db
-	if err := m.db.Put(app); err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	// now we need to model an oauth client from the application that the oauth library can use
-	oc := &oauth.Client{
-		ID:     clientID,
-		Secret: clientSecret,
-		Domain: form.RedirectURIs,
-		UserID: "", // This client isn't yet associated with a specific user,  it's just an app client right now
-	}
-
-	// chuck it in the db
-	if err := m.db.Put(oc); err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	mastoApp, err := m.mastoConverter.AppToMastoSensitive(app)
-	if err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
-	c.JSON(http.StatusOK, mastoApp)
-}
diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go
deleted file mode 100644
index 0421c5095..000000000
--- a/internal/apimodule/fileserver/servefile.go
+++ /dev/null
@@ -1,243 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package fileserver
-
-import (
-	"bytes"
-	"net/http"
-	"strings"
-
-	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/media"
-)
-
-// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
-//
-// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
-// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
-func (m *FileServer) ServeFile(c *gin.Context) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "ServeFile",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-	l.Trace("received request")
-
-	// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
-	// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
-	// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
-	accountID := c.Param(AccountIDKey)
-	if accountID == "" {
-		l.Debug("missing accountID from request")
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	mediaType := c.Param(MediaTypeKey)
-	if mediaType == "" {
-		l.Debug("missing mediaType from request")
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	mediaSize := c.Param(MediaSizeKey)
-	if mediaSize == "" {
-		l.Debug("missing mediaSize from request")
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	fileName := c.Param(FileNameKey)
-	if fileName == "" {
-		l.Debug("missing fileName from request")
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// Only serve media types that are defined in our internal media module
-	switch mediaType {
-	case media.MediaHeader, media.MediaAvatar, media.MediaAttachment:
-		m.serveAttachment(c, accountID, mediaType, mediaSize, fileName)
-		return
-	case media.MediaEmoji:
-		m.serveEmoji(c, accountID, mediaType, mediaSize, fileName)
-		return
-	}
-	l.Debugf("mediatype %s not recognized", mediaType)
-	c.String(http.StatusNotFound, "404 page not found")
-}
-
-func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "serveAttachment",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-
-	// This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static
-	switch mediaSize {
-	case media.MediaOriginal, media.MediaSmall, media.MediaStatic:
-	default:
-		l.Debugf("mediasize %s not recognized", mediaSize)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// derive the media id and the file extension from the last part of the request
-	spl := strings.Split(fileName, ".")
-	if len(spl) != 2 {
-		l.Debugf("filename %s not parseable", fileName)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-	wantedMediaID := spl[0]
-	fileExtension := spl[1]
-	if wantedMediaID == "" || fileExtension == "" {
-		l.Debugf("filename %s not parseable", fileName)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
-	attachment := >smodel.MediaAttachment{}
-	if err := m.db.GetByID(wantedMediaID, attachment); err != nil {
-		l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// make sure the given account id owns the requested attachment
-	if accountID != attachment.AccountID {
-		l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
-	var storagePath string
-	var contentType string
-	var contentLength int
-	switch mediaSize {
-	case media.MediaOriginal:
-		storagePath = attachment.File.Path
-		contentType = attachment.File.ContentType
-		contentLength = attachment.File.FileSize
-	case media.MediaSmall:
-		storagePath = attachment.Thumbnail.Path
-		contentType = attachment.Thumbnail.ContentType
-		contentLength = attachment.Thumbnail.FileSize
-	}
-
-	// use the path listed on the attachment we pulled out of the database to retrieve the object from storage
-	attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath)
-	if err != nil {
-		l.Debugf("error retrieving from storage: %s", err)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes)))
-
-	// finally we can return with all the information we derived above
-	c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{})
-}
-
-func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "serveEmoji",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-
-	// This corresponds to original-sized emoji as it was uploaded, or static
-	switch mediaSize {
-	case media.MediaOriginal, media.MediaStatic:
-	default:
-		l.Debugf("mediasize %s not recognized", mediaSize)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// derive the media id and the file extension from the last part of the request
-	spl := strings.Split(fileName, ".")
-	if len(spl) != 2 {
-		l.Debugf("filename %s not parseable", fileName)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-	wantedEmojiID := spl[0]
-	fileExtension := spl[1]
-	if wantedEmojiID == "" || fileExtension == "" {
-		l.Debugf("filename %s not parseable", fileName)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
-	emoji := >smodel.Emoji{}
-	if err := m.db.GetByID(wantedEmojiID, emoji); err != nil {
-		l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// make sure the instance account id owns the requested emoji
-	instanceAccount := >smodel.Account{}
-	if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil {
-		l.Debugf("error fetching instance account: %s", err)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-	if accountID != instanceAccount.ID {
-		l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
-	var storagePath string
-	var contentType string
-	var contentLength int
-	switch mediaSize {
-	case media.MediaOriginal:
-		storagePath = emoji.ImagePath
-		contentType = emoji.ImageContentType
-		contentLength = emoji.ImageFileSize
-	case media.MediaStatic:
-		storagePath = emoji.ImageStaticPath
-		contentType = "image/png"
-		contentLength = emoji.ImageStaticFileSize
-	}
-
-	// use the path listed on the emoji we pulled out of the database to retrieve the object from storage
-	emojiBytes, err := m.storage.RetrieveFileFrom(storagePath)
-	if err != nil {
-		l.Debugf("error retrieving emoji from storage: %s", err)
-		c.String(http.StatusNotFound, "404 page not found")
-		return
-	}
-
-	// finally we can return with all the information we derived above
-	c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{})
-}
diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go
deleted file mode 100644
index ee713a471..000000000
--- a/internal/apimodule/media/mediacreate.go
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package media
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"strconv"
-	"strings"
-
-	"github.com/gin-gonic/gin"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// MediaCreatePOSTHandler handles requests to create/upload media attachments
-func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
-	l := m.log.WithField("func", "statusCreatePOSTHandler")
-	authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything*
-	if err != nil {
-		l.Debugf("couldn't auth: %s", err)
-		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-		return
-	}
-
-	// First check this user/account is permitted to create media
-	// There's no point continuing otherwise.
-	if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
-		l.Debugf("couldn't auth: %s", err)
-		c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
-		return
-	}
-
-	// extract the media create form from the request context
-	l.Tracef("parsing request form: %s", c.Request.Form)
-	form := &mastotypes.AttachmentRequest{}
-	if err := c.ShouldBind(form); err != nil || form == nil {
-		l.Debugf("could not parse form from request: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
-		return
-	}
-
-	// Give the fields on the request form a first pass to make sure the request is superficially valid.
-	l.Tracef("validating form %+v", form)
-	if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
-		l.Debugf("error validating form: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// open the attachment and extract the bytes from it
-	f, err := form.File.Open()
-	if err != nil {
-		l.Debugf("error opening attachment: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)})
-		return
-	}
-	buf := new(bytes.Buffer)
-	size, err := io.Copy(buf, f)
-	if err != nil {
-		l.Debugf("error reading attachment: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)})
-		return
-	}
-	if size == 0 {
-		l.Debug("could not read provided attachment: size 0 bytes")
-		c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"})
-		return
-	}
-
-	// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
-	attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
-	if err != nil {
-		l.Debugf("error reading attachment: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)})
-		return
-	}
-
-	// now we need to add extra fields that the attachment processor doesn't know (from the form)
-	// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
-
-	// first description
-	attachment.Description = form.Description
-
-	// now parse the focus parameter
-	// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
-	var focusx, focusy float32
-	if form.Focus != "" {
-		spl := strings.Split(form.Focus, ",")
-		if len(spl) != 2 {
-			l.Debugf("improperly formatted focus %s", form.Focus)
-			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
-			return
-		}
-		xStr := spl[0]
-		yStr := spl[1]
-		if xStr == "" || yStr == "" {
-			l.Debugf("improperly formatted focus %s", form.Focus)
-			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
-			return
-		}
-		fx, err := strconv.ParseFloat(xStr, 32)
-		if err != nil {
-			l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
-			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
-			return
-		}
-		if fx > 1 || fx < -1 {
-			l.Debugf("improperly formatted focus %s", form.Focus)
-			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
-			return
-		}
-		focusx = float32(fx)
-		fy, err := strconv.ParseFloat(yStr, 32)
-		if err != nil {
-			l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
-			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
-			return
-		}
-		if fy > 1 || fy < -1 {
-			l.Debugf("improperly formatted focus %s", form.Focus)
-			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
-			return
-		}
-		focusy = float32(fy)
-	}
-	attachment.FileMeta.Focus.X = focusx
-	attachment.FileMeta.Focus.Y = focusy
-
-	// prepare the frontend representation now -- if there are any errors here at least we can bail without
-	// having already put something in the database and then having to clean it up again (eugh)
-	mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment)
-	if err != nil {
-		l.Debugf("error parsing media attachment to frontend type: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)})
-		return
-	}
-
-	// now we can confidently put the attachment in the database
-	if err := m.db.Put(attachment); err != nil {
-		l.Debugf("error storing media attachment in db: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)})
-		return
-	}
-
-	// and return its frontend representation
-	c.JSON(http.StatusAccepted, mastoAttachment)
-}
-
-func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error {
-	// check there actually is a file attached and it's not size 0
-	if form.File == nil || form.File.Size == 0 {
-		return errors.New("no attachment given")
-	}
-
-	// a very superficial check to see if no size limits are exceeded
-	// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
-	maxSize := config.MaxVideoSize
-	if config.MaxImageSize > maxSize {
-		maxSize = config.MaxImageSize
-	}
-	if form.File.Size > int64(maxSize) {
-		return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
-	}
-
-	if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
-		return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
-	}
-
-	// TODO: validate focus here
-
-	return nil
-}
diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go
deleted file mode 100644
index 2d4293d0e..000000000
--- a/internal/apimodule/mock_ClientAPIModule.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package apimodule
-
-import (
-	mock "github.com/stretchr/testify/mock"
-	db "github.com/superseriousbusiness/gotosocial/internal/db"
-
-	router "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type
-type MockClientAPIModule struct {
-	mock.Mock
-}
-
-// CreateTables provides a mock function with given fields: _a0
-func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error {
-	ret := _m.Called(_a0)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(db.DB) error); ok {
-		r0 = rf(_a0)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// Route provides a mock function with given fields: s
-func (_m *MockClientAPIModule) Route(s router.Router) error {
-	ret := _m.Called(s)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(router.Router) error); ok {
-		r0 = rf(s)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go
deleted file mode 100644
index 97354e767..000000000
--- a/internal/apimodule/status/statuscreate.go
+++ /dev/null
@@ -1,462 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package status
-
-import (
-	"errors"
-	"fmt"
-	"net/http"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/uuid"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-	"github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-type advancedStatusCreateForm struct {
-	mastotypes.StatusCreateRequest
-	advancedVisibilityFlagsForm
-}
-
-type advancedVisibilityFlagsForm struct {
-	// The gotosocial visibility model
-	VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"`
-	// This status will be federated beyond the local timeline(s)
-	Federated *bool `form:"federated"`
-	// This status can be boosted/reblogged
-	Boostable *bool `form:"boostable"`
-	// This status can be replied to
-	Replyable *bool `form:"replyable"`
-	// This status can be liked/faved
-	Likeable *bool `form:"likeable"`
-}
-
-// StatusCreatePOSTHandler deals with the creation of new statuses
-func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
-	l := m.log.WithField("func", "statusCreatePOSTHandler")
-	authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
-	if err != nil {
-		l.Debugf("couldn't auth: %s", err)
-		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-		return
-	}
-
-	// First check this user/account is permitted to post new statuses.
-	// There's no point continuing otherwise.
-	if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
-		l.Debugf("couldn't auth: %s", err)
-		c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
-		return
-	}
-
-	// extract the status create form from the request context
-	l.Tracef("parsing request form: %s", c.Request.Form)
-	form := &advancedStatusCreateForm{}
-	if err := c.ShouldBind(form); err != nil || form == nil {
-		l.Debugf("could not parse form from request: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
-		return
-	}
-
-	// Give the fields on the request form a first pass to make sure the request is superficially valid.
-	l.Tracef("validating form %+v", form)
-	if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
-		l.Debugf("error validating form: %s", err)
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// At this point we know the account is permitted to post, and we know the request form
-	// is valid (at least according to the API specifications and the instance configuration).
-	// So now we can start digging a bit deeper into the form and building up the new status from it.
-
-	// first we create a new status and add some basic info to it
-	uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host)
-	thisStatusID := uuid.NewString()
-	thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
-	thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
-	newStatus := >smodel.Status{
-		ID:                       thisStatusID,
-		URI:                      thisStatusURI,
-		URL:                      thisStatusURL,
-		Content:                  util.HTMLFormat(form.Status),
-		CreatedAt:                time.Now(),
-		UpdatedAt:                time.Now(),
-		Local:                    true,
-		AccountID:                authed.Account.ID,
-		ContentWarning:           form.SpoilerText,
-		ActivityStreamsType:      gtsmodel.ActivityStreamsNote,
-		Sensitive:                form.Sensitive,
-		Language:                 form.Language,
-		CreatedWithApplicationID: authed.Application.ID,
-		Text:                     form.Status,
-	}
-
-	// check if replyToID is ok
-	if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// check if mediaIDs are ok
-	if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// check if visibility settings are ok
-	if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// handle language settings
-	if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	// handle mentions
-	if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
-		return
-	}
-
-	/*
-		FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it
-	*/
-
-	// put the new status in the database, generating an ID for it in the process
-	if err := m.db.Put(newStatus); err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	// change the status ID of the media attachments to the new status
-	for _, a := range newStatus.GTSMediaAttachments {
-		a.StatusID = newStatus.ID
-		a.UpdatedAt = time.Now()
-		if err := m.db.UpdateByID(a.ID, a); err != nil {
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-	}
-
-	// pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc
-	m.distributor.FromClientAPI() <- distributor.FromClientAPI{
-		APObjectType:   gtsmodel.ActivityStreamsNote,
-		APActivityType: gtsmodel.ActivityStreamsCreate,
-		Activity:       newStatus,
-	}
-
-	// return the frontend representation of the new status to the submitter
-	mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil)
-	if err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-	c.JSON(http.StatusOK, mastoStatus)
-}
-
-func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error {
-	// validate that, structurally, we have a valid status/post
-	if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
-		return errors.New("no status, media, or poll provided")
-	}
-
-	if form.MediaIDs != nil && form.Poll != nil {
-		return errors.New("can't post media + poll in same status")
-	}
-
-	// validate status
-	if form.Status != "" {
-		if len(form.Status) > config.MaxChars {
-			return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
-		}
-	}
-
-	// validate media attachments
-	if len(form.MediaIDs) > config.MaxMediaFiles {
-		return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
-	}
-
-	// validate poll
-	if form.Poll != nil {
-		if form.Poll.Options == nil {
-			return errors.New("poll with no options")
-		}
-		if len(form.Poll.Options) > config.PollMaxOptions {
-			return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
-		}
-		for _, p := range form.Poll.Options {
-			if len(p) > config.PollOptionMaxChars {
-				return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
-			}
-		}
-	}
-
-	// validate spoiler text/cw
-	if form.SpoilerText != "" {
-		if len(form.SpoilerText) > config.CWMaxChars {
-			return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
-		}
-	}
-
-	// validate post language
-	if form.Language != "" {
-		if err := util.ValidateLanguage(form.Language); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
-	// by default all flags are set to true
-	gtsAdvancedVis := >smodel.VisibilityAdvanced{
-		Federated: true,
-		Boostable: true,
-		Replyable: true,
-		Likeable:  true,
-	}
-
-	var gtsBasicVis gtsmodel.Visibility
-	// Advanced takes priority if it's set.
-	// If it's not set, take whatever masto visibility is set.
-	// If *that's* not set either, then just take the account default.
-	// If that's also not set, take the default for the whole instance.
-	if form.VisibilityAdvanced != nil {
-		gtsBasicVis = *form.VisibilityAdvanced
-	} else if form.Visibility != "" {
-		gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility)
-	} else if accountDefaultVis != "" {
-		gtsBasicVis = accountDefaultVis
-	} else {
-		gtsBasicVis = gtsmodel.VisibilityDefault
-	}
-
-	switch gtsBasicVis {
-	case gtsmodel.VisibilityPublic:
-		// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
-		break
-	case gtsmodel.VisibilityUnlocked:
-		// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
-		if form.Federated != nil {
-			gtsAdvancedVis.Federated = *form.Federated
-		}
-
-		if form.Boostable != nil {
-			gtsAdvancedVis.Boostable = *form.Boostable
-		}
-
-		if form.Replyable != nil {
-			gtsAdvancedVis.Replyable = *form.Replyable
-		}
-
-		if form.Likeable != nil {
-			gtsAdvancedVis.Likeable = *form.Likeable
-		}
-
-	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
-		// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
-		gtsAdvancedVis.Boostable = false
-
-		if form.Federated != nil {
-			gtsAdvancedVis.Federated = *form.Federated
-		}
-
-		if form.Replyable != nil {
-			gtsAdvancedVis.Replyable = *form.Replyable
-		}
-
-		if form.Likeable != nil {
-			gtsAdvancedVis.Likeable = *form.Likeable
-		}
-
-	case gtsmodel.VisibilityDirect:
-		// direct is pretty easy: there's only one possible setting so return it
-		gtsAdvancedVis.Federated = true
-		gtsAdvancedVis.Boostable = false
-		gtsAdvancedVis.Federated = true
-		gtsAdvancedVis.Likeable = true
-	}
-
-	status.Visibility = gtsBasicVis
-	status.VisibilityAdvanced = gtsAdvancedVis
-	return nil
-}
-
-func (m *Module) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
-	if form.InReplyToID == "" {
-		return nil
-	}
-
-	// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
-	//
-	// 1. Does the replied status exist in the database?
-	// 2. Is the replied status marked as replyable?
-	// 3. Does a block exist between either the current account or the account that posted the status it's replying to?
-	//
-	// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
-	repliedStatus := >smodel.Status{}
-	repliedAccount := >smodel.Account{}
-	// check replied status exists + is replyable
-	if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil {
-		if _, ok := err.(db.ErrNoEntries); ok {
-			return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID)
-		}
-		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
-	}
-
-	if !repliedStatus.VisibilityAdvanced.Replyable {
-		return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID)
-	}
-
-	// check replied account is known to us
-	if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil {
-		if _, ok := err.(db.ErrNoEntries); ok {
-			return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID)
-		}
-		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
-	}
-	// check if a block exists
-	if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil {
-		if _, ok := err.(db.ErrNoEntries); !ok {
-			return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
-		}
-	} else if blocked {
-		return fmt.Errorf("status with id %s not replyable", form.InReplyToID)
-	}
-	status.InReplyToID = repliedStatus.ID
-	status.InReplyToAccountID = repliedAccount.ID
-
-	return nil
-}
-
-func (m *Module) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
-	if form.MediaIDs == nil {
-		return nil
-	}
-
-	gtsMediaAttachments := []*gtsmodel.MediaAttachment{}
-	attachments := []string{}
-	for _, mediaID := range form.MediaIDs {
-		// check these attachments exist
-		a := >smodel.MediaAttachment{}
-		if err := m.db.GetByID(mediaID, a); err != nil {
-			return fmt.Errorf("invalid media type or media not found for media id %s", mediaID)
-		}
-		// check they belong to the requesting account id
-		if a.AccountID != thisAccountID {
-			return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID)
-		}
-		// check they're not already used in a status
-		if a.StatusID != "" || a.ScheduledStatusID != "" {
-			return fmt.Errorf("media with id %s is already attached to a status", mediaID)
-		}
-		gtsMediaAttachments = append(gtsMediaAttachments, a)
-		attachments = append(attachments, a.ID)
-	}
-	status.GTSMediaAttachments = gtsMediaAttachments
-	status.Attachments = attachments
-	return nil
-}
-
-func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error {
-	if form.Language != "" {
-		status.Language = form.Language
-	} else {
-		status.Language = accountDefaultLanguage
-	}
-	if status.Language == "" {
-		return errors.New("no language given either in status create form or account default")
-	}
-	return nil
-}
-
-func (m *Module) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
-	menchies := []string{}
-	gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
-	if err != nil {
-		return fmt.Errorf("error generating mentions from status: %s", err)
-	}
-	for _, menchie := range gtsMenchies {
-		if err := m.db.Put(menchie); err != nil {
-			return fmt.Errorf("error putting mentions in db: %s", err)
-		}
-		menchies = append(menchies, menchie.TargetAccountID)
-	}
-	// add full populated gts menchies to the status for passing them around conveniently
-	status.GTSMentions = gtsMenchies
-	// add just the ids of the mentioned accounts to the status for putting in the db
-	status.Mentions = menchies
-	return nil
-}
-
-func (m *Module) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
-	tags := []string{}
-	gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
-	if err != nil {
-		return fmt.Errorf("error generating hashtags from status: %s", err)
-	}
-	for _, tag := range gtsTags {
-		if err := m.db.Upsert(tag, "name"); err != nil {
-			return fmt.Errorf("error putting tags in db: %s", err)
-		}
-		tags = append(tags, tag.ID)
-	}
-	// add full populated gts tags to the status for passing them around conveniently
-	status.GTSTags = gtsTags
-	// add just the ids of the used tags to the status for putting in the db
-	status.Tags = tags
-	return nil
-}
-
-func (m *Module) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
-	emojis := []string{}
-	gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
-	if err != nil {
-		return fmt.Errorf("error generating emojis from status: %s", err)
-	}
-	for _, e := range gtsEmojis {
-		emojis = append(emojis, e.ID)
-	}
-	// add full populated gts emojis to the status for passing them around conveniently
-	status.GTSEmojis = gtsEmojis
-	// add just the ids of the used emojis to the status for putting in the db
-	status.Emojis = emojis
-	return nil
-}
diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go
deleted file mode 100644
index 01dfe81df..000000000
--- a/internal/apimodule/status/statusdelete.go
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package status
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusDELETEHandler verifies and handles deletion of a status
-func (m *Module) StatusDELETEHandler(c *gin.Context) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "StatusDELETEHandler",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-	l.Debugf("entering function")
-
-	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
-	if err != nil {
-		l.Debug("not authed so can't delete status")
-		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
-		return
-	}
-
-	targetStatusID := c.Param(IDKey)
-	if targetStatusID == "" {
-		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
-		return
-	}
-
-	l.Tracef("going to search for target status %s", targetStatusID)
-	targetStatus := >smodel.Status{}
-	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
-		l.Errorf("error fetching status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	if targetStatus.AccountID != authed.Account.ID {
-		l.Debug("status doesn't belong to requesting account")
-		c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"})
-		return
-	}
-
-	l.Trace("going to get relevant accounts")
-	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
-	if err != nil {
-		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	var boostOfStatus *gtsmodel.Status
-	if targetStatus.BoostOfID != "" {
-		boostOfStatus = >smodel.Status{}
-		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
-			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
-			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-			return
-		}
-	}
-
-	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
-	if err != nil {
-		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil {
-		l.Errorf("error deleting status from the database: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	m.distributor.FromClientAPI() <- distributor.FromClientAPI{
-		APObjectType:   gtsmodel.ActivityStreamsNote,
-		APActivityType: gtsmodel.ActivityStreamsDelete,
-		Activity:       targetStatus,
-	}
-
-	c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go
deleted file mode 100644
index 9ce68af09..000000000
--- a/internal/apimodule/status/statusfave.go
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package status
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusFavePOSTHandler handles fave requests against a given status ID
-func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "StatusFavePOSTHandler",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-	l.Debugf("entering function")
-
-	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
-	if err != nil {
-		l.Debug("not authed so can't fave status")
-		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
-		return
-	}
-
-	targetStatusID := c.Param(IDKey)
-	if targetStatusID == "" {
-		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
-		return
-	}
-
-	l.Tracef("going to search for target status %s", targetStatusID)
-	targetStatus := >smodel.Status{}
-	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
-		l.Errorf("error fetching status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Tracef("going to search for target account %s", targetStatus.AccountID)
-	targetAccount := >smodel.Account{}
-	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
-		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to get relevant accounts")
-	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
-	if err != nil {
-		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to see if status is visible")
-	visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
-	if err != nil {
-		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	if !visible {
-		l.Trace("status is not visible")
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	// is the status faveable?
-	if !targetStatus.VisibilityAdvanced.Likeable {
-		l.Debug("status is not faveable")
-		c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)})
-		return
-	}
-
-	// it's visible! it's faveable! so let's fave the FUCK out of it
-	fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID)
-	if err != nil {
-		l.Debugf("error faveing status: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	var boostOfStatus *gtsmodel.Status
-	if targetStatus.BoostOfID != "" {
-		boostOfStatus = >smodel.Status{}
-		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
-			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
-			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-			return
-		}
-	}
-
-	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
-	if err != nil {
-		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	// if the targeted status was already faved, faved will be nil
-	// only put the fave in the distributor if something actually changed
-	if fave != nil {
-		fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
-		m.distributor.FromClientAPI() <- distributor.FromClientAPI{
-			APObjectType:   gtsmodel.ActivityStreamsNote, // status is a note
-			APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note
-			Activity:       fave,                         // pass the fave along for processing
-		}
-	}
-
-	c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go
deleted file mode 100644
index 58236edc2..000000000
--- a/internal/apimodule/status/statusfavedby.go
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package status
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
-func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "statusGETHandler",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-	l.Debugf("entering function")
-
-	var requestingAccount *gtsmodel.Account
-	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
-	if err != nil {
-		l.Debug("not authed but will continue to serve anyway if public status")
-		requestingAccount = nil
-	} else {
-		requestingAccount = authed.Account
-	}
-
-	targetStatusID := c.Param(IDKey)
-	if targetStatusID == "" {
-		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
-		return
-	}
-
-	l.Tracef("going to search for target status %s", targetStatusID)
-	targetStatus := >smodel.Status{}
-	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
-		l.Errorf("error fetching status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Tracef("going to search for target account %s", targetStatus.AccountID)
-	targetAccount := >smodel.Account{}
-	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
-		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to get relevant accounts")
-	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
-	if err != nil {
-		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to see if status is visible")
-	visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
-	if err != nil {
-		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	if !visible {
-		l.Trace("status is not visible")
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
-	favingAccounts, err := m.db.WhoFavedStatus(targetStatus)
-	if err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	// filter the list so the user doesn't see accounts they blocked or which blocked them
-	filteredAccounts := []*gtsmodel.Account{}
-	for _, acc := range favingAccounts {
-		blocked, err := m.db.Blocked(authed.Account.ID, acc.ID)
-		if err != nil {
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-		if !blocked {
-			filteredAccounts = append(filteredAccounts, acc)
-		}
-	}
-
-	// TODO: filter other things here? suspended? muted? silenced?
-
-	// now we can return the masto representation of those accounts
-	mastoAccounts := []*mastotypes.Account{}
-	for _, acc := range filteredAccounts {
-		mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc)
-		if err != nil {
-			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-			return
-		}
-		mastoAccounts = append(mastoAccounts, mastoAccount)
-	}
-
-	c.JSON(http.StatusOK, mastoAccounts)
-}
diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go
deleted file mode 100644
index 76918c782..000000000
--- a/internal/apimodule/status/statusget.go
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package status
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusGETHandler is for handling requests to just get one status based on its ID
-func (m *Module) StatusGETHandler(c *gin.Context) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "statusGETHandler",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-	l.Debugf("entering function")
-
-	var requestingAccount *gtsmodel.Account
-	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
-	if err != nil {
-		l.Debug("not authed but will continue to serve anyway if public status")
-		requestingAccount = nil
-	} else {
-		requestingAccount = authed.Account
-	}
-
-	targetStatusID := c.Param(IDKey)
-	if targetStatusID == "" {
-		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
-		return
-	}
-
-	l.Tracef("going to search for target status %s", targetStatusID)
-	targetStatus := >smodel.Status{}
-	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
-		l.Errorf("error fetching status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Tracef("going to search for target account %s", targetStatus.AccountID)
-	targetAccount := >smodel.Account{}
-	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
-		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to get relevant accounts")
-	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
-	if err != nil {
-		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to see if status is visible")
-	visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
-	if err != nil {
-		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	if !visible {
-		l.Trace("status is not visible")
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	var boostOfStatus *gtsmodel.Status
-	if targetStatus.BoostOfID != "" {
-		boostOfStatus = >smodel.Status{}
-		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
-			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
-			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-			return
-		}
-	}
-
-	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
-	if err != nil {
-		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go
deleted file mode 100644
index 9c06eaf92..000000000
--- a/internal/apimodule/status/statusunfave.go
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package status
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
-	"github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
-func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
-	l := m.log.WithFields(logrus.Fields{
-		"func":        "StatusUnfavePOSTHandler",
-		"request_uri": c.Request.RequestURI,
-		"user_agent":  c.Request.UserAgent(),
-		"origin_ip":   c.ClientIP(),
-	})
-	l.Debugf("entering function")
-
-	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
-	if err != nil {
-		l.Debug("not authed so can't unfave status")
-		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
-		return
-	}
-
-	targetStatusID := c.Param(IDKey)
-	if targetStatusID == "" {
-		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
-		return
-	}
-
-	l.Tracef("going to search for target status %s", targetStatusID)
-	targetStatus := >smodel.Status{}
-	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
-		l.Errorf("error fetching status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Tracef("going to search for target account %s", targetStatus.AccountID)
-	targetAccount := >smodel.Account{}
-	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
-		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to get relevant accounts")
-	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
-	if err != nil {
-		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	l.Trace("going to see if status is visible")
-	visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
-	if err != nil {
-		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	if !visible {
-		l.Trace("status is not visible")
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	// is the status faveable?
-	if !targetStatus.VisibilityAdvanced.Likeable {
-		l.Debug("status is not faveable")
-		c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)})
-		return
-	}
-
-	// it's visible! it's faveable! so let's unfave the FUCK out of it
-	fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID)
-	if err != nil {
-		l.Debugf("error unfaveing status: %s", err)
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
-
-	var boostOfStatus *gtsmodel.Status
-	if targetStatus.BoostOfID != "" {
-		boostOfStatus = >smodel.Status{}
-		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
-			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
-			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-			return
-		}
-	}
-
-	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
-	if err != nil {
-		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
-		return
-	}
-
-	// fave might be nil if this status wasn't faved in the first place
-	// we only want to pass the message to the distributor if something actually changed
-	if fave != nil {
-		fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
-		m.distributor.FromClientAPI() <- distributor.FromClientAPI{
-			APObjectType:   gtsmodel.ActivityStreamsNote, // status is a note
-			APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave
-			Activity:       fave,                         // pass the undone fave along
-		}
-	}
-
-	c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/cache/mock_Cache.go b/internal/cache/mock_Cache.go
deleted file mode 100644
index d8d18d68a..000000000
--- a/internal/cache/mock_Cache.go
+++ /dev/null
@@ -1,47 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package cache
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockCache is an autogenerated mock type for the Cache type
-type MockCache struct {
-	mock.Mock
-}
-
-// Fetch provides a mock function with given fields: k
-func (_m *MockCache) Fetch(k string) (interface{}, error) {
-	ret := _m.Called(k)
-
-	var r0 interface{}
-	if rf, ok := ret.Get(0).(func(string) interface{}); ok {
-		r0 = rf(k)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(interface{})
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(string) error); ok {
-		r1 = rf(k)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// Store provides a mock function with given fields: k, v
-func (_m *MockCache) Store(k string, v interface{}) error {
-	ret := _m.Called(k, v)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
-		r0 = rf(k, v)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
diff --git a/internal/config/mock_KeyedFlags.go b/internal/config/mock_KeyedFlags.go
deleted file mode 100644
index 95057d1d3..000000000
--- a/internal/config/mock_KeyedFlags.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package config
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockKeyedFlags is an autogenerated mock type for the KeyedFlags type
-type MockKeyedFlags struct {
-	mock.Mock
-}
-
-// Bool provides a mock function with given fields: k
-func (_m *MockKeyedFlags) Bool(k string) bool {
-	ret := _m.Called(k)
-
-	var r0 bool
-	if rf, ok := ret.Get(0).(func(string) bool); ok {
-		r0 = rf(k)
-	} else {
-		r0 = ret.Get(0).(bool)
-	}
-
-	return r0
-}
-
-// Int provides a mock function with given fields: k
-func (_m *MockKeyedFlags) Int(k string) int {
-	ret := _m.Called(k)
-
-	var r0 int
-	if rf, ok := ret.Get(0).(func(string) int); ok {
-		r0 = rf(k)
-	} else {
-		r0 = ret.Get(0).(int)
-	}
-
-	return r0
-}
-
-// IsSet provides a mock function with given fields: k
-func (_m *MockKeyedFlags) IsSet(k string) bool {
-	ret := _m.Called(k)
-
-	var r0 bool
-	if rf, ok := ret.Get(0).(func(string) bool); ok {
-		r0 = rf(k)
-	} else {
-		r0 = ret.Get(0).(bool)
-	}
-
-	return r0
-}
-
-// String provides a mock function with given fields: k
-func (_m *MockKeyedFlags) String(k string) string {
-	ret := _m.Called(k)
-
-	var r0 string
-	if rf, ok := ret.Get(0).(func(string) string); ok {
-		r0 = rf(k)
-	} else {
-		r0 = ret.Get(0).(string)
-	}
-
-	return r0
-}
diff --git a/internal/db/db.go b/internal/db/db.go
index 69ad7b822..3e085e180 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -20,17 +20,13 @@ package db
 
 import (
 	"context"
-	"fmt"
 	"net"
-	"strings"
 
 	"github.com/go-fed/activity/pub"
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 )
 
-const dbTypePostgres string = "POSTGRES"
+const DBTypePostgres string = "POSTGRES"
 
 // ErrNoEntries is to be returned from the DB interface when no entries are found for a given query.
 type ErrNoEntries struct{}
@@ -126,6 +122,12 @@ type DB interface {
 	// In case of no entries, a 'no entries' error will be returned
 	GetAccountByUserID(userID string, account *gtsmodel.Account) error
 
+	// GetLocalAccountByUsername is a shortcut for the common action of fetching an account ON THIS INSTANCE
+	// according to its username, which should be unique.
+	// The given account pointer will be set to the result of the query, whatever it is.
+	// In case of no entries, a 'no entries' error will be returned
+	GetLocalAccountByUsername(username string, account *gtsmodel.Account) error
+
 	// GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID.
 	// The given slice 'followRequests' will be set to the result of the query, whatever it is.
 	// In case of no entries, a 'no entries' error will be returned
@@ -277,14 +279,3 @@ type DB interface {
 	// if they exist in the db and conveniently returning them if they do.
 	EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error)
 }
-
-// New returns a new database service that satisfies the DB interface and, by extension,
-// the go-fed database interface described here: https://github.com/go-fed/activity/blob/master/pub/database.go
-func New(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
-	switch strings.ToUpper(c.DBConfig.Type) {
-	case dbTypePostgres:
-		return newPostgresService(ctx, c, log.WithField("service", "db"))
-	default:
-		return nil, fmt.Errorf("database type %s not supported", c.DBConfig.Type)
-	}
-}
diff --git a/internal/db/federating_db.go b/internal/db/federating_db.go
index 16e3262ae..ab66b19de 100644
--- a/internal/db/federating_db.go
+++ b/internal/db/federating_db.go
@@ -21,12 +21,16 @@ package db
 import (
 	"context"
 	"errors"
+	"fmt"
 	"net/url"
 	"sync"
 
 	"github.com/go-fed/activity/pub"
 	"github.com/go-fed/activity/streams/vocab"
+	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 // FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface.
@@ -35,13 +39,15 @@ type federatingDB struct {
 	locks  *sync.Map
 	db     DB
 	config *config.Config
+	log    *logrus.Logger
 }
 
-func newFederatingDB(db DB, config *config.Config) pub.Database {
+func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database {
 	return &federatingDB{
 		locks:  new(sync.Map),
 		db:     db,
 		config: config,
+		log:    log,
 	}
 }
 
@@ -98,7 +104,30 @@ func (f *federatingDB) Unlock(c context.Context, id *url.URL) error {
 //
 // The library makes this call only after acquiring a lock first.
 func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) {
-	return false, nil
+
+	if !util.IsInboxPath(inbox) {
+		return false, fmt.Errorf("%s is not an inbox URI", inbox.String())
+	}
+
+	if !util.IsStatusesPath(id) {
+		return false, fmt.Errorf("%s is not a status URI", id.String())
+	}
+	_, statusID, err := util.ParseStatusesPath(inbox)
+	if err != nil {
+		return false, fmt.Errorf("status URI %s was not parseable: %s", id.String(), err)
+	}
+
+	if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil {
+		if _, ok := err.(ErrNoEntries); ok {
+			// we don't have it
+			return false, nil
+		}
+		// actual error
+		return false, fmt.Errorf("error getting status from db: %s", err)
+	}
+
+	// we must have it
+	return true, nil
 }
 
 // GetInbox returns the first ordered collection page of the outbox at
@@ -118,26 +147,86 @@ func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOr
 	return nil
 }
 
-// Owns returns true if the database has an entry for the IRI and it
-// exists in the database.
-//
+// Owns returns true if the IRI belongs to this instance, and if
+// the database has an entry for the IRI.
 // The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Owns(c context.Context, id *url.URL) (owns bool, err error) {
-	return false, nil
+func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) {
+	// if the id host isn't this instance host, we don't own this IRI
+	if id.Host != f.config.Host {
+		return false, nil
+	}
+
+	// apparently we own it, so what *is* it?
+
+	// check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+	if util.IsStatusesPath(id) {
+		_, uid, err := util.ParseStatusesPath(id)
+		if err != nil {
+			return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
+		}
+		if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil {
+			if _, ok := err.(ErrNoEntries); ok {
+				// there are no entries for this status
+				return false, nil
+			}
+			// an actual error happened
+			return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err)
+		}
+		return true, nil
+	}
+
+	// check if it's a user, eg /users/example_username
+	if util.IsUserPath(id) {
+		username, err := util.ParseUserPath(id)
+		if err != nil {
+			return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
+		}
+		if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil {
+			if _, ok := err.(ErrNoEntries); ok {
+				// there are no entries for this username
+				return false, nil
+			}
+			// an actual error happened
+			return false, fmt.Errorf("database error fetching account with username %s: %s", username, err)
+		}
+		return true, nil
+	}
+
+	return false, fmt.Errorf("could not match activityID: %s", id.String())
 }
 
 // ActorForOutbox fetches the actor's IRI for the given outbox IRI.
 //
 // The library makes this call only after acquiring a lock first.
 func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) {
-	return nil, nil
+	if !util.IsOutboxPath(outboxIRI) {
+		return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String())
+	}
+	acct := >smodel.Account{}
+	if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil {
+		if _, ok := err.(ErrNoEntries); ok {
+			return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String())
+		}
+		return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String())
+	}
+	return url.Parse(acct.URI)
 }
 
 // ActorForInbox fetches the actor's IRI for the given outbox IRI.
 //
 // The library makes this call only after acquiring a lock first.
 func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) {
-	return nil, nil
+	if !util.IsInboxPath(inboxIRI) {
+		return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
+	}
+	acct := >smodel.Account{}
+	if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
+		if _, ok := err.(ErrNoEntries); ok {
+			return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
+		}
+		return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
+	}
+	return url.Parse(acct.URI)
 }
 
 // OutboxForInbox fetches the corresponding actor's outbox IRI for the
@@ -145,7 +234,17 @@ func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (acto
 //
 // The library makes this call only after acquiring a lock first.
 func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) {
-	return nil, nil
+	if !util.IsInboxPath(inboxIRI) {
+		return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
+	}
+	acct := >smodel.Account{}
+	if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
+		if _, ok := err.(ErrNoEntries); ok {
+			return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
+		}
+		return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
+	}
+	return url.Parse(acct.OutboxURI)
 }
 
 // Exists returns true if the database has an entry for the specified
diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go
deleted file mode 100644
index df2e41907..000000000
--- a/internal/db/mock_DB.go
+++ /dev/null
@@ -1,484 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package db
-
-import (
-	context "context"
-
-	mock "github.com/stretchr/testify/mock"
-	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-
-	net "net"
-
-	pub "github.com/go-fed/activity/pub"
-)
-
-// MockDB is an autogenerated mock type for the DB type
-type MockDB struct {
-	mock.Mock
-}
-
-// Blocked provides a mock function with given fields: account1, account2
-func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) {
-	ret := _m.Called(account1, account2)
-
-	var r0 bool
-	if rf, ok := ret.Get(0).(func(string, string) bool); ok {
-		r0 = rf(account1, account2)
-	} else {
-		r0 = ret.Get(0).(bool)
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(string, string) error); ok {
-		r1 = rf(account1, account2)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// CreateTable provides a mock function with given fields: i
-func (_m *MockDB) CreateTable(i interface{}) error {
-	ret := _m.Called(i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(interface{}) error); ok {
-		r0 = rf(i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// DeleteByID provides a mock function with given fields: id, i
-func (_m *MockDB) DeleteByID(id string, i interface{}) error {
-	ret := _m.Called(id, i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
-		r0 = rf(id, i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// DeleteWhere provides a mock function with given fields: key, value, i
-func (_m *MockDB) DeleteWhere(key string, value interface{}, i interface{}) error {
-	ret := _m.Called(key, value, i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok {
-		r0 = rf(key, value, i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// DropTable provides a mock function with given fields: i
-func (_m *MockDB) DropTable(i interface{}) error {
-	ret := _m.Called(i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(interface{}) error); ok {
-		r0 = rf(i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// EmojiStringsToEmojis provides a mock function with given fields: emojis, originAccountID, statusID
-func (_m *MockDB) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) {
-	ret := _m.Called(emojis, originAccountID, statusID)
-
-	var r0 []*gtsmodel.Emoji
-	if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Emoji); ok {
-		r0 = rf(emojis, originAccountID, statusID)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*gtsmodel.Emoji)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
-		r1 = rf(emojis, originAccountID, statusID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// Federation provides a mock function with given fields:
-func (_m *MockDB) Federation() pub.Database {
-	ret := _m.Called()
-
-	var r0 pub.Database
-	if rf, ok := ret.Get(0).(func() pub.Database); ok {
-		r0 = rf()
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(pub.Database)
-		}
-	}
-
-	return r0
-}
-
-// GetAccountByUserID provides a mock function with given fields: userID, account
-func (_m *MockDB) GetAccountByUserID(userID string, account *gtsmodel.Account) error {
-	ret := _m.Called(userID, account)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *gtsmodel.Account) error); ok {
-		r0 = rf(userID, account)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetAll provides a mock function with given fields: i
-func (_m *MockDB) GetAll(i interface{}) error {
-	ret := _m.Called(i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(interface{}) error); ok {
-		r0 = rf(i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetAvatarForAccountID provides a mock function with given fields: avatar, accountID
-func (_m *MockDB) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error {
-	ret := _m.Called(avatar, accountID)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
-		r0 = rf(avatar, accountID)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetByID provides a mock function with given fields: id, i
-func (_m *MockDB) GetByID(id string, i interface{}) error {
-	ret := _m.Called(id, i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
-		r0 = rf(id, i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests
-func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
-	ret := _m.Called(accountID, followRequests)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.FollowRequest) error); ok {
-		r0 = rf(accountID, followRequests)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetFollowersByAccountID provides a mock function with given fields: accountID, followers
-func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error {
-	ret := _m.Called(accountID, followers)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok {
-		r0 = rf(accountID, followers)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetFollowingByAccountID provides a mock function with given fields: accountID, following
-func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error {
-	ret := _m.Called(accountID, following)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok {
-		r0 = rf(accountID, following)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetHeaderForAccountID provides a mock function with given fields: header, accountID
-func (_m *MockDB) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error {
-	ret := _m.Called(header, accountID)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
-		r0 = rf(header, accountID)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetLastStatusForAccountID provides a mock function with given fields: accountID, status
-func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error {
-	ret := _m.Called(accountID, status)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *gtsmodel.Status) error); ok {
-		r0 = rf(accountID, status)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetStatusesByAccountID provides a mock function with given fields: accountID, statuses
-func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error {
-	ret := _m.Called(accountID, statuses)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status) error); ok {
-		r0 = rf(accountID, statuses)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit
-func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error {
-	ret := _m.Called(accountID, statuses, limit)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status, int) error); ok {
-		r0 = rf(accountID, statuses, limit)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// GetWhere provides a mock function with given fields: key, value, i
-func (_m *MockDB) GetWhere(key string, value interface{}, i interface{}) error {
-	ret := _m.Called(key, value, i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok {
-		r0 = rf(key, value, i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// IsEmailAvailable provides a mock function with given fields: email
-func (_m *MockDB) IsEmailAvailable(email string) error {
-	ret := _m.Called(email)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string) error); ok {
-		r0 = rf(email)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// IsHealthy provides a mock function with given fields: ctx
-func (_m *MockDB) IsHealthy(ctx context.Context) error {
-	ret := _m.Called(ctx)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context) error); ok {
-		r0 = rf(ctx)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// IsUsernameAvailable provides a mock function with given fields: username
-func (_m *MockDB) IsUsernameAvailable(username string) error {
-	ret := _m.Called(username)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string) error); ok {
-		r0 = rf(username)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// MentionStringsToMentions provides a mock function with given fields: targetAccounts, originAccountID, statusID
-func (_m *MockDB) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) {
-	ret := _m.Called(targetAccounts, originAccountID, statusID)
-
-	var r0 []*gtsmodel.Mention
-	if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Mention); ok {
-		r0 = rf(targetAccounts, originAccountID, statusID)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*gtsmodel.Mention)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
-		r1 = rf(targetAccounts, originAccountID, statusID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID
-func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) {
-	ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID)
-
-	var r0 *gtsmodel.User
-	if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *gtsmodel.User); ok {
-		r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*gtsmodel.User)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(string, string, bool, string, string, net.IP, string, string) error); ok {
-		r1 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// Put provides a mock function with given fields: i
-func (_m *MockDB) Put(i interface{}) error {
-	ret := _m.Called(i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(interface{}) error); ok {
-		r0 = rf(i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// SetHeaderOrAvatarForAccountID provides a mock function with given fields: mediaAttachment, accountID
-func (_m *MockDB) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
-	ret := _m.Called(mediaAttachment, accountID)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
-		r0 = rf(mediaAttachment, accountID)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// Stop provides a mock function with given fields: ctx
-func (_m *MockDB) Stop(ctx context.Context) error {
-	ret := _m.Called(ctx)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context) error); ok {
-		r0 = rf(ctx)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// TagStringsToTags provides a mock function with given fields: tags, originAccountID, statusID
-func (_m *MockDB) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) {
-	ret := _m.Called(tags, originAccountID, statusID)
-
-	var r0 []*gtsmodel.Tag
-	if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Tag); ok {
-		r0 = rf(tags, originAccountID, statusID)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*gtsmodel.Tag)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
-		r1 = rf(tags, originAccountID, statusID)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// UpdateByID provides a mock function with given fields: id, i
-func (_m *MockDB) UpdateByID(id string, i interface{}) error {
-	ret := _m.Called(id, i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
-		r0 = rf(id, i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// UpdateOneByID provides a mock function with given fields: id, key, value, i
-func (_m *MockDB) UpdateOneByID(id string, key string, value interface{}, i interface{}) error {
-	ret := _m.Called(id, key, value, i)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(string, string, interface{}, interface{}) error); ok {
-		r0 = rf(id, key, value, i)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
diff --git a/internal/db/pg.go b/internal/db/pg.go
index 24a57d8a5..647285032 100644
--- a/internal/db/pg.go
+++ b/internal/db/pg.go
@@ -37,7 +37,7 @@ import (
 	"github.com/google/uuid"
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/util"
 	"golang.org/x/crypto/bcrypt"
 )
@@ -46,14 +46,14 @@ import (
 type postgresService struct {
 	config       *config.Config
 	conn         *pg.DB
-	log          *logrus.Entry
+	log          *logrus.Logger
 	cancel       context.CancelFunc
 	federationDB pub.Database
 }
 
-// newPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
+// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
 // Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection.
-func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry) (DB, error) {
+func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
 	opts, err := derivePGOptions(c)
 	if err != nil {
 		return nil, fmt.Errorf("could not create postgres service: %s", err)
@@ -67,7 +67,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
 	// this will break the logfmt format we normally log in,
 	// since we can't choose where pg outputs to and it defaults to
 	// stdout. So use this option with care!
-	if log.Logger.GetLevel() >= logrus.TraceLevel {
+	if log.GetLevel() >= logrus.TraceLevel {
 		conn.AddQueryHook(pgdebug.DebugHook{
 			// Print all queries.
 			Verbose: true,
@@ -95,7 +95,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
 		cancel: cancel,
 	}
 
-	federatingDB := newFederatingDB(ps, c)
+	federatingDB := NewFederatingDB(ps, c, log)
 	ps.federationDB = federatingDB
 
 	// we can confidently return this useable postgres service now
@@ -109,8 +109,8 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
 // derivePGOptions takes an application config and returns either a ready-to-use *pg.Options
 // with sensible defaults, or an error if it's not satisfied by the provided config.
 func derivePGOptions(c *config.Config) (*pg.Options, error) {
-	if strings.ToUpper(c.DBConfig.Type) != dbTypePostgres {
-		return nil, fmt.Errorf("expected db type of %s but got %s", dbTypePostgres, c.DBConfig.Type)
+	if strings.ToUpper(c.DBConfig.Type) != DBTypePostgres {
+		return nil, fmt.Errorf("expected db type of %s but got %s", DBTypePostgres, c.DBConfig.Type)
 	}
 
 	// validate port
@@ -341,6 +341,16 @@ func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.A
 	return nil
 }
 
+func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error {
+	if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil {
+		if err == pg.ErrNoRows {
+			return ErrNoEntries{}
+		}
+		return err
+	}
+	return nil
+}
+
 func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
 	if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil {
 		if err == pg.ErrNoRows {
@@ -456,21 +466,23 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr
 		return nil, err
 	}
 
-	uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host)
+	newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)
 
 	a := >smodel.Account{
 		Username:              username,
 		DisplayName:           username,
 		Reason:                reason,
-		URL:                   uris.UserURL,
+		URL:                   newAccountURIs.UserURL,
 		PrivateKey:            key,
 		PublicKey:             &key.PublicKey,
+		PublicKeyURI:          newAccountURIs.PublicKeyURI,
 		ActorType:             gtsmodel.ActivityStreamsPerson,
-		URI:                   uris.UserURI,
-		InboxURL:              uris.InboxURI,
-		OutboxURL:             uris.OutboxURI,
-		FollowersURL:          uris.FollowersURI,
-		FeaturedCollectionURL: uris.CollectionURI,
+		URI:                   newAccountURIs.UserURI,
+		InboxURI:              newAccountURIs.InboxURI,
+		OutboxURI:             newAccountURIs.OutboxURI,
+		FollowersURI:          newAccountURIs.FollowersURI,
+		FollowingURI:          newAccountURIs.FollowingURI,
+		FeaturedCollectionURI: newAccountURIs.CollectionURI,
 	}
 	if _, err = ps.conn.Model(a).Insert(); err != nil {
 		return nil, err
@@ -566,6 +578,7 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachmen
 }
 
 func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) {
+	// TODO: check domain blocks as well
 	var blocked bool
 	if err := ps.conn.Model(>smodel.Block{}).
 		Where("account_id = ?", account1).Where("target_account_id = ?", account2).
diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go
index f9bd21c48..a54784022 100644
--- a/internal/db/pg_test.go
+++ b/internal/db/pg_test.go
@@ -16,6 +16,6 @@
    along with this program.  If not, see .
 */
 
-package db
+package db_test
 
 // TODO: write tests for postgres
diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go
deleted file mode 100644
index 151c1b522..000000000
--- a/internal/distributor/distributor.go
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package distributor
-
-import (
-	"github.com/sirupsen/logrus"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-)
-
-// Distributor should be passed to api modules (see internal/apimodule/...). It is used for
-// passing messages back and forth from the client API and the federating interface, via channels.
-// It also contains logic for filtering which messages should end up where.
-// It is designed to be used asynchronously: the client API and the federating API should just be able to
-// fire messages into the distributor and not wait for a reply before proceeding with other work. This allows
-// for clean distribution of messages without slowing down the client API and harming the user experience.
-type Distributor interface {
-	// FromClientAPI returns a channel for accepting messages that come from the gts client API.
-	FromClientAPI() chan FromClientAPI
-	// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
-	ToClientAPI() chan ToClientAPI
-	// Start starts the Distributor, reading from its channels and passing messages back and forth.
-	Start() error
-	// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down.
-	Stop() error
-}
-
-// distributor just implements the Distributor interface
-type distributor struct {
-	// federator     pub.FederatingActor
-	fromClientAPI chan FromClientAPI
-	toClientAPI   chan ToClientAPI
-	stop          chan interface{}
-	log           *logrus.Logger
-}
-
-// New returns a new Distributor that uses the given federator and logger
-func New(log *logrus.Logger) Distributor {
-	return &distributor{
-		// federator:     federator,
-		fromClientAPI: make(chan FromClientAPI, 100),
-		toClientAPI:   make(chan ToClientAPI, 100),
-		stop:          make(chan interface{}),
-		log:           log,
-	}
-}
-
-// ClientAPIIn returns a channel for accepting messages that come from the gts client API.
-func (d *distributor) FromClientAPI() chan FromClientAPI {
-	return d.fromClientAPI
-}
-
-// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
-func (d *distributor) ToClientAPI() chan ToClientAPI {
-	return d.toClientAPI
-}
-
-// Start starts the Distributor, reading from its channels and passing messages back and forth.
-func (d *distributor) Start() error {
-	go func() {
-	DistLoop:
-		for {
-			select {
-			case clientMsg := <-d.fromClientAPI:
-				d.log.Infof("received message FROM client API: %+v", clientMsg)
-			case clientMsg := <-d.toClientAPI:
-				d.log.Infof("received message TO client API: %+v", clientMsg)
-			case <-d.stop:
-				break DistLoop
-			}
-		}
-	}()
-	return nil
-}
-
-// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down.
-// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages.
-func (d *distributor) Stop() error {
-	close(d.stop)
-	return nil
-}
-
-// FromClientAPI wraps a message that travels from the client API into the distributor
-type FromClientAPI struct {
-	APObjectType   gtsmodel.ActivityStreamsObject
-	APActivityType gtsmodel.ActivityStreamsActivity
-	Activity       interface{}
-}
-
-// ToClientAPI wraps a message that travels from the distributor into the client API
-type ToClientAPI struct {
-	APObjectType   gtsmodel.ActivityStreamsObject
-	APActivityType gtsmodel.ActivityStreamsActivity
-	Activity       interface{}
-}
diff --git a/internal/distributor/mock_Distributor.go b/internal/distributor/mock_Distributor.go
deleted file mode 100644
index 42248c3f2..000000000
--- a/internal/distributor/mock_Distributor.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package distributor
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockDistributor is an autogenerated mock type for the Distributor type
-type MockDistributor struct {
-	mock.Mock
-}
-
-// FromClientAPI provides a mock function with given fields:
-func (_m *MockDistributor) FromClientAPI() chan FromClientAPI {
-	ret := _m.Called()
-
-	var r0 chan FromClientAPI
-	if rf, ok := ret.Get(0).(func() chan FromClientAPI); ok {
-		r0 = rf()
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(chan FromClientAPI)
-		}
-	}
-
-	return r0
-}
-
-// Start provides a mock function with given fields:
-func (_m *MockDistributor) Start() error {
-	ret := _m.Called()
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func() error); ok {
-		r0 = rf()
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// Stop provides a mock function with given fields:
-func (_m *MockDistributor) Stop() error {
-	ret := _m.Called()
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func() error); ok {
-		r0 = rf()
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// ToClientAPI provides a mock function with given fields:
-func (_m *MockDistributor) ToClientAPI() chan ToClientAPI {
-	ret := _m.Called()
-
-	var r0 chan ToClientAPI
-	if rf, ok := ret.Get(0).(func() chan ToClientAPI); ok {
-		r0 = rf()
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(chan ToClientAPI)
-		}
-	}
-
-	return r0
-}
diff --git a/testrig/distributor.go b/internal/federation/clock.go
similarity index 69%
rename from testrig/distributor.go
rename to internal/federation/clock.go
index a7206e5ea..f0d6f5e84 100644
--- a/testrig/distributor.go
+++ b/internal/federation/clock.go
@@ -16,11 +16,27 @@
    along with this program.  If not, see .
 */
 
-package testrig
+package federation
 
-import "github.com/superseriousbusiness/gotosocial/internal/distributor"
+import (
+	"time"
 
-// NewTestDistributor returns a Distributor suitable for testing purposes
-func NewTestDistributor() distributor.Distributor {
-	return distributor.New(NewTestLog())
+	"github.com/go-fed/activity/pub"
+)
+
+/*
+	GOFED CLOCK INTERFACE
+	Determines the time.
+*/
+
+// Clock implements the Clock interface of go-fed
+type Clock struct{}
+
+// Now just returns the time now
+func (c *Clock) Now() time.Time {
+	return time.Now()
+}
+
+func NewClock() pub.Clock {
+	return &Clock{}
 }
diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go
new file mode 100644
index 000000000..9274e78b4
--- /dev/null
+++ b/internal/federation/commonbehavior.go
@@ -0,0 +1,152 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package federation
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/go-fed/activity/streams/vocab"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+/*
+	GOFED COMMON BEHAVIOR INTERFACE
+	Contains functions required for both the Social API and Federating Protocol.
+	It is passed to the library as a dependency injection from the client
+	application.
+*/
+
+// AuthenticateGetInbox delegates the authentication of a GET to an
+// inbox.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+//
+// If an error is returned, it is passed back to the caller of
+// GetInbox. In this case, the implementation must not write a
+// response to the ResponseWriter as is expected that the client will
+// do so when handling the error. The 'authenticated' is ignored.
+//
+// If no error is returned, but authentication or authorization fails,
+// then authenticated must be false and error nil. It is expected that
+// the implementation handles writing to the ResponseWriter in this
+// case.
+//
+// Finally, if the authentication and authorization succeeds, then
+// authenticated must be true and error nil. The request will continue
+// to be processed.
+func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
+	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+	// the CLIENT API, not through the federation API, so we just do nothing here.
+	return nil, false, nil
+}
+
+// AuthenticateGetOutbox delegates the authentication of a GET to an
+// outbox.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+//
+// If an error is returned, it is passed back to the caller of
+// GetOutbox. In this case, the implementation must not write a
+// response to the ResponseWriter as is expected that the client will
+// do so when handling the error. The 'authenticated' is ignored.
+//
+// If no error is returned, but authentication or authorization fails,
+// then authenticated must be false and error nil. It is expected that
+// the implementation handles writing to the ResponseWriter in this
+// case.
+//
+// Finally, if the authentication and authorization succeeds, then
+// authenticated must be true and error nil. The request will continue
+// to be processed.
+func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
+	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+	// the CLIENT API, not through the federation API, so we just do nothing here.
+	return nil, false, nil
+}
+
+// GetOutbox returns the OrderedCollection inbox of the actor for this
+// context. It is up to the implementation to provide the correct
+// collection for the kind of authorization given in the request.
+//
+// AuthenticateGetOutbox will be called prior to this.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+func (f *federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
+	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+	// the CLIENT API, not through the federation API, so we just do nothing here.
+	return nil, nil
+}
+
+// NewTransport returns a new Transport on behalf of a specific actor.
+//
+// The actorBoxIRI will be either the inbox or outbox of an actor who is
+// attempting to do the dereferencing or delivery. Any authentication
+// scheme applied on the request must be based on this actor. The
+// request must contain some sort of credential of the user, such as a
+// HTTP Signature.
+//
+// The gofedAgent passed in should be used by the Transport
+// implementation in the User-Agent, as well as the application-specific
+// user agent string. The gofedAgent will indicate this library's use as
+// well as the library's version number.
+//
+// Any server-wide rate-limiting that needs to occur should happen in a
+// Transport implementation. This factory function allows this to be
+// created, so peer servers are not DOS'd.
+//
+// Any retry logic should also be handled by the Transport
+// implementation.
+//
+// Note that the library will not maintain a long-lived pointer to the
+// returned Transport so that any private credentials are able to be
+// garbage collected.
+func (f *federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) {
+
+	var username string
+	var err error
+
+	if util.IsInboxPath(actorBoxIRI) {
+		username, err = util.ParseInboxPath(actorBoxIRI)
+		if err != nil {
+			return nil, fmt.Errorf("couldn't parse path %s as an inbox: %s", actorBoxIRI.String(), err)
+		}
+	} else if util.IsOutboxPath(actorBoxIRI) {
+		username, err = util.ParseOutboxPath(actorBoxIRI)
+		if err != nil {
+			return nil, fmt.Errorf("couldn't parse path %s as an outbox: %s", actorBoxIRI.String(), err)
+		}
+	} else {
+		return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String())
+	}
+
+	account := >smodel.Account{}
+	if err := f.db.GetLocalAccountByUsername(username, account); err != nil {
+		return nil, fmt.Errorf("error getting account with username %s from the db: %s", username, err)
+	}
+
+	return f.transportController.NewTransport(account.PublicKeyURI, account.PrivateKey)
+}
diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go
new file mode 100644
index 000000000..f105d9125
--- /dev/null
+++ b/internal/federation/federatingactor.go
@@ -0,0 +1,136 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package federation
+
+import (
+	"context"
+	"net/http"
+	"net/url"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/go-fed/activity/streams/vocab"
+)
+
+// federatingActor implements the go-fed federating protocol interface
+type federatingActor struct {
+	actor pub.FederatingActor
+}
+
+// newFederatingProtocol returns the gotosocial implementation of the GTSFederatingProtocol interface
+func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor {
+	actor := pub.NewFederatingActor(c, s2s, db, clock)
+
+	return &federatingActor{
+		actor: actor,
+	}
+}
+
+// Send a federated activity.
+//
+// The provided url must be the outbox of the sender. All processing of
+// the activity occurs similarly to the C2S flow:
+//   - If t is not an Activity, it is wrapped in a Create activity.
+//   - A new ID is generated for the activity.
+//   - The activity is added to the specified outbox.
+//   - The activity is prepared and delivered to recipients.
+//
+// Note that this function will only behave as expected if the
+// implementation has been constructed to support federation. This
+// method will guaranteed work for non-custom Actors. For custom actors,
+// care should be used to not call this method if only C2S is supported.
+func (f *federatingActor) Send(c context.Context, outbox *url.URL, t vocab.Type) (pub.Activity, error) {
+	return f.actor.Send(c, outbox, t)
+}
+
+// PostInbox returns true if the request was handled as an ActivityPub
+// POST to an actor's inbox. If false, the request was not an
+// ActivityPub request and may still be handled by the caller in
+// another way, such as serving a web page.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the Actor was constructed with the Federated Protocol enabled,
+// side effects will occur.
+//
+// If the Federated Protocol is not enabled, writes the
+// http.StatusMethodNotAllowed status code in the response. No side
+// effects occur.
+func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+	return f.actor.PostInbox(c, w, r)
+}
+
+// GetInbox returns true if the request was handled as an ActivityPub
+// GET to an actor's inbox. If false, the request was not an ActivityPub
+// request and may still be handled by the caller in another way, such
+// as serving a web page.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the request is an ActivityPub request, the Actor will defer to the
+// application to determine the correct authorization of the request and
+// the resulting OrderedCollection to respond with. The Actor handles
+// serializing this OrderedCollection and responding with the correct
+// headers and http.StatusOK.
+func (f *federatingActor) GetInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+	return f.actor.GetInbox(c, w, r)
+}
+
+// PostOutbox returns true if the request was handled as an ActivityPub
+// POST to an actor's outbox. If false, the request was not an
+// ActivityPub request and may still be handled by the caller in another
+// way, such as serving a web page.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the Actor was constructed with the Social Protocol enabled, side
+// effects will occur.
+//
+// If the Social Protocol is not enabled, writes the
+// http.StatusMethodNotAllowed status code in the response. No side
+// effects occur.
+//
+// If the Social and Federated Protocol are both enabled, it will handle
+// the side effects of receiving an ActivityStream Activity, and then
+// federate the Activity to peers.
+func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+	return f.actor.PostOutbox(c, w, r)
+}
+
+// GetOutbox returns true if the request was handled as an ActivityPub
+// GET to an actor's outbox. If false, the request was not an
+// ActivityPub request.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the request is an ActivityPub request, the Actor will defer to the
+// application to determine the correct authorization of the request and
+// the resulting OrderedCollection to respond with. The Actor handles
+// serializing this OrderedCollection and responding with the correct
+// headers and http.StatusOK.
+func (f *federatingActor) GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+	return f.actor.GetOutbox(c, w, r)
+}
diff --git a/internal/federation/federation.go b/internal/federation/federatingprotocol.go
similarity index 55%
rename from internal/federation/federation.go
rename to internal/federation/federatingprotocol.go
index a2aba3fcf..1764eb791 100644
--- a/internal/federation/federation.go
+++ b/internal/federation/federatingprotocol.go
@@ -16,34 +16,23 @@
    along with this program.  If not, see .
 */
 
-// Package federation provides ActivityPub/federation functionality for GoToSocial
 package federation
 
 import (
 	"context"
+	"errors"
+	"fmt"
 	"net/http"
 	"net/url"
-	"time"
 
 	"github.com/go-fed/activity/pub"
 	"github.com/go-fed/activity/streams/vocab"
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
-// New returns a go-fed compatible federating actor
-func New(db db.DB, log *logrus.Logger) pub.FederatingActor {
-	f := &Federator{
-		db: db,
-	}
-	return pub.NewFederatingActor(f, f, db.Federation(), f)
-}
-
-// Federator implements several go-fed interfaces in one convenient location
-type Federator struct {
-	db db.DB
-}
-
 /*
 	GO FED FEDERATING PROTOCOL INTERFACE
 	FederatingProtocol contains behaviors an application needs to satisfy for the
@@ -70,9 +59,21 @@ type Federator struct {
 // PostInbox. In this case, the DelegateActor implementation must not
 // write a response to the ResponseWriter as is expected that the caller
 // to PostInbox will do so when handling the error.
-func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) {
-	// TODO
-	return nil, nil
+func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) {
+	l := f.log.WithFields(logrus.Fields{
+		"func":      "PostInboxRequestBodyHook",
+		"useragent": r.UserAgent(),
+		"url":       r.URL.String(),
+	})
+
+	if activity == nil {
+		err := errors.New("nil activity in PostInboxRequestBodyHook")
+		l.Debug(err)
+		return nil, err
+	}
+
+	ctxWithActivity := context.WithValue(ctx, util.APActivity, activity)
+	return ctxWithActivity, nil
 }
 
 // AuthenticatePostInbox delegates the authentication of a POST to an
@@ -91,9 +92,54 @@ func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques
 // Finally, if the authentication and authorization succeeds, then
 // authenticated must be true and error nil. The request will continue
 // to be processed.
-func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
-	// TODO
-	return nil, false, nil
+func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
+	l := f.log.WithFields(logrus.Fields{
+		"func":      "AuthenticatePostInbox",
+		"useragent": r.UserAgent(),
+		"url":       r.URL.String(),
+	})
+	l.Trace("received request to authenticate")
+
+	requestedAccountI := ctx.Value(util.APAccount)
+	if requestedAccountI == nil {
+		return ctx, false, errors.New("requested account not set in context")
+	}
+
+	requestedAccount, ok := requestedAccountI.(*gtsmodel.Account)
+	if !ok || requestedAccount == nil {
+		return ctx, false, errors.New("requested account not parsebale from context")
+	}
+
+	publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r)
+	if err != nil {
+		l.Debugf("request not authenticated: %s", err)
+		return ctx, false, fmt.Errorf("not authenticated: %s", err)
+	}
+
+	requestingAccount := >smodel.Account{}
+	if err := f.db.GetWhere("uri", publicKeyOwnerURI.String(), requestingAccount); err != nil {
+		// there's been a proper error so return it
+		if _, ok := err.(db.ErrNoEntries); !ok {
+			return ctx, false, fmt.Errorf("error getting requesting account with public key id %s: %s", publicKeyOwnerURI.String(), err)
+		}
+
+		// we don't know this account (yet) so let's dereference it right now
+		// TODO: slow-fed
+		person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI)
+		if err != nil {
+			return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err)
+		}
+
+		a, err := f.typeConverter.ASRepresentationToAccount(person)
+		if err != nil {
+			return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err)
+		}
+		requestingAccount = a
+	}
+
+	contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount)
+
+	return contextWithRequestingAccount, true, nil
 }
 
 // Blocked should determine whether to permit a set of actors given by
@@ -110,7 +156,7 @@ func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
 // Finally, if the authentication and authorization succeeds, then
 // blocked must be false and error nil. The request will continue
 // to be processed.
-func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {
+func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {
 	// TODO
 	return false, nil
 }
@@ -134,7 +180,7 @@ func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er
 //
 // Applications are not expected to handle every single ActivityStreams
 // type and extension. The unhandled ones are passed to DefaultCallback.
-func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {
+func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {
 	// TODO
 	return pub.FederatingWrappedCallbacks{}, nil, nil
 }
@@ -146,8 +192,12 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrap
 // Applications are not expected to handle every single ActivityStreams
 // type and extension, so the unhandled ones are passed to
 // DefaultCallback.
-func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity) error {
-	// TODO
+func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity) error {
+	l := f.log.WithFields(logrus.Fields{
+		"func":   "DefaultCallback",
+		"aptype": activity.GetTypeName(),
+	})
+	l.Debugf("received unhandle-able activity type so ignoring it")
 	return nil
 }
 
@@ -155,7 +205,7 @@ func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity)
 // an activity to determine if inbox forwarding needs to occur.
 //
 // Zero or negative numbers indicate infinite recursion.
-func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
+func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
 	// TODO
 	return 0
 }
@@ -165,7 +215,7 @@ func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
 // delivery.
 //
 // Zero or negative numbers indicate infinite recursion.
-func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int {
+func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int {
 	// TODO
 	return 0
 }
@@ -177,7 +227,7 @@ func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int {
 //
 // The activity is provided as a reference for more intelligent
 // logic to be used, but the implementation must not modify it.
-func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) {
+func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) {
 	// TODO
 	return nil, nil
 }
@@ -190,114 +240,8 @@ func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []
 //
 // Always called, regardless whether the Federated Protocol or Social
 // API is enabled.
-func (f *Federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
-	// TODO
+func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
+	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+	// the CLIENT API, not through the federation API, so we just do nothing here.
 	return nil, nil
 }
-
-/*
-	GOFED COMMON BEHAVIOR INTERFACE
-	Contains functions required for both the Social API and Federating Protocol.
-	It is passed to the library as a dependency injection from the client
-	application.
-*/
-
-// AuthenticateGetInbox delegates the authentication of a GET to an
-// inbox.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-//
-// If an error is returned, it is passed back to the caller of
-// GetInbox. In this case, the implementation must not write a
-// response to the ResponseWriter as is expected that the client will
-// do so when handling the error. The 'authenticated' is ignored.
-//
-// If no error is returned, but authentication or authorization fails,
-// then authenticated must be false and error nil. It is expected that
-// the implementation handles writing to the ResponseWriter in this
-// case.
-//
-// Finally, if the authentication and authorization succeeds, then
-// authenticated must be true and error nil. The request will continue
-// to be processed.
-func (f *Federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
-	// TODO
-	// use context.WithValue() and context.Value() to set and get values through here
-	return nil, false, nil
-}
-
-// AuthenticateGetOutbox delegates the authentication of a GET to an
-// outbox.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-//
-// If an error is returned, it is passed back to the caller of
-// GetOutbox. In this case, the implementation must not write a
-// response to the ResponseWriter as is expected that the client will
-// do so when handling the error. The 'authenticated' is ignored.
-//
-// If no error is returned, but authentication or authorization fails,
-// then authenticated must be false and error nil. It is expected that
-// the implementation handles writing to the ResponseWriter in this
-// case.
-//
-// Finally, if the authentication and authorization succeeds, then
-// authenticated must be true and error nil. The request will continue
-// to be processed.
-func (f *Federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
-	// TODO
-	return nil, false, nil
-}
-
-// GetOutbox returns the OrderedCollection inbox of the actor for this
-// context. It is up to the implementation to provide the correct
-// collection for the kind of authorization given in the request.
-//
-// AuthenticateGetOutbox will be called prior to this.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-func (f *Federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
-	// TODO
-	return nil, nil
-}
-
-// NewTransport returns a new Transport on behalf of a specific actor.
-//
-// The actorBoxIRI will be either the inbox or outbox of an actor who is
-// attempting to do the dereferencing or delivery. Any authentication
-// scheme applied on the request must be based on this actor. The
-// request must contain some sort of credential of the user, such as a
-// HTTP Signature.
-//
-// The gofedAgent passed in should be used by the Transport
-// implementation in the User-Agent, as well as the application-specific
-// user agent string. The gofedAgent will indicate this library's use as
-// well as the library's version number.
-//
-// Any server-wide rate-limiting that needs to occur should happen in a
-// Transport implementation. This factory function allows this to be
-// created, so peer servers are not DOS'd.
-//
-// Any retry logic should also be handled by the Transport
-// implementation.
-//
-// Note that the library will not maintain a long-lived pointer to the
-// returned Transport so that any private credentials are able to be
-// garbage collected.
-func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) {
-	// TODO
-	return nil, nil
-}
-
-/*
-	GOFED CLOCK INTERFACE
-	Determines the time.
-*/
-
-// Now returns the current time.
-func (f *Federator) Now() time.Time {
-	return time.Now()
-}
diff --git a/internal/federation/federator.go b/internal/federation/federator.go
new file mode 100644
index 000000000..4fe0369b9
--- /dev/null
+++ b/internal/federation/federator.go
@@ -0,0 +1,79 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package federation
+
+import (
+	"net/http"
+	"net/url"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/transport"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// Federator wraps various interfaces and functions to manage activitypub federation from gotosocial
+type Federator interface {
+	// FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes.
+	FederatingActor() pub.FederatingActor
+	// AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources.
+	// The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments.
+	AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error)
+	// DereferenceRemoteAccount can be used to get the representation of a remote account, based on the account ID (which is a URI).
+	// The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments.
+	DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error)
+	// GetTransportForUser returns a new transport initialized with the key credentials belonging to the given username.
+	// This can be used for making signed http requests.
+	GetTransportForUser(username string) (pub.Transport, error)
+	pub.CommonBehavior
+	pub.FederatingProtocol
+}
+
+type federator struct {
+	config              *config.Config
+	db                  db.DB
+	clock               pub.Clock
+	typeConverter       typeutils.TypeConverter
+	transportController transport.Controller
+	actor               pub.FederatingActor
+	log                 *logrus.Logger
+}
+
+// NewFederator returns a new federator
+func NewFederator(db db.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator {
+
+	clock := &Clock{}
+	f := &federator{
+		config:              config,
+		db:                  db,
+		clock:               &Clock{},
+		typeConverter:       typeConverter,
+		transportController: transportController,
+		log:                 log,
+	}
+	actor := newFederatingActor(f, f, db.Federation(), clock)
+	f.actor = actor
+	return f
+}
+
+func (f *federator) FederatingActor() pub.FederatingActor {
+	return f.actor
+}
diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go
new file mode 100644
index 000000000..2eab09507
--- /dev/null
+++ b/internal/federation/federator_test.go
@@ -0,0 +1,190 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package federation_test
+
+import (
+	"bytes"
+	"context"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ProtocolTestSuite struct {
+	suite.Suite
+	config        *config.Config
+	db            db.DB
+	log           *logrus.Logger
+	storage       storage.Storage
+	typeConverter typeutils.TypeConverter
+	accounts      map[string]*gtsmodel.Account
+	activities    map[string]testrig.ActivityWithSignature
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *ProtocolTestSuite) SetupSuite() {
+	// setup standard items
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.log = testrig.NewTestLog()
+	suite.storage = testrig.NewTestStorage()
+	suite.typeConverter = testrig.NewTestTypeConverter(suite.db)
+	suite.accounts = testrig.NewTestAccounts()
+	suite.activities = testrig.NewTestActivities(suite.accounts)
+}
+
+func (suite *ProtocolTestSuite) SetupTest() {
+	testrig.StandardDBSetup(suite.db)
+
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *ProtocolTestSuite) TearDownTest() {
+	testrig.StandardDBTeardown(suite.db)
+}
+
+// make sure PostInboxRequestBodyHook properly sets the inbox username and activity on the context
+func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() {
+
+	// the activity we're gonna use
+	activity := suite.activities["dm_for_zork"]
+
+	// setup transport controller with a no-op client so we don't make external calls
+	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+		return nil, nil
+	}))
+	// setup module being tested
+	federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter)
+
+	// setup request
+	ctx := context.Background()
+	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting
+	request.Header.Set("Signature", activity.SignatureHeader)
+
+	// trigger the function being tested, and return the new context it creates
+	newContext, err := federator.PostInboxRequestBodyHook(ctx, request, activity.Activity)
+	assert.NoError(suite.T(), err)
+	assert.NotNil(suite.T(), newContext)
+
+	// activity should be set on context now
+	activityI := newContext.Value(util.APActivity)
+	assert.NotNil(suite.T(), activityI)
+	returnedActivity, ok := activityI.(pub.Activity)
+	assert.True(suite.T(), ok)
+	assert.NotNil(suite.T(), returnedActivity)
+	assert.EqualValues(suite.T(), activity.Activity, returnedActivity)
+}
+
+func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() {
+
+	// the activity we're gonna use
+	activity := suite.activities["dm_for_zork"]
+	sendingAccount := suite.accounts["remote_account_1"]
+	inboxAccount := suite.accounts["local_account_1"]
+
+	encodedPublicKey, err := x509.MarshalPKIXPublicKey(sendingAccount.PublicKey)
+	assert.NoError(suite.T(), err)
+	publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+		Type:  "PUBLIC KEY",
+		Bytes: encodedPublicKey,
+	})
+	publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n")
+
+	// for this test we need the client to return the public key of the activity creator on the 'remote' instance
+	responseBodyString := fmt.Sprintf(`
+	{
+		"@context": [
+			"https://www.w3.org/ns/activitystreams",
+			"https://w3id.org/security/v1"
+		],
+
+		"id": "%s",
+		"type": "Person",
+		"preferredUsername": "%s",
+		"inbox": "%s",
+
+		"publicKey": {
+			"id": "%s",
+			"owner": "%s",
+			"publicKeyPem": "%s"
+		}
+	}`, sendingAccount.URI, sendingAccount.Username, sendingAccount.InboxURI, sendingAccount.PublicKeyURI, sendingAccount.URI, publicKeyString)
+
+	// create a transport controller whose client will just return the response body string we specified above
+	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+		r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString)))
+		return &http.Response{
+			StatusCode: 200,
+			Body:       r,
+		}, nil
+	}))
+
+	// now setup module being tested, with the mock transport controller
+	federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter)
+
+	// setup request
+	ctx := context.Background()
+	// by the time AuthenticatePostInbox is called, PostInboxRequestBodyHook should have already been called,
+	// which should have set the account and username onto the request. We can replicate that behavior here:
+	ctxWithAccount := context.WithValue(ctx, util.APAccount, inboxAccount)
+	ctxWithActivity := context.WithValue(ctxWithAccount, util.APActivity, activity)
+
+	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting
+	// we need these headers for the request to be validated
+	request.Header.Set("Signature", activity.SignatureHeader)
+	request.Header.Set("Date", activity.DateHeader)
+	request.Header.Set("Digest", activity.DigestHeader)
+	// we can pass this recorder as a writer and read it back after
+	recorder := httptest.NewRecorder()
+
+	// trigger the function being tested, and return the new context it creates
+	newContext, authed, err := federator.AuthenticatePostInbox(ctxWithActivity, recorder, request)
+	assert.NoError(suite.T(), err)
+	assert.True(suite.T(), authed)
+
+	// since we know this account already it should be set on the context
+	requestingAccountI := newContext.Value(util.APRequestingAccount)
+	assert.NotNil(suite.T(), requestingAccountI)
+	requestingAccount, ok := requestingAccountI.(*gtsmodel.Account)
+	assert.True(suite.T(), ok)
+	assert.Equal(suite.T(), sendingAccount.Username, requestingAccount.Username)
+}
+
+func TestProtocolTestSuite(t *testing.T) {
+	suite.Run(t, new(ProtocolTestSuite))
+}
diff --git a/internal/federation/util.go b/internal/federation/util.go
new file mode 100644
index 000000000..ab854db7c
--- /dev/null
+++ b/internal/federation/util.go
@@ -0,0 +1,237 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package federation
+
+import (
+	"context"
+	"crypto/x509"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/go-fed/activity/streams"
+	"github.com/go-fed/activity/streams/vocab"
+	"github.com/go-fed/httpsig"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+/*
+	publicKeyer is BORROWED DIRECTLY FROM https://github.com/go-fed/apcore/blob/master/ap/util.go
+	Thank you @cj@mastodon.technology ! <3
+*/
+type publicKeyer interface {
+	GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
+}
+
+/*
+	getPublicKeyFromResponse is adapted from https://github.com/go-fed/apcore/blob/master/ap/util.go
+	Thank you @cj@mastodon.technology ! <3
+*/
+func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (vocab.W3IDSecurityV1PublicKey, error) {
+	m := make(map[string]interface{})
+	if err := json.Unmarshal(b, &m); err != nil {
+		return nil, err
+	}
+
+	t, err := streams.ToType(c, m)
+	if err != nil {
+		return nil, err
+	}
+
+	pker, ok := t.(publicKeyer)
+	if !ok {
+		return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t)
+	}
+
+	pkp := pker.GetW3IDSecurityV1PublicKey()
+	if pkp == nil {
+		return nil, errors.New("publicKey property is not provided")
+	}
+
+	var pkpFound vocab.W3IDSecurityV1PublicKey
+	for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() {
+		if !pkpIter.IsW3IDSecurityV1PublicKey() {
+			continue
+		}
+		pkValue := pkpIter.Get()
+		var pkID *url.URL
+		pkID, err = pub.GetId(pkValue)
+		if err != nil {
+			return nil, err
+		}
+		if pkID.String() != keyID.String() {
+			continue
+		}
+		pkpFound = pkValue
+		break
+	}
+
+	if pkpFound == nil {
+		return nil, fmt.Errorf("cannot find publicKey with id: %s", keyID)
+	}
+
+	return pkpFound, nil
+}
+
+// AuthenticateFederatedRequest authenticates any kind of incoming federated request from a remote server. This includes things like
+// GET requests for dereferencing our users or statuses etc, and POST requests for delivering new Activities. The function returns
+// the URL of the owner of the public key used in the http signature.
+//
+// Authenticate in this case is defined as just making sure that the http request is actually signed by whoever claims
+// to have signed it, by fetching the public key from the signature and checking it against the remote public key. This function
+// *does not* check whether the request is authorized, only whether it's authentic.
+//
+// The provided username will be used to generate a transport for making remote requests/derefencing the public key ID of the request signature.
+// Ideally you should pass in the username of the user *being requested*, so that the remote server can decide how to handle the request based on who's making it.
+// Ie., if the request on this server is for https://example.org/users/some_username then you should pass in the username 'some_username'.
+// The remote server will then know that this is the user making the dereferencing request, and they can decide to allow or deny the request depending on their settings.
+//
+// Note that it is also valid to pass in an empty string here, in which case the keys of the instance account will be used.
+//
+// Also note that this function *does not* dereference the remote account that the signature key is associated with.
+// Other functions should use the returned URL to dereference the remote account, if required.
+func (f *federator) AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) {
+	verifier, err := httpsig.NewVerifier(r)
+	if err != nil {
+		return nil, fmt.Errorf("could not create http sig verifier: %s", err)
+	}
+
+	// The key ID should be given in the signature so that we know where to fetch it from the remote server.
+	// This will be something like https://example.org/users/whatever_requesting_user#main-key
+	requestingPublicKeyID, err := url.Parse(verifier.KeyId())
+	if err != nil {
+		return nil, fmt.Errorf("could not parse key id into a url: %s", err)
+	}
+
+	transport, err := f.GetTransportForUser(username)
+	if err != nil {
+		return nil, fmt.Errorf("transport err: %s", err)
+	}
+
+	// The actual http call to the remote server is made right here in the Dereference function.
+	b, err := transport.Dereference(context.Background(), requestingPublicKeyID)
+	if err != nil {
+		return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err)
+	}
+
+	// if the key isn't in the response, we can't authenticate the request
+	requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID)
+	if err != nil {
+		return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err)
+	}
+
+	// we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey
+	pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem()
+	if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() {
+		return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value")
+	}
+
+	// and decode the PEM so that we can parse it as a golang public key
+	pubKeyPem := pkPemProp.Get()
+	block, _ := pem.Decode([]byte(pubKeyPem))
+	if block == nil || block.Type != "PUBLIC KEY" {
+		return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")
+	}
+
+	p, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("could not parse public key from block bytes: %s", err)
+	}
+	if p == nil {
+		return nil, errors.New("returned public key was empty")
+	}
+
+	// do the actual authentication here!
+	algo := httpsig.RSA_SHA256 // TODO: make this more robust
+	if err := verifier.Verify(p, algo); err != nil {
+		return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err)
+	}
+
+	// all good! we just need the URI of the key owner to return
+	pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner()
+	if pkOwnerProp == nil || !pkOwnerProp.IsIRI() {
+		return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value")
+	}
+	pkOwnerURI := pkOwnerProp.GetIRI()
+
+	return pkOwnerURI, nil
+}
+
+func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) {
+
+	transport, err := f.GetTransportForUser(username)
+	if err != nil {
+		return nil, fmt.Errorf("transport err: %s", err)
+	}
+
+	b, err := transport.Dereference(context.Background(), remoteAccountID)
+	if err != nil {
+		return nil, fmt.Errorf("error deferencing %s: %s", remoteAccountID.String(), err)
+	}
+
+	m := make(map[string]interface{})
+	if err := json.Unmarshal(b, &m); err != nil {
+		return nil, fmt.Errorf("error unmarshalling bytes into json: %s", err)
+	}
+
+	t, err := streams.ToType(context.Background(), m)
+	if err != nil {
+		return nil, fmt.Errorf("error resolving json into ap vocab type: %s", err)
+	}
+
+	switch t.GetTypeName() {
+	case string(gtsmodel.ActivityStreamsPerson):
+		p, ok := t.(vocab.ActivityStreamsPerson)
+		if !ok {
+			return nil, errors.New("error resolving type as activitystreams person")
+		}
+		return p, nil
+	case string(gtsmodel.ActivityStreamsApplication):
+		// TODO: convert application into person
+	}
+
+	return nil, fmt.Errorf("type name %s not supported", t.GetTypeName())
+}
+
+func (f *federator) GetTransportForUser(username string) (pub.Transport, error) {
+	// We need an account to use to create a transport for dereferecing the signature.
+	// If a username has been given, we can fetch the account with that username and use it.
+	// Otherwise, we can take the instance account and use those credentials to make the request.
+	ourAccount := >smodel.Account{}
+	var u string
+	if username == "" {
+		u = f.config.Host
+	} else {
+		u = username
+	}
+	if err := f.db.GetLocalAccountByUsername(u, ourAccount); err != nil {
+		return nil, fmt.Errorf("error getting account %s from db: %s", username, err)
+	}
+
+	transport, err := f.transportController.NewTransport(ourAccount.PublicKeyURI, ourAccount.PrivateKey)
+	if err != nil {
+		return nil, fmt.Errorf("error creating transport for user %s: %s", username, err)
+	}
+	return transport, nil
+}
diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go
index 2f90858b4..8d3142f84 100644
--- a/internal/gotosocial/actions.go
+++ b/internal/gotosocial/actions.go
@@ -21,36 +21,37 @@ package gotosocial
 import (
 	"context"
 	"fmt"
+	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
 
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/action"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/admin"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/app"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/auth"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
-	mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/security"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/cache"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/app"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/auth"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
+	mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/security"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/distributor"
 	"github.com/superseriousbusiness/gotosocial/internal/federation"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
 	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/transport"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 )
 
 // Run creates and starts a gotosocial server
 var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logrus.Logger) error {
-	dbService, err := db.New(ctx, c, log)
+	dbService, err := db.NewPostgresService(ctx, c, log)
 	if err != nil {
 		return fmt.Errorf("error creating dbservice: %s", err)
 	}
@@ -65,28 +66,30 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
 		return fmt.Errorf("error creating storage backend: %s", err)
 	}
 
+	// build converters and util
+	typeConverter := typeutils.NewConverter(c, dbService)
+
 	// build backend handlers
 	mediaHandler := media.New(c, dbService, storageBackend, log)
 	oauthServer := oauth.New(dbService, log)
-	distributor := distributor.New(log)
-	if err := distributor.Start(); err != nil {
-		return fmt.Errorf("error starting distributor: %s", err)
+	transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log)
+	federator := federation.NewFederator(dbService, transportController, c, log, typeConverter)
+	processor := message.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, dbService, log)
+	if err := processor.Start(); err != nil {
+		return fmt.Errorf("error starting processor: %s", err)
 	}
 
-	// build converters and util
-	mastoConverter := mastotypes.New(c, dbService)
-
 	// build client api modules
-	authModule := auth.New(oauthServer, dbService, log)
-	accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log)
-	appsModule := app.New(oauthServer, dbService, mastoConverter, log)
-	mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log)
-	fileServerModule := fileserver.New(c, dbService, storageBackend, log)
-	adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log)
-	statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log)
+	authModule := auth.New(c, dbService, oauthServer, log)
+	accountModule := account.New(c, processor, log)
+	appsModule := app.New(c, processor, log)
+	mm := mediaModule.New(c, processor, log)
+	fileServerModule := fileserver.New(c, processor, log)
+	adminModule := admin.New(c, processor, log)
+	statusModule := status.New(c, processor, log)
 	securityModule := security.New(c, log)
 
-	apiModules := []apimodule.ClientAPIModule{
+	apis := []api.ClientModule{
 		// modules with middleware go first
 		securityModule,
 		authModule,
@@ -100,20 +103,17 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
 		statusModule,
 	}
 
-	for _, m := range apiModules {
+	for _, m := range apis {
 		if err := m.Route(router); err != nil {
 			return fmt.Errorf("routing error: %s", err)
 		}
-		if err := m.CreateTables(dbService); err != nil {
-			return fmt.Errorf("table creation error: %s", err)
-		}
 	}
 
 	if err := dbService.CreateInstanceAccount(); err != nil {
 		return fmt.Errorf("error creating instance account: %s", err)
 	}
 
-	gts, err := New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c)
+	gts, err := New(dbService, router, federator, c)
 	if err != nil {
 		return fmt.Errorf("error creating gotosocial service: %s", err)
 	}
diff --git a/internal/gotosocial/gotosocial.go b/internal/gotosocial/gotosocial.go
index d8f46f873..f20e1161d 100644
--- a/internal/gotosocial/gotosocial.go
+++ b/internal/gotosocial/gotosocial.go
@@ -21,10 +21,9 @@ package gotosocial
 import (
 	"context"
 
-	"github.com/go-fed/activity/pub"
-	"github.com/superseriousbusiness/gotosocial/internal/cache"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
 	"github.com/superseriousbusiness/gotosocial/internal/router"
 )
 
@@ -38,23 +37,21 @@ type Gotosocial interface {
 // New returns a new gotosocial server, initialized with the given configuration.
 // An error will be returned the caller if something goes wrong during initialization
 // eg., no db or storage connection, port for router already in use, etc.
-func New(db db.DB, cache cache.Cache, apiRouter router.Router, federationAPI pub.FederatingActor, config *config.Config) (Gotosocial, error) {
+func New(db db.DB, apiRouter router.Router, federator federation.Federator, config *config.Config) (Gotosocial, error) {
 	return &gotosocial{
-		db:            db,
-		cache:         cache,
-		apiRouter:     apiRouter,
-		federationAPI: federationAPI,
-		config:        config,
+		db:        db,
+		apiRouter: apiRouter,
+		federator: federator,
+		config:    config,
 	}, nil
 }
 
 // gotosocial fulfils the gotosocial interface.
 type gotosocial struct {
-	db            db.DB
-	cache         cache.Cache
-	apiRouter     router.Router
-	federationAPI pub.FederatingActor
-	config        *config.Config
+	db        db.DB
+	apiRouter router.Router
+	federator federation.Federator
+	config    *config.Config
 }
 
 // Start starts up the gotosocial server. If something goes wrong
diff --git a/internal/gotosocial/mock_Gotosocial.go b/internal/gotosocial/mock_Gotosocial.go
deleted file mode 100644
index 66f776e5c..000000000
--- a/internal/gotosocial/mock_Gotosocial.go
+++ /dev/null
@@ -1,42 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package gotosocial
-
-import (
-	context "context"
-
-	mock "github.com/stretchr/testify/mock"
-)
-
-// MockGotosocial is an autogenerated mock type for the Gotosocial type
-type MockGotosocial struct {
-	mock.Mock
-}
-
-// Start provides a mock function with given fields: _a0
-func (_m *MockGotosocial) Start(_a0 context.Context) error {
-	ret := _m.Called(_a0)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context) error); ok {
-		r0 = rf(_a0)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// Stop provides a mock function with given fields: _a0
-func (_m *MockGotosocial) Stop(_a0 context.Context) error {
-	ret := _m.Called(_a0)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context) error); ok {
-		r0 = rf(_a0)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
diff --git a/internal/db/gtsmodel/README.md b/internal/gtsmodel/README.md
similarity index 100%
rename from internal/db/gtsmodel/README.md
rename to internal/gtsmodel/README.md
diff --git a/internal/db/gtsmodel/account.go b/internal/gtsmodel/account.go
similarity index 90%
rename from internal/db/gtsmodel/account.go
rename to internal/gtsmodel/account.go
index 4bf5a9d33..181b061df 100644
--- a/internal/db/gtsmodel/account.go
+++ b/internal/gtsmodel/account.go
@@ -46,8 +46,12 @@ type Account struct {
 
 	// ID of the avatar as a media attachment
 	AvatarMediaAttachmentID string
+	// For a non-local account, where can the header be fetched?
+	AvatarRemoteURL string
 	// ID of the header as a media attachment
 	HeaderMediaAttachmentID string
+	// For a non-local account, where can the header be fetched?
+	HeaderRemoteURL string
 	// DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
 	DisplayName string
 	// a key/value map of fields that this account has added to their profile
@@ -93,15 +97,15 @@ type Account struct {
 	// Last time this account was located using the webfinger API.
 	LastWebfingeredAt time.Time `pg:"type:timestamp"`
 	// Address of this account's activitypub inbox, for sending activity to
-	InboxURL string `pg:",unique"`
+	InboxURI string `pg:",unique"`
 	// Address of this account's activitypub outbox
-	OutboxURL string `pg:",unique"`
-	// Don't support shared inbox right now so this is just a stub for a future implementation
-	SharedInboxURL string `pg:",unique"`
-	// URL for getting the followers list of this account
-	FollowersURL string `pg:",unique"`
+	OutboxURI string `pg:",unique"`
+	// URI for getting the following list of this account
+	FollowingURI string `pg:",unique"`
+	// URI for getting the followers list of this account
+	FollowersURI string `pg:",unique"`
 	// URL for getting the featured collection list of this account
-	FeaturedCollectionURL string `pg:",unique"`
+	FeaturedCollectionURI string `pg:",unique"`
 	// What type of activitypub actor is this account?
 	ActorType ActivityStreamsActor
 	// This account is associated with x account id
@@ -115,6 +119,8 @@ type Account struct {
 	PrivateKey *rsa.PrivateKey
 	// Publickey for encoding activitypub requests, will be defined for both local and remote accounts
 	PublicKey *rsa.PublicKey
+	// Web-reachable location of this account's public key
+	PublicKeyURI string
 
 	/*
 		ADMIN FIELDS
diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/gtsmodel/activitystreams.go
similarity index 100%
rename from internal/db/gtsmodel/activitystreams.go
rename to internal/gtsmodel/activitystreams.go
diff --git a/internal/db/gtsmodel/application.go b/internal/gtsmodel/application.go
similarity index 100%
rename from internal/db/gtsmodel/application.go
rename to internal/gtsmodel/application.go
diff --git a/internal/db/gtsmodel/block.go b/internal/gtsmodel/block.go
similarity index 100%
rename from internal/db/gtsmodel/block.go
rename to internal/gtsmodel/block.go
diff --git a/internal/db/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go
similarity index 100%
rename from internal/db/gtsmodel/domainblock.go
rename to internal/gtsmodel/domainblock.go
diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go
similarity index 100%
rename from internal/db/gtsmodel/emaildomainblock.go
rename to internal/gtsmodel/emaildomainblock.go
diff --git a/internal/db/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go
similarity index 97%
rename from internal/db/gtsmodel/emoji.go
rename to internal/gtsmodel/emoji.go
index c11e2e6b0..c175a1c57 100644
--- a/internal/db/gtsmodel/emoji.go
+++ b/internal/gtsmodel/emoji.go
@@ -58,6 +58,8 @@ type Emoji struct {
 	// MIME content type of the emoji image
 	// Probably "image/png"
 	ImageContentType string `pg:",notnull"`
+	// MIME content type of the static version of the emoji image.
+	ImageStaticContentType string `pg:",notnull"`
 	// Size of the emoji image file in bytes, for serving purposes.
 	ImageFileSize int `pg:",notnull"`
 	// Size of the static version of the emoji image file in bytes, for serving purposes.
diff --git a/internal/db/gtsmodel/follow.go b/internal/gtsmodel/follow.go
similarity index 100%
rename from internal/db/gtsmodel/follow.go
rename to internal/gtsmodel/follow.go
diff --git a/internal/db/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go
similarity index 100%
rename from internal/db/gtsmodel/followrequest.go
rename to internal/gtsmodel/followrequest.go
diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go
similarity index 96%
rename from internal/db/gtsmodel/mediaattachment.go
rename to internal/gtsmodel/mediaattachment.go
index 751956252..e98602842 100644
--- a/internal/db/gtsmodel/mediaattachment.go
+++ b/internal/gtsmodel/mediaattachment.go
@@ -108,15 +108,15 @@ type FileType string
 
 const (
 	// FileTypeImage is for jpegs and pngs
-	FileTypeImage FileType = "image"
+	FileTypeImage FileType = "Image"
 	// FileTypeGif is for native gifs and soundless videos that have been converted to gifs
-	FileTypeGif FileType = "gif"
+	FileTypeGif FileType = "Gif"
 	// FileTypeAudio is for audio-only files (no video)
-	FileTypeAudio FileType = "audio"
+	FileTypeAudio FileType = "Audio"
 	// FileTypeVideo is for files with audio + visual
-	FileTypeVideo FileType = "video"
+	FileTypeVideo FileType = "Video"
 	// FileTypeUnknown is for unknown file types (surprise surprise!)
-	FileTypeUnknown FileType = "unknown"
+	FileTypeUnknown FileType = "Unknown"
 )
 
 // FileMeta describes metadata about the actual contents of the file.
diff --git a/internal/db/gtsmodel/mention.go b/internal/gtsmodel/mention.go
similarity index 100%
rename from internal/db/gtsmodel/mention.go
rename to internal/gtsmodel/mention.go
diff --git a/internal/db/gtsmodel/poll.go b/internal/gtsmodel/poll.go
similarity index 100%
rename from internal/db/gtsmodel/poll.go
rename to internal/gtsmodel/poll.go
diff --git a/internal/db/gtsmodel/status.go b/internal/gtsmodel/status.go
similarity index 100%
rename from internal/db/gtsmodel/status.go
rename to internal/gtsmodel/status.go
diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go
similarity index 100%
rename from internal/db/gtsmodel/statusbookmark.go
rename to internal/gtsmodel/statusbookmark.go
diff --git a/internal/db/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go
similarity index 100%
rename from internal/db/gtsmodel/statusfave.go
rename to internal/gtsmodel/statusfave.go
diff --git a/internal/db/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go
similarity index 100%
rename from internal/db/gtsmodel/statusmute.go
rename to internal/gtsmodel/statusmute.go
diff --git a/internal/db/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go
similarity index 100%
rename from internal/db/gtsmodel/statuspin.go
rename to internal/gtsmodel/statuspin.go
diff --git a/internal/db/gtsmodel/tag.go b/internal/gtsmodel/tag.go
similarity index 100%
rename from internal/db/gtsmodel/tag.go
rename to internal/gtsmodel/tag.go
diff --git a/internal/db/gtsmodel/user.go b/internal/gtsmodel/user.go
similarity index 100%
rename from internal/db/gtsmodel/user.go
rename to internal/gtsmodel/user.go
diff --git a/internal/mastotypes/mastomodel/README.md b/internal/mastotypes/mastomodel/README.md
deleted file mode 100644
index 38f9e89c4..000000000
--- a/internal/mastotypes/mastomodel/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Mastotypes
-
-This package contains Go types/structs for Mastodon's REST API.
-
-See [here](https://docs.joinmastodon.org/methods/apps/).
diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go
deleted file mode 100644
index 732d933ae..000000000
--- a/internal/mastotypes/mock_Converter.go
+++ /dev/null
@@ -1,148 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package mastotypes
-
-import (
-	mock "github.com/stretchr/testify/mock"
-	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-)
-
-// MockConverter is an autogenerated mock type for the Converter type
-type MockConverter struct {
-	mock.Mock
-}
-
-// AccountToMastoPublic provides a mock function with given fields: account
-func (_m *MockConverter) AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) {
-	ret := _m.Called(account)
-
-	var r0 *mastotypes.Account
-	if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok {
-		r0 = rf(account)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*mastotypes.Account)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok {
-		r1 = rf(account)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// AccountToMastoSensitive provides a mock function with given fields: account
-func (_m *MockConverter) AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) {
-	ret := _m.Called(account)
-
-	var r0 *mastotypes.Account
-	if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok {
-		r0 = rf(account)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*mastotypes.Account)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok {
-		r1 = rf(account)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// AppToMastoPublic provides a mock function with given fields: application
-func (_m *MockConverter) AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) {
-	ret := _m.Called(application)
-
-	var r0 *mastotypes.Application
-	if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok {
-		r0 = rf(application)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*mastotypes.Application)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok {
-		r1 = rf(application)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// AppToMastoSensitive provides a mock function with given fields: application
-func (_m *MockConverter) AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) {
-	ret := _m.Called(application)
-
-	var r0 *mastotypes.Application
-	if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok {
-		r0 = rf(application)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*mastotypes.Application)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok {
-		r1 = rf(application)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// AttachmentToMasto provides a mock function with given fields: attachment
-func (_m *MockConverter) AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) {
-	ret := _m.Called(attachment)
-
-	var r0 mastotypes.Attachment
-	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment) mastotypes.Attachment); ok {
-		r0 = rf(attachment)
-	} else {
-		r0 = ret.Get(0).(mastotypes.Attachment)
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(*gtsmodel.MediaAttachment) error); ok {
-		r1 = rf(attachment)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// MentionToMasto provides a mock function with given fields: m
-func (_m *MockConverter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) {
-	ret := _m.Called(m)
-
-	var r0 mastotypes.Mention
-	if rf, ok := ret.Get(0).(func(*gtsmodel.Mention) mastotypes.Mention); ok {
-		r0 = rf(m)
-	} else {
-		r0 = ret.Get(0).(mastotypes.Mention)
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(*gtsmodel.Mention) error); ok {
-		r1 = rf(m)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
diff --git a/internal/media/media.go b/internal/media/media.go
index df8c01e48..c6403fc81 100644
--- a/internal/media/media.go
+++ b/internal/media/media.go
@@ -28,25 +28,32 @@ import (
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/storage"
 )
 
+// Size describes the *size* of a piece of media
+type Size string
+
+// Type describes the *type* of a piece of media
+type Type string
+
 const (
-	// MediaSmall is the key for small/thumbnail versions of media
-	MediaSmall = "small"
-	// MediaOriginal is the key for original/fullsize versions of media and emoji
-	MediaOriginal = "original"
-	// MediaStatic is the key for static (non-animated) versions of emoji
-	MediaStatic = "static"
-	// MediaAttachment is the key for media attachments
-	MediaAttachment = "attachment"
-	// MediaHeader is the key for profile header requests
-	MediaHeader = "header"
-	// MediaAvatar is the key for profile avatar requests
-	MediaAvatar = "avatar"
-	// MediaEmoji is the key for emoji type requests
-	MediaEmoji = "emoji"
+	// Small is the key for small/thumbnail versions of media
+	Small Size = "small"
+	// Original is the key for original/fullsize versions of media and emoji
+	Original Size = "original"
+	// Static is the key for static (non-animated) versions of emoji
+	Static Size = "static"
+
+	// Attachment is the key for media attachments
+	Attachment Type = "attachment"
+	// Header is the key for profile header requests
+	Header Type = "header"
+	// Avatar is the key for profile avatar requests
+	Avatar Type = "avatar"
+	// Emoji is the key for emoji type requests
+	Emoji Type = "emoji"
 
 	// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
 	EmojiMaxBytes = 51200
@@ -57,7 +64,7 @@ type Handler interface {
 	// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
 	// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
 	// and then returns information to the caller about the new header.
-	ProcessHeaderOrAvatar(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error)
+	ProcessHeaderOrAvatar(img []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error)
 
 	// ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,
 	// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
@@ -94,10 +101,10 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo
 // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
 // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
 // and then returns information to the caller about the new header.
-func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) {
+func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) {
 	l := mh.log.WithField("func", "SetHeaderForAccountID")
 
-	if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar {
+	if mediaType != Header && mediaType != Avatar {
 		return nil, errors.New("header or avatar not selected")
 	}
 
@@ -106,7 +113,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin
 	if err != nil {
 		return nil, err
 	}
-	if !supportedImageType(contentType) {
+	if !SupportedImageType(contentType) {
 		return nil, fmt.Errorf("%s is not an accepted image type", contentType)
 	}
 
@@ -116,14 +123,14 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin
 	l.Tracef("read %d bytes of file", len(attachment))
 
 	// process it
-	ma, err := mh.processHeaderOrAvi(attachment, contentType, headerOrAvi, accountID)
+	ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID)
 	if err != nil {
-		return nil, fmt.Errorf("error processing %s: %s", headerOrAvi, err)
+		return nil, fmt.Errorf("error processing %s: %s", mediaType, err)
 	}
 
 	// set it in the database
 	if err := mh.db.SetHeaderOrAvatarForAccountID(ma, accountID); err != nil {
-		return nil, fmt.Errorf("error putting %s in database: %s", headerOrAvi, err)
+		return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err)
 	}
 
 	return ma, nil
@@ -139,8 +146,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri
 	}
 	mainType := strings.Split(contentType, "/")[0]
 	switch mainType {
-	case "video":
-		if !supportedVideoType(contentType) {
+	case MIMEVideo:
+		if !SupportedVideoType(contentType) {
 			return nil, fmt.Errorf("video type %s not supported", contentType)
 		}
 		if len(attachment) == 0 {
@@ -150,8 +157,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri
 			return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize)
 		}
 		return mh.processVideoAttachment(attachment, accountID, contentType)
-	case "image":
-		if !supportedImageType(contentType) {
+	case MIMEImage:
+		if !SupportedImageType(contentType) {
 			return nil, fmt.Errorf("image type %s not supported", contentType)
 		}
 		if len(attachment) == 0 {
@@ -192,13 +199,13 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
 		return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes)
 	}
 
-	// clean any exif data from image/png type but leave gifs alone
+	// clean any exif data from png but leave gifs alone
 	switch contentType {
-	case "image/png":
+	case MIMEPng:
 		if clean, err = purgeExif(emojiBytes); err != nil {
 			return nil, fmt.Errorf("error cleaning exif data: %s", err)
 		}
-	case "image/gif":
+	case MIMEGif:
 		clean = emojiBytes
 	default:
 		return nil, errors.New("media type unrecognized")
@@ -218,7 +225,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
 	// (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created
 	// with the same username as the instance hostname, which doesn't belong to any particular user.
 	instanceAccount := >smodel.Account{}
-	if err := mh.db.GetWhere("username", mh.config.Host, instanceAccount); err != nil {
+	if err := mh.db.GetLocalAccountByUsername(mh.config.Host, instanceAccount); err != nil {
 		return nil, fmt.Errorf("error fetching instance account: %s", err)
 	}
 
@@ -234,15 +241,15 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
 
 	// webfinger uri for the emoji -- unrelated to actually serving the image
 	// will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c
-	emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, MediaEmoji, newEmojiID)
+	emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, Emoji, newEmojiID)
 
 	// serve url and storage path for the original emoji -- can be png or gif
-	emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension)
-	emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension)
+	emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
+	emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
 
 	// serve url and storage path for the static version -- will always be png
-	emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID)
-	emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID)
+	emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, Emoji, Static, newEmojiID)
+	emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Static, newEmojiID)
 
 	// store the original
 	if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil {
@@ -256,25 +263,26 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
 
 	// and finally return the new emoji data to the caller -- it's up to them what to do with it
 	e := >smodel.Emoji{
-		ID:                   newEmojiID,
-		Shortcode:            shortcode,
-		Domain:               "", // empty because this is a local emoji
-		CreatedAt:            time.Now(),
-		UpdatedAt:            time.Now(),
-		ImageRemoteURL:       "", // empty because this is a local emoji
-		ImageStaticRemoteURL: "", // empty because this is a local emoji
-		ImageURL:             emojiURL,
-		ImageStaticURL:       emojiStaticURL,
-		ImagePath:            emojiPath,
-		ImageStaticPath:      emojiStaticPath,
-		ImageContentType:     contentType,
-		ImageFileSize:        len(original.image),
-		ImageStaticFileSize:  len(static.image),
-		ImageUpdatedAt:       time.Now(),
-		Disabled:             false,
-		URI:                  emojiURI,
-		VisibleInPicker:      true,
-		CategoryID:           "", // empty because this is a new emoji -- no category yet
+		ID:                     newEmojiID,
+		Shortcode:              shortcode,
+		Domain:                 "", // empty because this is a local emoji
+		CreatedAt:              time.Now(),
+		UpdatedAt:              time.Now(),
+		ImageRemoteURL:         "", // empty because this is a local emoji
+		ImageStaticRemoteURL:   "", // empty because this is a local emoji
+		ImageURL:               emojiURL,
+		ImageStaticURL:         emojiStaticURL,
+		ImagePath:              emojiPath,
+		ImageStaticPath:        emojiStaticPath,
+		ImageContentType:       contentType,
+		ImageStaticContentType: MIMEPng, // static version will always be a png
+		ImageFileSize:          len(original.image),
+		ImageStaticFileSize:    len(static.image),
+		ImageUpdatedAt:         time.Now(),
+		Disabled:               false,
+		URI:                    emojiURI,
+		VisibleInPicker:        true,
+		CategoryID:             "", // empty because this is a new emoji -- no category yet
 	}
 	return e, nil
 }
@@ -294,7 +302,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
 	var small *imageAndMeta
 
 	switch contentType {
-	case "image/jpeg", "image/png":
+	case MIMEJpeg, MIMEPng:
 		if clean, err = purgeExif(data); err != nil {
 			return nil, fmt.Errorf("error cleaning exif data: %s", err)
 		}
@@ -302,7 +310,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
 		if err != nil {
 			return nil, fmt.Errorf("error parsing image: %s", err)
 		}
-	case "image/gif":
+	case MIMEGif:
 		clean = data
 		original, err = deriveGif(clean, contentType)
 		if err != nil {
@@ -326,13 +334,13 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
 	smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg
 
 	// we store the original...
-	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, newMediaID, extension)
+	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, Attachment, Original, newMediaID, extension)
 	if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
 		return nil, fmt.Errorf("storage error: %s", err)
 	}
 
 	// and a thumbnail...
-	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg
+	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, Attachment, Small, newMediaID) // all thumbnails/smalls are encoded as jpeg
 	if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
 		return nil, fmt.Errorf("storage error: %s", err)
 	}
@@ -372,7 +380,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
 		},
 		Thumbnail: gtsmodel.Thumbnail{
 			Path:        smallPath,
-			ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg
+			ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg
 			FileSize:    len(small.image),
 			UpdatedAt:   time.Now(),
 			URL:         smallURL,
@@ -386,14 +394,14 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
 
 }
 
-func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*gtsmodel.MediaAttachment, error) {
+func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) {
 	var isHeader bool
 	var isAvatar bool
 
-	switch headerOrAvi {
-	case MediaHeader:
+	switch mediaType {
+	case Header:
 		isHeader = true
-	case MediaAvatar:
+	case Avatar:
 		isAvatar = true
 	default:
 		return nil, errors.New("header or avatar not selected")
@@ -403,15 +411,15 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string
 	var err error
 
 	switch contentType {
-	case "image/jpeg":
+	case MIMEJpeg:
 		if clean, err = purgeExif(imageBytes); err != nil {
 			return nil, fmt.Errorf("error cleaning exif data: %s", err)
 		}
-	case "image/png":
+	case MIMEPng:
 		if clean, err = purgeExif(imageBytes); err != nil {
 			return nil, fmt.Errorf("error cleaning exif data: %s", err)
 		}
-	case "image/gif":
+	case MIMEGif:
 		clean = imageBytes
 	default:
 		return nil, errors.New("media type unrecognized")
@@ -432,17 +440,17 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string
 	newMediaID := uuid.NewString()
 
 	URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
-	originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension)
-	smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension)
+	originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
+	smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
 
 	// we store the original...
-	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaOriginal, newMediaID, extension)
+	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Original, newMediaID, extension)
 	if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
 		return nil, fmt.Errorf("storage error: %s", err)
 	}
 
 	// and a thumbnail...
-	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaSmall, newMediaID, extension)
+	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Small, newMediaID, extension)
 	if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
 		return nil, fmt.Errorf("storage error: %s", err)
 	}
diff --git a/internal/media/media_test.go b/internal/media/media_test.go
index 58f2e029e..8045295d2 100644
--- a/internal/media/media_test.go
+++ b/internal/media/media_test.go
@@ -29,7 +29,7 @@ import (
 	"github.com/stretchr/testify/suite"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/storage"
 )
 
@@ -78,7 +78,7 @@ func (suite *MediaTestSuite) SetupSuite() {
 	}
 	suite.config = c
 	// use an actual database for this, because it's just easier than mocking one out
-	database, err := db.New(context.Background(), c, log)
+	database, err := db.NewPostgresService(context.Background(), c, log)
 	if err != nil {
 		suite.FailNow(err.Error())
 	}
diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go
index 1f875557a..10fffbba4 100644
--- a/internal/media/mock_MediaHandler.go
+++ b/internal/media/mock_MediaHandler.go
@@ -4,7 +4,7 @@ package media
 
 import (
 	mock "github.com/stretchr/testify/mock"
-	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 )
 
 // MockMediaHandler is an autogenerated mock type for the MediaHandler type
diff --git a/internal/media/util.go b/internal/media/util.go
index 64d1ee770..f4f2819af 100644
--- a/internal/media/util.go
+++ b/internal/media/util.go
@@ -33,6 +33,26 @@ import (
 	"github.com/superseriousbusiness/exifremove/pkg/exifremove"
 )
 
+const (
+	// MIMEImage is the mime type for image
+	MIMEImage = "image"
+	// MIMEJpeg is the jpeg image mime type
+	MIMEJpeg = "image/jpeg"
+	// MIMEGif is the gif image mime type
+	MIMEGif = "image/gif"
+	// MIMEPng is the png image mime type
+	MIMEPng = "image/png"
+
+	// MIMEVideo is the mime type for video
+	MIMEVideo = "video"
+	// MIMEMp4 is the mp4 video mime type
+	MIMEMp4 = "video/mp4"
+	// MIMEMpeg is the mpeg video mime type
+	MIMEMpeg = "video/mpeg"
+	// MIMEWebm is the webm video mime type
+	MIMEWebm = "video/webm"
+)
+
 // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg").
 // Returns an error if the content type is not something we can process.
 func parseContentType(content []byte) (string, error) {
@@ -54,13 +74,13 @@ func parseContentType(content []byte) (string, error) {
 	return kind.MIME.Value, nil
 }
 
-// supportedImageType checks mime type of an image against a slice of accepted types,
+// SupportedImageType checks mime type of an image against a slice of accepted types,
 // and returns True if the mime type is accepted.
-func supportedImageType(mimeType string) bool {
+func SupportedImageType(mimeType string) bool {
 	acceptedImageTypes := []string{
-		"image/jpeg",
-		"image/gif",
-		"image/png",
+		MIMEJpeg,
+		MIMEGif,
+		MIMEPng,
 	}
 	for _, accepted := range acceptedImageTypes {
 		if mimeType == accepted {
@@ -70,13 +90,13 @@ func supportedImageType(mimeType string) bool {
 	return false
 }
 
-// supportedVideoType checks mime type of a video against a slice of accepted types,
+// SupportedVideoType checks mime type of a video against a slice of accepted types,
 // and returns True if the mime type is accepted.
-func supportedVideoType(mimeType string) bool {
+func SupportedVideoType(mimeType string) bool {
 	acceptedVideoTypes := []string{
-		"video/mp4",
-		"video/mpeg",
-		"video/webm",
+		MIMEMp4,
+		MIMEMpeg,
+		MIMEWebm,
 	}
 	for _, accepted := range acceptedVideoTypes {
 		if mimeType == accepted {
@@ -89,8 +109,8 @@ func supportedVideoType(mimeType string) bool {
 // supportedEmojiType checks that the content type is image/png -- the only type supported for emoji.
 func supportedEmojiType(mimeType string) bool {
 	acceptedEmojiTypes := []string{
-		"image/gif",
-		"image/png",
+		MIMEGif,
+		MIMEPng,
 	}
 	for _, accepted := range acceptedEmojiTypes {
 		if mimeType == accepted {
@@ -121,7 +141,7 @@ func deriveGif(b []byte, extension string) (*imageAndMeta, error) {
 	var g *gif.GIF
 	var err error
 	switch extension {
-	case "image/gif":
+	case MIMEGif:
 		g, err = gif.DecodeAll(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
@@ -161,12 +181,12 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) {
 	var err error
 
 	switch contentType {
-	case "image/jpeg":
+	case MIMEJpeg:
 		i, err = jpeg.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
 		}
-	case "image/png":
+	case MIMEPng:
 		i, err = png.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
@@ -210,17 +230,17 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet
 	var err error
 
 	switch contentType {
-	case "image/jpeg":
+	case MIMEJpeg:
 		i, err = jpeg.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
 		}
-	case "image/png":
+	case MIMEPng:
 		i, err = png.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
 		}
-	case "image/gif":
+	case MIMEGif:
 		i, err = gif.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
@@ -254,12 +274,12 @@ func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) {
 	var err error
 
 	switch contentType {
-	case "image/png":
+	case MIMEPng:
 		i, err = png.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
 		}
-	case "image/gif":
+	case MIMEGif:
 		i, err = gif.Decode(bytes.NewReader(b))
 		if err != nil {
 			return nil, err
@@ -285,3 +305,31 @@ type imageAndMeta struct {
 	aspect   float64
 	blurhash string
 }
+
+// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized
+func ParseMediaType(s string) (Type, error) {
+	switch Type(s) {
+	case Attachment:
+		return Attachment, nil
+	case Header:
+		return Header, nil
+	case Avatar:
+		return Avatar, nil
+	case Emoji:
+		return Emoji, nil
+	}
+	return "", fmt.Errorf("%s not a recognized MediaType", s)
+}
+
+// ParseMediaSize converts s to a recognized MediaSize, or returns an error if unrecognized
+func ParseMediaSize(s string) (Size, error) {
+	switch Size(s) {
+	case Small:
+		return Small, nil
+	case Original:
+		return Original, nil
+	case Static:
+		return Static, nil
+	}
+	return "", fmt.Errorf("%s not a recognized MediaSize", s)
+}
diff --git a/internal/media/util_test.go b/internal/media/util_test.go
index be617a256..db2cca690 100644
--- a/internal/media/util_test.go
+++ b/internal/media/util_test.go
@@ -135,10 +135,10 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() {
 }
 
 func (suite *MediaUtilTestSuite) TestSupportedImageTypes() {
-	ok := supportedImageType("image/jpeg")
+	ok := SupportedImageType("image/jpeg")
 	assert.True(suite.T(), ok)
 
-	ok = supportedImageType("image/bmp")
+	ok = SupportedImageType("image/bmp")
 	assert.False(suite.T(), ok)
 }
 
diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go
new file mode 100644
index 000000000..9433140d7
--- /dev/null
+++ b/internal/message/accountprocess.go
@@ -0,0 +1,168 @@
+package message
+
+import (
+	"errors"
+	"fmt"
+
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// accountCreate does the dirty work of making an account and user in the database.
+// It then returns a token to the caller, for use with the new account, as per the
+// spec here: https://docs.joinmastodon.org/methods/accounts/
+func (p *processor) AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) {
+	l := p.log.WithField("func", "accountCreate")
+
+	if err := p.db.IsEmailAvailable(form.Email); err != nil {
+		return nil, err
+	}
+
+	if err := p.db.IsUsernameAvailable(form.Username); err != nil {
+		return nil, err
+	}
+
+	// don't store a reason if we don't require one
+	reason := form.Reason
+	if !p.config.AccountsConfig.ReasonRequired {
+		reason = ""
+	}
+
+	l.Trace("creating new username and account")
+	user, err := p.db.NewSignup(form.Username, reason, p.config.AccountsConfig.RequireApproval, form.Email, form.Password, form.IP, form.Locale, authed.Application.ID)
+	if err != nil {
+		return nil, fmt.Errorf("error creating new signup in the database: %s", err)
+	}
+
+	l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, authed.Application.ID)
+	accessToken, err := p.oauthServer.GenerateUserAccessToken(authed.Token, authed.Application.ClientSecret, user.ID)
+	if err != nil {
+		return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)
+	}
+
+	return &apimodel.Token{
+		AccessToken: accessToken.GetAccess(),
+		TokenType:   "Bearer",
+		Scope:       accessToken.GetScope(),
+		CreatedAt:   accessToken.GetAccessCreateAt().Unix(),
+	}, nil
+}
+
+func (p *processor) AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) {
+	targetAccount := >smodel.Account{}
+	if err := p.db.GetByID(targetAccountID, targetAccount); err != nil {
+		if _, ok := err.(db.ErrNoEntries); ok {
+			return nil, errors.New("account not found")
+		}
+		return nil, fmt.Errorf("db error: %s", err)
+	}
+
+	var mastoAccount *apimodel.Account
+	var err error
+	if authed.Account != nil && targetAccount.ID == authed.Account.ID {
+		mastoAccount, err = p.tc.AccountToMastoSensitive(targetAccount)
+	} else {
+		mastoAccount, err = p.tc.AccountToMastoPublic(targetAccount)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("error converting account: %s", err)
+	}
+	return mastoAccount, nil
+}
+
+func (p *processor) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) {
+	l := p.log.WithField("func", "AccountUpdate")
+
+	if form.Discoverable != nil {
+		if err := p.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil {
+			return nil, fmt.Errorf("error updating discoverable: %s", err)
+		}
+	}
+
+	if form.Bot != nil {
+		if err := p.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil {
+			return nil, fmt.Errorf("error updating bot: %s", err)
+		}
+	}
+
+	if form.DisplayName != nil {
+		if err := util.ValidateDisplayName(*form.DisplayName); err != nil {
+			return nil, err
+		}
+		if err := p.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil {
+			return nil, err
+		}
+	}
+
+	if form.Note != nil {
+		if err := util.ValidateNote(*form.Note); err != nil {
+			return nil, err
+		}
+		if err := p.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil {
+			return nil, err
+		}
+	}
+
+	if form.Avatar != nil && form.Avatar.Size != 0 {
+		avatarInfo, err := p.updateAccountAvatar(form.Avatar, authed.Account.ID)
+		if err != nil {
+			return nil, err
+		}
+		l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo)
+	}
+
+	if form.Header != nil && form.Header.Size != 0 {
+		headerInfo, err := p.updateAccountHeader(form.Header, authed.Account.ID)
+		if err != nil {
+			return nil, err
+		}
+		l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo)
+	}
+
+	if form.Locked != nil {
+		if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
+			return nil, err
+		}
+	}
+
+	if form.Source != nil {
+		if form.Source.Language != nil {
+			if err := util.ValidateLanguage(*form.Source.Language); err != nil {
+				return nil, err
+			}
+			if err := p.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil {
+				return nil, err
+			}
+		}
+
+		if form.Source.Sensitive != nil {
+			if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
+				return nil, err
+			}
+		}
+
+		if form.Source.Privacy != nil {
+			if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil {
+				return nil, err
+			}
+			if err := p.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	// fetch the account with all updated values set
+	updatedAccount := >smodel.Account{}
+	if err := p.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
+		return nil, fmt.Errorf("could not fetch updated account %s: %s", authed.Account.ID, err)
+	}
+
+	acctSensitive, err := p.tc.AccountToMastoSensitive(updatedAccount)
+	if err != nil {
+		return nil, fmt.Errorf("could not convert account into mastosensitive account: %s", err)
+	}
+	return acctSensitive, nil
+}
diff --git a/internal/message/adminprocess.go b/internal/message/adminprocess.go
new file mode 100644
index 000000000..abf7b61c7
--- /dev/null
+++ b/internal/message/adminprocess.go
@@ -0,0 +1,48 @@
+package message
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) {
+	if !authed.User.Admin {
+		return nil, fmt.Errorf("user %s not an admin", authed.User.ID)
+	}
+
+	// open the emoji and extract the bytes from it
+	f, err := form.Image.Open()
+	if err != nil {
+		return nil, fmt.Errorf("error opening emoji: %s", err)
+	}
+	buf := new(bytes.Buffer)
+	size, err := io.Copy(buf, f)
+	if err != nil {
+		return nil, fmt.Errorf("error reading emoji: %s", err)
+	}
+	if size == 0 {
+		return nil, errors.New("could not read provided emoji: size 0 bytes")
+	}
+
+	// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
+	emoji, err := p.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
+	if err != nil {
+		return nil, fmt.Errorf("error reading emoji: %s", err)
+	}
+
+	mastoEmoji, err := p.tc.EmojiToMasto(emoji)
+	if err != nil {
+		return nil, fmt.Errorf("error converting emoji to mastotype: %s", err)
+	}
+
+	if err := p.db.Put(emoji); err != nil {
+		return nil, fmt.Errorf("database error while processing emoji: %s", err)
+	}
+
+	return &mastoEmoji, nil
+}
diff --git a/internal/message/appprocess.go b/internal/message/appprocess.go
new file mode 100644
index 000000000..bf56f0874
--- /dev/null
+++ b/internal/message/appprocess.go
@@ -0,0 +1,59 @@
+package message
+
+import (
+	"github.com/google/uuid"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) {
+	// set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/
+	var scopes string
+	if form.Scopes == "" {
+		scopes = "read"
+	} else {
+		scopes = form.Scopes
+	}
+
+	// generate new IDs for this application and its associated client
+	clientID := uuid.NewString()
+	clientSecret := uuid.NewString()
+	vapidKey := uuid.NewString()
+
+	// generate the application to put in the database
+	app := >smodel.Application{
+		Name:         form.ClientName,
+		Website:      form.Website,
+		RedirectURI:  form.RedirectURIs,
+		ClientID:     clientID,
+		ClientSecret: clientSecret,
+		Scopes:       scopes,
+		VapidKey:     vapidKey,
+	}
+
+	// chuck it in the db
+	if err := p.db.Put(app); err != nil {
+		return nil, err
+	}
+
+	// now we need to model an oauth client from the application that the oauth library can use
+	oc := &oauth.Client{
+		ID:     clientID,
+		Secret: clientSecret,
+		Domain: form.RedirectURIs,
+		UserID: "", // This client isn't yet associated with a specific user,  it's just an app client right now
+	}
+
+	// chuck it in the db
+	if err := p.db.Put(oc); err != nil {
+		return nil, err
+	}
+
+	mastoApp, err := p.tc.AppToMastoSensitive(app)
+	if err != nil {
+		return nil, err
+	}
+
+	return mastoApp, nil
+}
diff --git a/internal/message/error.go b/internal/message/error.go
new file mode 100644
index 000000000..cbd55dc78
--- /dev/null
+++ b/internal/message/error.go
@@ -0,0 +1,106 @@
+package message
+
+import (
+	"errors"
+	"net/http"
+	"strings"
+)
+
+// ErrorWithCode wraps an internal error with an http code, and a 'safe' version of
+// the error that can be served to clients without revealing internal business logic.
+//
+// A typical use of this error would be to first log the Original error, then return
+// the Safe error and the StatusCode to an API caller.
+type ErrorWithCode interface {
+	// Error returns the original internal error for debugging within the GoToSocial logs.
+	// This should *NEVER* be returned to a client as it may contain sensitive information.
+	Error() string
+	// Safe returns the API-safe version of the error for serialization towards a client.
+	// There's not much point logging this internally because it won't contain much helpful information.
+	Safe() string
+	//  Code returns the status code for serving to a client.
+	Code() int
+}
+
+type errorWithCode struct {
+	original error
+	safe     error
+	code     int
+}
+
+func (e errorWithCode) Error() string {
+	return e.original.Error()
+}
+
+func (e errorWithCode) Safe() string {
+	return e.safe.Error()
+}
+
+func (e errorWithCode) Code() int {
+	return e.code
+}
+
+// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text.
+func NewErrorBadRequest(original error, helpText ...string) ErrorWithCode {
+	safe := "bad request"
+	if helpText != nil {
+		safe = safe + ": " + strings.Join(helpText, ": ")
+	}
+	return errorWithCode{
+		original: original,
+		safe:     errors.New(safe),
+		code:     http.StatusBadRequest,
+	}
+}
+
+// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text.
+func NewErrorNotAuthorized(original error, helpText ...string) ErrorWithCode {
+	safe := "not authorized"
+	if helpText != nil {
+		safe = safe + ": " + strings.Join(helpText, ": ")
+	}
+	return errorWithCode{
+		original: original,
+		safe:     errors.New(safe),
+		code:     http.StatusUnauthorized,
+	}
+}
+
+// NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text.
+func NewErrorForbidden(original error, helpText ...string) ErrorWithCode {
+	safe := "forbidden"
+	if helpText != nil {
+		safe = safe + ": " + strings.Join(helpText, ": ")
+	}
+	return errorWithCode{
+		original: original,
+		safe:     errors.New(safe),
+		code:     http.StatusForbidden,
+	}
+}
+
+// NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text.
+func NewErrorNotFound(original error, helpText ...string) ErrorWithCode {
+	safe := "404 not found"
+	if helpText != nil {
+		safe = safe + ": " + strings.Join(helpText, ": ")
+	}
+	return errorWithCode{
+		original: original,
+		safe:     errors.New(safe),
+		code:     http.StatusNotFound,
+	}
+}
+
+// NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text.
+func NewErrorInternalError(original error, helpText ...string) ErrorWithCode {
+	safe := "internal server error"
+	if helpText != nil {
+		safe = safe + ": " + strings.Join(helpText, ": ")
+	}
+	return errorWithCode{
+		original: original,
+		safe:     errors.New(safe),
+		code:     http.StatusInternalServerError,
+	}
+}
diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go
new file mode 100644
index 000000000..6dc6330cf
--- /dev/null
+++ b/internal/message/fediprocess.go
@@ -0,0 +1,102 @@
+package message
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/go-fed/activity/streams"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// authenticateAndDereferenceFediRequest authenticates the HTTP signature of an incoming federation request, using the given
+// username to perform the validation. It will *also* dereference the originator of the request and return it as a gtsmodel account
+// for further processing. NOTE that this function will have the side effect of putting the dereferenced account into the database,
+// and passing it into the processor through a channel for further asynchronous processing.
+func (p *processor) authenticateAndDereferenceFediRequest(username string, r *http.Request) (*gtsmodel.Account, error) {
+
+	// first authenticate
+	requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(username, r)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't authenticate request for username %s: %s", username, err)
+	}
+
+	// OK now we can do the dereferencing part
+	// we might already have an entry for this account so check that first
+	requestingAccount := >smodel.Account{}
+
+	err = p.db.GetWhere("uri", requestingAccountURI.String(), requestingAccount)
+	if err == nil {
+		// we do have it yay, return it
+		return requestingAccount, nil
+	}
+
+	if _, ok := err.(db.ErrNoEntries); !ok {
+		// something has actually gone wrong so bail
+		return nil, fmt.Errorf("database error getting account with uri %s: %s", requestingAccountURI.String(), err)
+	}
+
+	// we just don't have an entry for this account yet
+	// what we do now should depend on our chosen federation method
+	// for now though, we'll just dereference it
+	// TODO: slow-fed
+	requestingPerson, err := p.federator.DereferenceRemoteAccount(username, requestingAccountURI)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't dereference %s: %s", requestingAccountURI.String(), err)
+	}
+
+	// convert it to our internal account representation
+	requestingAccount, err = p.tc.ASRepresentationToAccount(requestingPerson)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't convert dereferenced uri %s to gtsmodel account: %s", requestingAccountURI.String(), err)
+	}
+
+	// shove it in the database for later
+	if err := p.db.Put(requestingAccount); err != nil {
+		return nil, fmt.Errorf("database error inserting account with uri %s: %s", requestingAccountURI.String(), err)
+	}
+
+	// put it in our channel to queue it for async processing
+	p.FromFederator() <- FromFederator{
+		APObjectType:   gtsmodel.ActivityStreamsProfile,
+		APActivityType: gtsmodel.ActivityStreamsCreate,
+		Activity:       requestingAccount,
+	}
+
+	return requestingAccount, nil
+}
+
+func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) {
+	// get the account the request is referring to
+	requestedAccount := >smodel.Account{}
+	if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil {
+		return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
+	}
+
+	// authenticate the request
+	requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request)
+	if err != nil {
+		return nil, NewErrorNotAuthorized(err)
+	}
+
+	blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID)
+	if err != nil {
+		return nil, NewErrorInternalError(err)
+	}
+
+	if blocked {
+		return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
+	}
+
+	requestedPerson, err := p.tc.AccountToAS(requestedAccount)
+	if err != nil {
+		return nil, NewErrorInternalError(err)
+	}
+
+	data, err := streams.Serialize(requestedPerson)
+	if err != nil {
+		return nil, NewErrorInternalError(err)
+	}
+
+	return data, nil
+}
diff --git a/internal/message/mediaprocess.go b/internal/message/mediaprocess.go
new file mode 100644
index 000000000..77b387df3
--- /dev/null
+++ b/internal/message/mediaprocess.go
@@ -0,0 +1,188 @@
+package message
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) {
+	// First check this user/account is permitted to create media
+	// There's no point continuing otherwise.
+	if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+		return nil, errors.New("not authorized to post new media")
+	}
+
+	// open the attachment and extract the bytes from it
+	f, err := form.File.Open()
+	if err != nil {
+		return nil, fmt.Errorf("error opening attachment: %s", err)
+	}
+	buf := new(bytes.Buffer)
+	size, err := io.Copy(buf, f)
+	if err != nil {
+		return nil, fmt.Errorf("error reading attachment: %s", err)
+
+	}
+	if size == 0 {
+		return nil, errors.New("could not read provided attachment: size 0 bytes")
+	}
+
+	// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
+	attachment, err := p.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
+	if err != nil {
+		return nil, fmt.Errorf("error reading attachment: %s", err)
+	}
+
+	// now we need to add extra fields that the attachment processor doesn't know (from the form)
+	// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
+
+	// first description
+	attachment.Description = form.Description
+
+	// now parse the focus parameter
+	// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
+	var focusx, focusy float32
+	if form.Focus != "" {
+		spl := strings.Split(form.Focus, ",")
+		if len(spl) != 2 {
+			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+		}
+		xStr := spl[0]
+		yStr := spl[1]
+		if xStr == "" || yStr == "" {
+			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+		}
+		fx, err := strconv.ParseFloat(xStr, 32)
+		if err != nil {
+			return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err)
+		}
+		if fx > 1 || fx < -1 {
+			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+		}
+		focusx = float32(fx)
+		fy, err := strconv.ParseFloat(yStr, 32)
+		if err != nil {
+			return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err)
+		}
+		if fy > 1 || fy < -1 {
+			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+		}
+		focusy = float32(fy)
+	}
+	attachment.FileMeta.Focus.X = focusx
+	attachment.FileMeta.Focus.Y = focusy
+
+	// prepare the frontend representation now -- if there are any errors here at least we can bail without
+	// having already put something in the database and then having to clean it up again (eugh)
+	mastoAttachment, err := p.tc.AttachmentToMasto(attachment)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err)
+	}
+
+	// now we can confidently put the attachment in the database
+	if err := p.db.Put(attachment); err != nil {
+		return nil, fmt.Errorf("error storing media attachment in db: %s", err)
+	}
+
+	return &mastoAttachment, nil
+}
+
+func (p *processor) MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) {
+	// parse the form fields
+	mediaSize, err := media.ParseMediaSize(form.MediaSize)
+	if err != nil {
+		return nil, NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize))
+	}
+
+	mediaType, err := media.ParseMediaType(form.MediaType)
+	if err != nil {
+		return nil, NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType))
+	}
+
+	spl := strings.Split(form.FileName, ".")
+	if len(spl) != 2 || spl[0] == "" || spl[1] == "" {
+		return nil, NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName))
+	}
+	wantedMediaID := spl[0]
+
+	// get the account that owns the media and make sure it's not suspended
+	acct := >smodel.Account{}
+	if err := p.db.GetByID(form.AccountID, acct); err != nil {
+		return nil, NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err))
+	}
+	if !acct.SuspendedAt.IsZero() {
+		return nil, NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID))
+	}
+
+	// make sure the requesting account and the media account don't block each other
+	if authed.Account != nil {
+		blocked, err := p.db.Blocked(authed.Account.ID, form.AccountID)
+		if err != nil {
+			return nil, NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, authed.Account.ID, err))
+		}
+		if blocked {
+			return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, authed.Account.ID))
+		}
+	}
+
+	// the way we store emojis is a little different from the way we store other attachments,
+	// so we need to take different steps depending on the media type being requested
+	content := &apimodel.Content{}
+	var storagePath string
+	switch mediaType {
+	case media.Emoji:
+		e := >smodel.Emoji{}
+		if err := p.db.GetByID(wantedMediaID, e); err != nil {
+			return nil, NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err))
+		}
+		if e.Disabled {
+			return nil, NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID))
+		}
+		switch mediaSize {
+		case media.Original:
+			content.ContentType = e.ImageContentType
+			storagePath = e.ImagePath
+		case media.Static:
+			content.ContentType = e.ImageStaticContentType
+			storagePath = e.ImageStaticPath
+		default:
+			return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize))
+		}
+	case media.Attachment, media.Header, media.Avatar:
+		a := >smodel.MediaAttachment{}
+		if err := p.db.GetByID(wantedMediaID, a); err != nil {
+			return nil, NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err))
+		}
+		if a.AccountID != form.AccountID {
+			return nil, NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID))
+		}
+		switch mediaSize {
+		case media.Original:
+			content.ContentType = a.File.ContentType
+			storagePath = a.File.Path
+		case media.Small:
+			content.ContentType = a.Thumbnail.ContentType
+			storagePath = a.Thumbnail.Path
+		default:
+			return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize))
+		}
+	}
+
+	bytes, err := p.storage.RetrieveFileFrom(storagePath)
+	if err != nil {
+		return nil, NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err))
+	}
+
+	content.ContentLength = int64(len(bytes))
+	content.Content = bytes
+	return content, nil
+}
diff --git a/internal/message/processor.go b/internal/message/processor.go
new file mode 100644
index 000000000..d0027c915
--- /dev/null
+++ b/internal/message/processor.go
@@ -0,0 +1,215 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package message
+
+import (
+	"net/http"
+
+	"github.com/sirupsen/logrus"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/storage"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// Processor should be passed to api modules (see internal/apimodule/...). It is used for
+// passing messages back and forth from the client API and the federating interface, via channels.
+// It also contains logic for filtering which messages should end up where.
+// It is designed to be used asynchronously: the client API and the federating API should just be able to
+// fire messages into the processor and not wait for a reply before proceeding with other work. This allows
+// for clean distribution of messages without slowing down the client API and harming the user experience.
+type Processor interface {
+	// ToClientAPI returns a channel for putting in messages that need to go to the gts client API.
+	ToClientAPI() chan ToClientAPI
+	// FromClientAPI returns a channel for putting messages in that come from the client api going to the processor
+	FromClientAPI() chan FromClientAPI
+	// ToFederator returns a channel for putting in messages that need to go to the federator (activitypub).
+	ToFederator() chan ToFederator
+	// FromFederator returns a channel for putting messages in that come from the federator (activitypub) going into the processor
+	FromFederator() chan FromFederator
+	// Start starts the Processor, reading from its channels and passing messages back and forth.
+	Start() error
+	// Stop stops the processor cleanly, finishing handling any remaining messages before closing down.
+	Stop() error
+
+	/*
+		CLIENT API-FACING PROCESSING FUNCTIONS
+		These functions are intended to be called when the API client needs an immediate (ie., synchronous) reply
+		to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly
+		formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate
+		response, pass work to the processor using a channel instead.
+	*/
+
+	// AccountCreate processes the given form for creating a new account, returning an oauth token for that account if successful.
+	AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error)
+	// AccountGet processes the given request for account information.
+	AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error)
+	// AccountUpdate processes the update of an account with the given form
+	AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
+
+	// AppCreate processes the creation of a new API application
+	AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error)
+
+	// StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK.
+	StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error)
+	// StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through.
+	StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+	// StatusFave processes the faving of a given status, returning the updated status if the fave goes through.
+	StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+	// StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.
+	StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error)
+	// StatusGet gets the given status, taking account of privacy settings and blocks etc.
+	StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+	// StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through.
+	StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+
+	// MediaCreate handles the creation of a media attachment, using the given form.
+	MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error)
+	// MediaGet handles the fetching of a media attachment, using the given request form.
+	MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error)
+	// AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form.
+	AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error)
+
+	/*
+		FEDERATION API-FACING PROCESSING FUNCTIONS
+		These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply
+		to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly
+		formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate
+		response, pass work to the processor using a channel instead.
+	*/
+
+	// GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
+	// before returning a JSON serializable interface to the caller.
+	GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode)
+}
+
+// processor just implements the Processor interface
+type processor struct {
+	// federator     pub.FederatingActor
+	toClientAPI   chan ToClientAPI
+	fromClientAPI chan FromClientAPI
+	toFederator   chan ToFederator
+	fromFederator chan FromFederator
+	federator     federation.Federator
+	stop          chan interface{}
+	log           *logrus.Logger
+	config        *config.Config
+	tc            typeutils.TypeConverter
+	oauthServer   oauth.Server
+	mediaHandler  media.Handler
+	storage       storage.Storage
+	db            db.DB
+}
+
+// NewProcessor returns a new Processor that uses the given federator and logger
+func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage storage.Storage, db db.DB, log *logrus.Logger) Processor {
+	return &processor{
+		toClientAPI:   make(chan ToClientAPI, 100),
+		fromClientAPI: make(chan FromClientAPI, 100),
+		toFederator:   make(chan ToFederator, 100),
+		fromFederator: make(chan FromFederator, 100),
+		federator:     federator,
+		stop:          make(chan interface{}),
+		log:           log,
+		config:        config,
+		tc:            tc,
+		oauthServer:   oauthServer,
+		mediaHandler:  mediaHandler,
+		storage:       storage,
+		db:            db,
+	}
+}
+
+func (p *processor) ToClientAPI() chan ToClientAPI {
+	return p.toClientAPI
+}
+
+func (p *processor) FromClientAPI() chan FromClientAPI {
+	return p.fromClientAPI
+}
+
+func (p *processor) ToFederator() chan ToFederator {
+	return p.toFederator
+}
+
+func (p *processor) FromFederator() chan FromFederator {
+	return p.fromFederator
+}
+
+// Start starts the Processor, reading from its channels and passing messages back and forth.
+func (p *processor) Start() error {
+	go func() {
+	DistLoop:
+		for {
+			select {
+			case clientMsg := <-p.toClientAPI:
+				p.log.Infof("received message TO client API: %+v", clientMsg)
+			case clientMsg := <-p.fromClientAPI:
+				p.log.Infof("received message FROM client API: %+v", clientMsg)
+			case federatorMsg := <-p.toFederator:
+				p.log.Infof("received message TO federator: %+v", federatorMsg)
+			case federatorMsg := <-p.fromFederator:
+				p.log.Infof("received message FROM federator: %+v", federatorMsg)
+			case <-p.stop:
+				break DistLoop
+			}
+		}
+	}()
+	return nil
+}
+
+// Stop stops the processor cleanly, finishing handling any remaining messages before closing down.
+// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages.
+func (p *processor) Stop() error {
+	close(p.stop)
+	return nil
+}
+
+// ToClientAPI wraps a message that travels from the processor into the client API
+type ToClientAPI struct {
+	APObjectType   gtsmodel.ActivityStreamsObject
+	APActivityType gtsmodel.ActivityStreamsActivity
+	Activity       interface{}
+}
+
+// FromClientAPI wraps a message that travels from client API into the processor
+type FromClientAPI struct {
+	APObjectType   gtsmodel.ActivityStreamsObject
+	APActivityType gtsmodel.ActivityStreamsActivity
+	Activity       interface{}
+}
+
+// ToFederator wraps a message that travels from the processor into the federator
+type ToFederator struct {
+	APObjectType   gtsmodel.ActivityStreamsObject
+	APActivityType gtsmodel.ActivityStreamsActivity
+	Activity       interface{}
+}
+
+// FromFederator wraps a message that travels from the federator into the processor
+type FromFederator struct {
+	APObjectType   gtsmodel.ActivityStreamsObject
+	APActivityType gtsmodel.ActivityStreamsActivity
+	Activity       interface{}
+}
diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go
new file mode 100644
index 000000000..c928eec1a
--- /dev/null
+++ b/internal/message/processorutil.go
@@ -0,0 +1,304 @@
+package message
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"mime/multipart"
+
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/media"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
+	// by default all flags are set to true
+	gtsAdvancedVis := >smodel.VisibilityAdvanced{
+		Federated: true,
+		Boostable: true,
+		Replyable: true,
+		Likeable:  true,
+	}
+
+	var gtsBasicVis gtsmodel.Visibility
+	// Advanced takes priority if it's set.
+	// If it's not set, take whatever masto visibility is set.
+	// If *that's* not set either, then just take the account default.
+	// If that's also not set, take the default for the whole instance.
+	if form.VisibilityAdvanced != nil {
+		gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced)
+	} else if form.Visibility != "" {
+		gtsBasicVis = p.tc.MastoVisToVis(form.Visibility)
+	} else if accountDefaultVis != "" {
+		gtsBasicVis = accountDefaultVis
+	} else {
+		gtsBasicVis = gtsmodel.VisibilityDefault
+	}
+
+	switch gtsBasicVis {
+	case gtsmodel.VisibilityPublic:
+		// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
+		break
+	case gtsmodel.VisibilityUnlocked:
+		// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
+		if form.Federated != nil {
+			gtsAdvancedVis.Federated = *form.Federated
+		}
+
+		if form.Boostable != nil {
+			gtsAdvancedVis.Boostable = *form.Boostable
+		}
+
+		if form.Replyable != nil {
+			gtsAdvancedVis.Replyable = *form.Replyable
+		}
+
+		if form.Likeable != nil {
+			gtsAdvancedVis.Likeable = *form.Likeable
+		}
+
+	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
+		// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
+		gtsAdvancedVis.Boostable = false
+
+		if form.Federated != nil {
+			gtsAdvancedVis.Federated = *form.Federated
+		}
+
+		if form.Replyable != nil {
+			gtsAdvancedVis.Replyable = *form.Replyable
+		}
+
+		if form.Likeable != nil {
+			gtsAdvancedVis.Likeable = *form.Likeable
+		}
+
+	case gtsmodel.VisibilityDirect:
+		// direct is pretty easy: there's only one possible setting so return it
+		gtsAdvancedVis.Federated = true
+		gtsAdvancedVis.Boostable = false
+		gtsAdvancedVis.Federated = true
+		gtsAdvancedVis.Likeable = true
+	}
+
+	status.Visibility = gtsBasicVis
+	status.VisibilityAdvanced = gtsAdvancedVis
+	return nil
+}
+
+func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
+	if form.InReplyToID == "" {
+		return nil
+	}
+
+	// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
+	//
+	// 1. Does the replied status exist in the database?
+	// 2. Is the replied status marked as replyable?
+	// 3. Does a block exist between either the current account or the account that posted the status it's replying to?
+	//
+	// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
+	repliedStatus := >smodel.Status{}
+	repliedAccount := >smodel.Account{}
+	// check replied status exists + is replyable
+	if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil {
+		if _, ok := err.(db.ErrNoEntries); ok {
+			return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID)
+		}
+		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+	}
+
+	if !repliedStatus.VisibilityAdvanced.Replyable {
+		return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID)
+	}
+
+	// check replied account is known to us
+	if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil {
+		if _, ok := err.(db.ErrNoEntries); ok {
+			return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID)
+		}
+		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+	}
+	// check if a block exists
+	if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil {
+		if _, ok := err.(db.ErrNoEntries); !ok {
+			return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+		}
+	} else if blocked {
+		return fmt.Errorf("status with id %s not replyable", form.InReplyToID)
+	}
+	status.InReplyToID = repliedStatus.ID
+	status.InReplyToAccountID = repliedAccount.ID
+
+	return nil
+}
+
+func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
+	if form.MediaIDs == nil {
+		return nil
+	}
+
+	gtsMediaAttachments := []*gtsmodel.MediaAttachment{}
+	attachments := []string{}
+	for _, mediaID := range form.MediaIDs {
+		// check these attachments exist
+		a := >smodel.MediaAttachment{}
+		if err := p.db.GetByID(mediaID, a); err != nil {
+			return fmt.Errorf("invalid media type or media not found for media id %s", mediaID)
+		}
+		// check they belong to the requesting account id
+		if a.AccountID != thisAccountID {
+			return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID)
+		}
+		// check they're not already used in a status
+		if a.StatusID != "" || a.ScheduledStatusID != "" {
+			return fmt.Errorf("media with id %s is already attached to a status", mediaID)
+		}
+		gtsMediaAttachments = append(gtsMediaAttachments, a)
+		attachments = append(attachments, a.ID)
+	}
+	status.GTSMediaAttachments = gtsMediaAttachments
+	status.Attachments = attachments
+	return nil
+}
+
+func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error {
+	if form.Language != "" {
+		status.Language = form.Language
+	} else {
+		status.Language = accountDefaultLanguage
+	}
+	if status.Language == "" {
+		return errors.New("no language given either in status create form or account default")
+	}
+	return nil
+}
+
+func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+	menchies := []string{}
+	gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
+	if err != nil {
+		return fmt.Errorf("error generating mentions from status: %s", err)
+	}
+	for _, menchie := range gtsMenchies {
+		if err := p.db.Put(menchie); err != nil {
+			return fmt.Errorf("error putting mentions in db: %s", err)
+		}
+		menchies = append(menchies, menchie.TargetAccountID)
+	}
+	// add full populated gts menchies to the status for passing them around conveniently
+	status.GTSMentions = gtsMenchies
+	// add just the ids of the mentioned accounts to the status for putting in the db
+	status.Mentions = menchies
+	return nil
+}
+
+func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+	tags := []string{}
+	gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
+	if err != nil {
+		return fmt.Errorf("error generating hashtags from status: %s", err)
+	}
+	for _, tag := range gtsTags {
+		if err := p.db.Upsert(tag, "name"); err != nil {
+			return fmt.Errorf("error putting tags in db: %s", err)
+		}
+		tags = append(tags, tag.ID)
+	}
+	// add full populated gts tags to the status for passing them around conveniently
+	status.GTSTags = gtsTags
+	// add just the ids of the used tags to the status for putting in the db
+	status.Tags = tags
+	return nil
+}
+
+func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+	emojis := []string{}
+	gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
+	if err != nil {
+		return fmt.Errorf("error generating emojis from status: %s", err)
+	}
+	for _, e := range gtsEmojis {
+		emojis = append(emojis, e.ID)
+	}
+	// add full populated gts emojis to the status for passing them around conveniently
+	status.GTSEmojis = gtsEmojis
+	// add just the ids of the used emojis to the status for putting in the db
+	status.Emojis = emojis
+	return nil
+}
+
+/*
+	HELPER FUNCTIONS
+*/
+
+// TODO: try to combine the below two functions because this is a lot of code repetition.
+
+// updateAccountAvatar does the dirty work of checking the avatar part of an account update form,
+// parsing and checking the image, and doing the necessary updates in the database for this to become
+// the account's new avatar image.
+func (p *processor) updateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
+	var err error
+	if int(avatar.Size) > p.config.MediaConfig.MaxImageSize {
+		err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, p.config.MediaConfig.MaxImageSize)
+		return nil, err
+	}
+	f, err := avatar.Open()
+	if err != nil {
+		return nil, fmt.Errorf("could not read provided avatar: %s", err)
+	}
+
+	// extract the bytes
+	buf := new(bytes.Buffer)
+	size, err := io.Copy(buf, f)
+	if err != nil {
+		return nil, fmt.Errorf("could not read provided avatar: %s", err)
+	}
+	if size == 0 {
+		return nil, errors.New("could not read provided avatar: size 0 bytes")
+	}
+
+	// do the setting
+	avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar)
+	if err != nil {
+		return nil, fmt.Errorf("error processing avatar: %s", err)
+	}
+
+	return avatarInfo, f.Close()
+}
+
+// updateAccountHeader does the dirty work of checking the header part of an account update form,
+// parsing and checking the image, and doing the necessary updates in the database for this to become
+// the account's new header image.
+func (p *processor) updateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
+	var err error
+	if int(header.Size) > p.config.MediaConfig.MaxImageSize {
+		err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, p.config.MediaConfig.MaxImageSize)
+		return nil, err
+	}
+	f, err := header.Open()
+	if err != nil {
+		return nil, fmt.Errorf("could not read provided header: %s", err)
+	}
+
+	// extract the bytes
+	buf := new(bytes.Buffer)
+	size, err := io.Copy(buf, f)
+	if err != nil {
+		return nil, fmt.Errorf("could not read provided header: %s", err)
+	}
+	if size == 0 {
+		return nil, errors.New("could not read provided header: size 0 bytes")
+	}
+
+	// do the setting
+	headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header)
+	if err != nil {
+		return nil, fmt.Errorf("error processing header: %s", err)
+	}
+
+	return headerInfo, f.Close()
+}
diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go
new file mode 100644
index 000000000..b7237fecf
--- /dev/null
+++ b/internal/message/statusprocess.go
@@ -0,0 +1,350 @@
+package message
+
+import (
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/google/uuid"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) {
+	uris := util.GenerateURIsForAccount(auth.Account.Username, p.config.Protocol, p.config.Host)
+	thisStatusID := uuid.NewString()
+	thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
+	thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
+	newStatus := >smodel.Status{
+		ID:                       thisStatusID,
+		URI:                      thisStatusURI,
+		URL:                      thisStatusURL,
+		Content:                  util.HTMLFormat(form.Status),
+		CreatedAt:                time.Now(),
+		UpdatedAt:                time.Now(),
+		Local:                    true,
+		AccountID:                auth.Account.ID,
+		ContentWarning:           form.SpoilerText,
+		ActivityStreamsType:      gtsmodel.ActivityStreamsNote,
+		Sensitive:                form.Sensitive,
+		Language:                 form.Language,
+		CreatedWithApplicationID: auth.Application.ID,
+		Text:                     form.Status,
+	}
+
+	// check if replyToID is ok
+	if err := p.processReplyToID(form, auth.Account.ID, newStatus); err != nil {
+		return nil, err
+	}
+
+	// check if mediaIDs are ok
+	if err := p.processMediaIDs(form, auth.Account.ID, newStatus); err != nil {
+		return nil, err
+	}
+
+	// check if visibility settings are ok
+	if err := p.processVisibility(form, auth.Account.Privacy, newStatus); err != nil {
+		return nil, err
+	}
+
+	// handle language settings
+	if err := p.processLanguage(form, auth.Account.Language, newStatus); err != nil {
+		return nil, err
+	}
+
+	// handle mentions
+	if err := p.processMentions(form, auth.Account.ID, newStatus); err != nil {
+		return nil, err
+	}
+
+	if err := p.processTags(form, auth.Account.ID, newStatus); err != nil {
+		return nil, err
+	}
+
+	if err := p.processEmojis(form, auth.Account.ID, newStatus); err != nil {
+		return nil, err
+	}
+
+	// put the new status in the database, generating an ID for it in the process
+	if err := p.db.Put(newStatus); err != nil {
+		return nil, err
+	}
+
+	// change the status ID of the media attachments to the new status
+	for _, a := range newStatus.GTSMediaAttachments {
+		a.StatusID = newStatus.ID
+		a.UpdatedAt = time.Now()
+		if err := p.db.UpdateByID(a.ID, a); err != nil {
+			return nil, err
+		}
+	}
+
+	// return the frontend representation of the new status to the submitter
+	return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil)
+}
+
+func (p *processor) StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+	l := p.log.WithField("func", "StatusDelete")
+	l.Tracef("going to search for target status %s", targetStatusID)
+	targetStatus := >smodel.Status{}
+	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+	}
+
+	if targetStatus.AccountID != authed.Account.ID {
+		return nil, errors.New("status doesn't belong to requesting account")
+	}
+
+	l.Trace("going to get relevant accounts")
+	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+	}
+
+	var boostOfStatus *gtsmodel.Status
+	if targetStatus.BoostOfID != "" {
+		boostOfStatus = >smodel.Status{}
+		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+		}
+	}
+
+	mastoStatus, err := p.tc.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+	}
+
+	if err := p.db.DeleteByID(targetStatus.ID, targetStatus); err != nil {
+		return nil, fmt.Errorf("error deleting status from the database: %s", err)
+	}
+
+	return mastoStatus, nil
+}
+
+func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+	l := p.log.WithField("func", "StatusFave")
+	l.Tracef("going to search for target status %s", targetStatusID)
+	targetStatus := >smodel.Status{}
+	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+	}
+
+	l.Tracef("going to search for target account %s", targetStatus.AccountID)
+	targetAccount := >smodel.Account{}
+	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+	}
+
+	l.Trace("going to get relevant accounts")
+	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+	}
+
+	l.Trace("going to see if status is visible")
+	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+	if err != nil {
+		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+	}
+
+	if !visible {
+		return nil, errors.New("status is not visible")
+	}
+
+	// is the status faveable?
+	if !targetStatus.VisibilityAdvanced.Likeable {
+		return nil, errors.New("status is not faveable")
+	}
+
+	// it's visible! it's faveable! so let's fave the FUCK out of it
+	_, err = p.db.FaveStatus(targetStatus, authed.Account.ID)
+	if err != nil {
+		return nil, fmt.Errorf("error faveing status: %s", err)
+	}
+
+	var boostOfStatus *gtsmodel.Status
+	if targetStatus.BoostOfID != "" {
+		boostOfStatus = >smodel.Status{}
+		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+		}
+	}
+
+	mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+	}
+
+	return mastoStatus, nil
+}
+
+func (p *processor) StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) {
+	l := p.log.WithField("func", "StatusFavedBy")
+
+	l.Tracef("going to search for target status %s", targetStatusID)
+	targetStatus := >smodel.Status{}
+	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+	}
+
+	l.Tracef("going to search for target account %s", targetStatus.AccountID)
+	targetAccount := >smodel.Account{}
+	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+	}
+
+	l.Trace("going to get relevant accounts")
+	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+	}
+
+	l.Trace("going to see if status is visible")
+	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+	if err != nil {
+		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+	}
+
+	if !visible {
+		return nil, errors.New("status is not visible")
+	}
+
+	// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
+	favingAccounts, err := p.db.WhoFavedStatus(targetStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error seeing who faved status: %s", err)
+	}
+
+	// filter the list so the user doesn't see accounts they blocked or which blocked them
+	filteredAccounts := []*gtsmodel.Account{}
+	for _, acc := range favingAccounts {
+		blocked, err := p.db.Blocked(authed.Account.ID, acc.ID)
+		if err != nil {
+			return nil, fmt.Errorf("error checking blocks: %s", err)
+		}
+		if !blocked {
+			filteredAccounts = append(filteredAccounts, acc)
+		}
+	}
+
+	// TODO: filter other things here? suspended? muted? silenced?
+
+	// now we can return the masto representation of those accounts
+	mastoAccounts := []*apimodel.Account{}
+	for _, acc := range filteredAccounts {
+		mastoAccount, err := p.tc.AccountToMastoPublic(acc)
+		if err != nil {
+			return nil, fmt.Errorf("error converting account to api model: %s", err)
+		}
+		mastoAccounts = append(mastoAccounts, mastoAccount)
+	}
+
+	return mastoAccounts, nil
+}
+
+func (p *processor) StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+	l := p.log.WithField("func", "StatusGet")
+
+	l.Tracef("going to search for target status %s", targetStatusID)
+	targetStatus := >smodel.Status{}
+	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+	}
+
+	l.Tracef("going to search for target account %s", targetStatus.AccountID)
+	targetAccount := >smodel.Account{}
+	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+	}
+
+	l.Trace("going to get relevant accounts")
+	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+	}
+
+	l.Trace("going to see if status is visible")
+	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+	if err != nil {
+		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+	}
+
+	if !visible {
+		return nil, errors.New("status is not visible")
+	}
+
+	var boostOfStatus *gtsmodel.Status
+	if targetStatus.BoostOfID != "" {
+		boostOfStatus = >smodel.Status{}
+		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+		}
+	}
+
+	mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+	}
+
+	return mastoStatus, nil
+
+}
+
+func (p *processor) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+	l := p.log.WithField("func", "StatusUnfave")
+	l.Tracef("going to search for target status %s", targetStatusID)
+	targetStatus := >smodel.Status{}
+	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+	}
+
+	l.Tracef("going to search for target account %s", targetStatus.AccountID)
+	targetAccount := >smodel.Account{}
+	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+	}
+
+	l.Trace("going to get relevant accounts")
+	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+	}
+
+	l.Trace("going to see if status is visible")
+	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+	if err != nil {
+		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+	}
+
+	if !visible {
+		return nil, errors.New("status is not visible")
+	}
+
+	// is the status faveable?
+	if !targetStatus.VisibilityAdvanced.Likeable {
+		return nil, errors.New("status is not faveable")
+	}
+
+	// it's visible! it's faveable! so let's unfave the FUCK out of it
+	_, err = p.db.UnfaveStatus(targetStatus, authed.Account.ID)
+	if err != nil {
+		return nil, fmt.Errorf("error unfaveing status: %s", err)
+	}
+
+	var boostOfStatus *gtsmodel.Status
+	if targetStatus.BoostOfID != "" {
+		boostOfStatus = >smodel.Status{}
+		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+		}
+	}
+
+	mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+	}
+
+	return mastoStatus, nil
+}
diff --git a/internal/oauth/clientstore.go b/internal/oauth/clientstore.go
index 4e678891a..5241cf412 100644
--- a/internal/oauth/clientstore.go
+++ b/internal/oauth/clientstore.go
@@ -30,7 +30,8 @@ type clientStore struct {
 	db db.DB
 }
 
-func newClientStore(db db.DB) oauth2.ClientStore {
+// NewClientStore returns an implementation of the oauth2 ClientStore interface, using the given db as a storage backend.
+func NewClientStore(db db.DB) oauth2.ClientStore {
 	pts := &clientStore{
 		db: db,
 	}
diff --git a/internal/oauth/clientstore_test.go b/internal/oauth/clientstore_test.go
index a7028228d..b77163e48 100644
--- a/internal/oauth/clientstore_test.go
+++ b/internal/oauth/clientstore_test.go
@@ -15,7 +15,7 @@
    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see .
 */
-package oauth
+package oauth_test
 
 import (
 	"context"
@@ -25,6 +25,7 @@ import (
 	"github.com/stretchr/testify/suite"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 	"github.com/superseriousbusiness/oauth2/v4/models"
 )
 
@@ -61,7 +62,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
 		Database:        "postgres",
 		ApplicationName: "gotosocial",
 	}
-	db, err := db.New(context.Background(), c, log)
+	db, err := db.NewPostgresService(context.Background(), c, log)
 	if err != nil {
 		logrus.Panicf("error creating database connection: %s", err)
 	}
@@ -69,7 +70,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
 	suite.db = db
 
 	models := []interface{}{
-		&Client{},
+		&oauth.Client{},
 	}
 
 	for _, m := range models {
@@ -82,7 +83,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
 // TearDownTest drops the oauth_clients table and closes the pg connection after each test
 func (suite *PgClientStoreTestSuite) TearDownTest() {
 	models := []interface{}{
-		&Client{},
+		&oauth.Client{},
 	}
 	for _, m := range models {
 		if err := suite.db.DropTable(m); err != nil {
@@ -97,7 +98,7 @@ func (suite *PgClientStoreTestSuite) TearDownTest() {
 
 func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() {
 	// set a new client in the store
-	cs := newClientStore(suite.db)
+	cs := oauth.NewClientStore(suite.db)
 	if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil {
 		suite.FailNow(err.Error())
 	}
@@ -115,7 +116,7 @@ func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() {
 
 func (suite *PgClientStoreTestSuite) TestClientSetAndDelete() {
 	// set a new client in the store
-	cs := newClientStore(suite.db)
+	cs := oauth.NewClientStore(suite.db)
 	if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil {
 		suite.FailNow(err.Error())
 	}
diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go
index 594b9b5a9..1b8449619 100644
--- a/internal/oauth/oauth_test.go
+++ b/internal/oauth/oauth_test.go
@@ -16,6 +16,6 @@
    along with this program.  If not, see .
 */
 
-package oauth
+package oauth_test
 
 // TODO: write tests
diff --git a/internal/oauth/server.go b/internal/oauth/server.go
index 1ddf18b03..7877d667e 100644
--- a/internal/oauth/server.go
+++ b/internal/oauth/server.go
@@ -23,10 +23,8 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/gin-gonic/gin"
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
 	"github.com/superseriousbusiness/oauth2/v4"
 	"github.com/superseriousbusiness/oauth2/v4/errors"
 	"github.com/superseriousbusiness/oauth2/v4/manage"
@@ -66,94 +64,53 @@ type s struct {
 	log    *logrus.Logger
 }
 
-// Authed wraps an authorized token, application, user, and account.
-// It is used in the functions GetAuthed and MustAuth.
-// Because the user might *not* be authed, any of the fields in this struct
-// might be nil, so make sure to check that when you're using this struct anywhere.
-type Authed struct {
-	Token       oauth2.TokenInfo
-	Application *gtsmodel.Application
-	User        *gtsmodel.User
-	Account     *gtsmodel.Account
-}
+// New returns a new oauth server that implements the Server interface
+func New(database db.DB, log *logrus.Logger) Server {
+	ts := newTokenStore(context.Background(), database, log)
+	cs := NewClientStore(database)
 
-// GetAuthed is a convenience function for returning an Authed struct from a gin context.
-// In essence, it tries to extract a token, application, user, and account from the context,
-// and then sets them on a struct for convenience.
-//
-// If any are not present in the context, they will be set to nil on the returned Authed struct.
-//
-// If *ALL* are not present, then nil and an error will be returned.
-//
-// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed).
-func GetAuthed(c *gin.Context) (*Authed, error) {
-	ctx := c.Copy()
-	a := &Authed{}
-	var i interface{}
-	var ok bool
+	manager := manage.NewDefaultManager()
+	manager.MapTokenStorage(ts)
+	manager.MapClientStorage(cs)
+	manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
+	sc := &server.Config{
+		TokenType: "Bearer",
+		// Must follow the spec.
+		AllowGetAccessRequest: false,
+		// Support only the non-implicit flow.
+		AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code},
+		// Allow:
+		// - Authorization Code (for first & third parties)
+		// - Client Credentials (for applications)
+		AllowedGrantTypes: []oauth2.GrantType{
+			oauth2.AuthorizationCode,
+			oauth2.ClientCredentials,
+		},
+		AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain},
+	}
 
-	i, ok = ctx.Get(SessionAuthorizedToken)
-	if ok {
-		parsed, ok := i.(oauth2.TokenInfo)
-		if !ok {
-			return nil, errors.New("could not parse token from session context")
+	srv := server.NewServer(sc, manager)
+	srv.SetInternalErrorHandler(func(err error) *errors.Response {
+		log.Errorf("internal oauth error: %s", err)
+		return nil
+	})
+
+	srv.SetResponseErrorHandler(func(re *errors.Response) {
+		log.Errorf("internal response error: %s", re.Error)
+	})
+
+	srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) {
+		userID := r.FormValue("userid")
+		if userID == "" {
+			return "", errors.New("userid was empty")
 		}
-		a.Token = parsed
+		return userID, nil
+	})
+	srv.SetClientInfoHandler(server.ClientFormHandler)
+	return &s{
+		server: srv,
+		log:    log,
 	}
-
-	i, ok = ctx.Get(SessionAuthorizedApplication)
-	if ok {
-		parsed, ok := i.(*gtsmodel.Application)
-		if !ok {
-			return nil, errors.New("could not parse application from session context")
-		}
-		a.Application = parsed
-	}
-
-	i, ok = ctx.Get(SessionAuthorizedUser)
-	if ok {
-		parsed, ok := i.(*gtsmodel.User)
-		if !ok {
-			return nil, errors.New("could not parse user from session context")
-		}
-		a.User = parsed
-	}
-
-	i, ok = ctx.Get(SessionAuthorizedAccount)
-	if ok {
-		parsed, ok := i.(*gtsmodel.Account)
-		if !ok {
-			return nil, errors.New("could not parse account from session context")
-		}
-		a.Account = parsed
-	}
-
-	if a.Token == nil && a.Application == nil && a.User == nil && a.Account == nil {
-		return nil, errors.New("not authorized")
-	}
-
-	return a, nil
-}
-
-// MustAuth is like GetAuthed, but will fail if one of the requirements is not met.
-func MustAuth(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Authed, error) {
-	a, err := GetAuthed(c)
-	if err != nil {
-		return nil, err
-	}
-	if requireToken && a.Token == nil {
-		return nil, errors.New("token not supplied")
-	}
-	if requireApp && a.Application == nil {
-		return nil, errors.New("application not supplied")
-	}
-	if requireUser && a.User == nil {
-		return nil, errors.New("user not supplied")
-	}
-	if requireAccount && a.Account == nil {
-		return nil, errors.New("account not supplied")
-	}
-	return a, nil
 }
 
 // HandleTokenRequest wraps the oauth2 library's HandleTokenRequest function
@@ -211,52 +168,3 @@ func (s *s) GenerateUserAccessToken(ti oauth2.TokenInfo, clientSecret string, us
 	s.log.Tracef("obtained user-level access token: %+v", accessToken)
 	return accessToken, nil
 }
-
-// New returns a new oauth server that implements the Server interface
-func New(database db.DB, log *logrus.Logger) Server {
-	ts := newTokenStore(context.Background(), database, log)
-	cs := newClientStore(database)
-
-	manager := manage.NewDefaultManager()
-	manager.MapTokenStorage(ts)
-	manager.MapClientStorage(cs)
-	manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
-	sc := &server.Config{
-		TokenType: "Bearer",
-		// Must follow the spec.
-		AllowGetAccessRequest: false,
-		// Support only the non-implicit flow.
-		AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code},
-		// Allow:
-		// - Authorization Code (for first & third parties)
-		// - Client Credentials (for applications)
-		AllowedGrantTypes: []oauth2.GrantType{
-			oauth2.AuthorizationCode,
-			oauth2.ClientCredentials,
-		},
-		AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain},
-	}
-
-	srv := server.NewServer(sc, manager)
-	srv.SetInternalErrorHandler(func(err error) *errors.Response {
-		log.Errorf("internal oauth error: %s", err)
-		return nil
-	})
-
-	srv.SetResponseErrorHandler(func(re *errors.Response) {
-		log.Errorf("internal response error: %s", re.Error)
-	})
-
-	srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) {
-		userID := r.FormValue("userid")
-		if userID == "" {
-			return "", errors.New("userid was empty")
-		}
-		return userID, nil
-	})
-	srv.SetClientInfoHandler(server.ClientFormHandler)
-	return &s{
-		server: srv,
-		log:    log,
-	}
-}
diff --git a/internal/oauth/tokenstore_test.go b/internal/oauth/tokenstore_test.go
index 594b9b5a9..1b8449619 100644
--- a/internal/oauth/tokenstore_test.go
+++ b/internal/oauth/tokenstore_test.go
@@ -16,6 +16,6 @@
    along with this program.  If not, see .
 */
 
-package oauth
+package oauth_test
 
 // TODO: write tests
diff --git a/internal/oauth/util.go b/internal/oauth/util.go
new file mode 100644
index 000000000..378b81450
--- /dev/null
+++ b/internal/oauth/util.go
@@ -0,0 +1,86 @@
+package oauth
+
+import (
+	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/oauth2/v4"
+	"github.com/superseriousbusiness/oauth2/v4/errors"
+)
+
+// Auth wraps an authorized token, application, user, and account.
+// It is used in the functions GetAuthed and MustAuth.
+// Because the user might *not* be authed, any of the fields in this struct
+// might be nil, so make sure to check that when you're using this struct anywhere.
+type Auth struct {
+	Token       oauth2.TokenInfo
+	Application *gtsmodel.Application
+	User        *gtsmodel.User
+	Account     *gtsmodel.Account
+}
+
+// Authed is a convenience function for returning an Authed struct from a gin context.
+// In essence, it tries to extract a token, application, user, and account from the context,
+// and then sets them on a struct for convenience.
+//
+// If any are not present in the context, they will be set to nil on the returned Authed struct.
+//
+// If *ALL* are not present, then nil and an error will be returned.
+//
+// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed).
+// Authed is like GetAuthed, but will fail if one of the requirements is not met.
+func Authed(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Auth, error) {
+	ctx := c.Copy()
+	a := &Auth{}
+	var i interface{}
+	var ok bool
+
+	i, ok = ctx.Get(SessionAuthorizedToken)
+	if ok {
+		parsed, ok := i.(oauth2.TokenInfo)
+		if !ok {
+			return nil, errors.New("could not parse token from session context")
+		}
+		a.Token = parsed
+	}
+
+	i, ok = ctx.Get(SessionAuthorizedApplication)
+	if ok {
+		parsed, ok := i.(*gtsmodel.Application)
+		if !ok {
+			return nil, errors.New("could not parse application from session context")
+		}
+		a.Application = parsed
+	}
+
+	i, ok = ctx.Get(SessionAuthorizedUser)
+	if ok {
+		parsed, ok := i.(*gtsmodel.User)
+		if !ok {
+			return nil, errors.New("could not parse user from session context")
+		}
+		a.User = parsed
+	}
+
+	i, ok = ctx.Get(SessionAuthorizedAccount)
+	if ok {
+		parsed, ok := i.(*gtsmodel.Account)
+		if !ok {
+			return nil, errors.New("could not parse account from session context")
+		}
+		a.Account = parsed
+	}
+
+	if requireToken && a.Token == nil {
+		return nil, errors.New("token not supplied")
+	}
+	if requireApp && a.Application == nil {
+		return nil, errors.New("application not supplied")
+	}
+	if requireUser && a.User == nil {
+		return nil, errors.New("user not supplied")
+	}
+	if requireAccount && a.Account == nil {
+		return nil, errors.New("account not supplied")
+	}
+	return a, nil
+}
diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go
index 2d88189db..a596c3d97 100644
--- a/internal/storage/inmem.go
+++ b/internal/storage/inmem.go
@@ -35,7 +35,7 @@ func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) {
 	l := s.log.WithField("func", "RetrieveFileFrom")
 	l.Debugf("retrieving from path %s", path)
 	d, ok := s.stored[path]
-	if !ok {
+	if !ok || len(d) == 0 {
 		return nil, fmt.Errorf("no data found at path %s", path)
 	}
 	return d, nil
diff --git a/internal/transport/controller.go b/internal/transport/controller.go
new file mode 100644
index 000000000..525141025
--- /dev/null
+++ b/internal/transport/controller.go
@@ -0,0 +1,71 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package transport
+
+import (
+	"crypto"
+	"fmt"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/go-fed/httpsig"
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+)
+
+// Controller generates transports for use in making federation requests to other servers.
+type Controller interface {
+	NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error)
+}
+
+type controller struct {
+	config   *config.Config
+	clock    pub.Clock
+	client   pub.HttpClient
+	appAgent string
+}
+
+// NewController returns an implementation of the Controller interface for creating new transports
+func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient, log *logrus.Logger) Controller {
+	return &controller{
+		config:   config,
+		clock:    clock,
+		client:   client,
+		appAgent: fmt.Sprintf("%s %s", config.ApplicationName, config.Host),
+	}
+}
+
+// NewTransport returns a new http signature transport with the given public key id (a URL), and the given private key.
+func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) {
+	prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512}
+	digestAlgo := httpsig.DigestSha256
+	getHeaders := []string{"(request-target)", "date"}
+	postHeaders := []string{"(request-target)", "date", "digest"}
+
+	getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature)
+	if err != nil {
+		return nil, fmt.Errorf("error creating get signer: %s", err)
+	}
+
+	postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature)
+	if err != nil {
+		return nil, fmt.Errorf("error creating post signer: %s", err)
+	}
+
+	return pub.NewHttpSigTransport(c.client, c.appAgent, c.clock, getSigner, postSigner, pubKeyID, privkey), nil
+}
diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go
new file mode 100644
index 000000000..ba5c4aa2a
--- /dev/null
+++ b/internal/typeutils/accountable.go
@@ -0,0 +1,101 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils
+
+import "github.com/go-fed/activity/streams/vocab"
+
+// Accountable represents the minimum activitypub interface for representing an 'account'.
+// This interface is fulfilled by: Person, Application, Organization, Service, and Group
+type Accountable interface {
+	withJSONLDId
+	withGetTypeName
+	withPreferredUsername
+	withIcon
+	withDisplayName
+	withImage
+	withSummary
+	withDiscoverable
+	withURL
+	withPublicKey
+	withInbox
+	withOutbox
+	withFollowing
+	withFollowers
+	withFeatured
+}
+
+type withJSONLDId interface {
+	GetJSONLDId() vocab.JSONLDIdProperty
+}
+
+type withGetTypeName interface {
+	GetTypeName() string
+}
+
+type withPreferredUsername interface {
+	GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
+}
+
+type withIcon interface {
+	GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
+}
+
+type withDisplayName interface {
+	GetActivityStreamsName() vocab.ActivityStreamsNameProperty
+}
+
+type withImage interface {
+	GetActivityStreamsImage() vocab.ActivityStreamsImageProperty
+}
+
+type withSummary interface {
+	GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty
+}
+
+type withDiscoverable interface {
+	GetTootDiscoverable() vocab.TootDiscoverableProperty
+}
+
+type withURL interface {
+	GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty
+}
+
+type withPublicKey interface {
+	GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
+}
+
+type withInbox interface {
+	GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty
+}
+
+type withOutbox interface {
+	GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty
+}
+
+type withFollowing interface {
+	GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty
+}
+
+type withFollowers interface {
+	GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty
+}
+
+type withFeatured interface {
+	GetTootFeatured() vocab.TootFeaturedProperty
+}
diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go
new file mode 100644
index 000000000..8d39be3ec
--- /dev/null
+++ b/internal/typeutils/asextractionutil.go
@@ -0,0 +1,216 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils
+
+import (
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"net/url"
+
+	"github.com/go-fed/activity/pub"
+)
+
+func extractPreferredUsername(i withPreferredUsername) (string, error) {
+	u := i.GetActivityStreamsPreferredUsername()
+	if u == nil || !u.IsXMLSchemaString() {
+		return "", errors.New("preferredUsername was not a string")
+	}
+	if u.GetXMLSchemaString() == "" {
+		return "", errors.New("preferredUsername was empty")
+	}
+	return u.GetXMLSchemaString(), nil
+}
+
+func extractName(i withDisplayName) (string, error) {
+	nameProp := i.GetActivityStreamsName()
+	if nameProp == nil {
+		return "", errors.New("activityStreamsName not found")
+	}
+
+	// take the first name string we can find
+	for nameIter := nameProp.Begin(); nameIter != nameProp.End(); nameIter = nameIter.Next() {
+		if nameIter.IsXMLSchemaString() && nameIter.GetXMLSchemaString() != "" {
+			return nameIter.GetXMLSchemaString(), nil
+		}
+	}
+
+	return "", errors.New("activityStreamsName not found")
+}
+
+// extractIconURL extracts a URL to a supported image file from something like:
+//   "icon": {
+//     "mediaType": "image/jpeg",
+//     "type": "Image",
+//     "url": "http://example.org/path/to/some/file.jpeg"
+//   },
+func extractIconURL(i withIcon) (*url.URL, error) {
+	iconProp := i.GetActivityStreamsIcon()
+	if iconProp == nil {
+		return nil, errors.New("icon property was nil")
+	}
+
+	// icon can potentially contain multiple entries, so we iterate through all of them
+	// here in order to find the first one that meets these criteria:
+	// 1. is an image
+	// 2. has a URL so we can grab it
+	for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() {
+		// 1. is an image
+		if !iconIter.IsActivityStreamsImage() {
+			continue
+		}
+		imageValue := iconIter.GetActivityStreamsImage()
+		if imageValue == nil {
+			continue
+		}
+
+		// 2. has a URL so we can grab it
+		url, err := extractURL(imageValue)
+		if err == nil && url != nil {
+			return url, nil
+		}
+	}
+	// if we get to this point we didn't find an icon meeting our criteria :'(
+	return nil, errors.New("could not extract valid image from icon")
+}
+
+// extractImageURL extracts a URL to a supported image file from something like:
+//   "image": {
+//     "mediaType": "image/jpeg",
+//     "type": "Image",
+//     "url": "http://example.org/path/to/some/file.jpeg"
+//   },
+func extractImageURL(i withImage) (*url.URL, error) {
+	imageProp := i.GetActivityStreamsImage()
+	if imageProp == nil {
+		return nil, errors.New("icon property was nil")
+	}
+
+	// icon can potentially contain multiple entries, so we iterate through all of them
+	// here in order to find the first one that meets these criteria:
+	// 1. is an image
+	// 2. has a URL so we can grab it
+	for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() {
+		// 1. is an image
+		if !imageIter.IsActivityStreamsImage() {
+			continue
+		}
+		imageValue := imageIter.GetActivityStreamsImage()
+		if imageValue == nil {
+			continue
+		}
+
+		// 2. has a URL so we can grab it
+		url, err := extractURL(imageValue)
+		if err == nil && url != nil {
+			return url, nil
+		}
+	}
+	// if we get to this point we didn't find an image meeting our criteria :'(
+	return nil, errors.New("could not extract valid image from image property")
+}
+
+func extractSummary(i withSummary) (string, error) {
+	summaryProp := i.GetActivityStreamsSummary()
+	if summaryProp == nil {
+		return "", errors.New("summary property was nil")
+	}
+
+	for summaryIter := summaryProp.Begin(); summaryIter != summaryProp.End(); summaryIter = summaryIter.Next() {
+		if summaryIter.IsXMLSchemaString() && summaryIter.GetXMLSchemaString() != "" {
+			return summaryIter.GetXMLSchemaString(), nil
+		}
+	}
+
+	return "", errors.New("could not extract summary")
+}
+
+func extractDiscoverable(i withDiscoverable) (bool, error) {
+	if i.GetTootDiscoverable() == nil {
+		return false, errors.New("discoverable was nil")
+	}
+	return i.GetTootDiscoverable().Get(), nil
+}
+
+func extractURL(i withURL) (*url.URL, error) {
+	urlProp := i.GetActivityStreamsUrl()
+	if urlProp == nil {
+		return nil, errors.New("url property was nil")
+	}
+
+	for urlIter := urlProp.Begin(); urlIter != urlProp.End(); urlIter = urlIter.Next() {
+		if urlIter.IsIRI() && urlIter.GetIRI() != nil {
+			return urlIter.GetIRI(), nil
+		}
+	}
+
+	return nil, errors.New("could not extract url")
+}
+
+func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKey, *url.URL, error) {
+	publicKeyProp := i.GetW3IDSecurityV1PublicKey()
+	if publicKeyProp == nil {
+		return nil, nil, errors.New("public key property was nil")
+	}
+
+	for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() {
+		pkey := publicKeyIter.Get()
+		if pkey == nil {
+			continue
+		}
+
+		pkeyID, err := pub.GetId(pkey)
+		if err != nil || pkeyID == nil {
+			continue
+		}
+
+		if pkey.GetW3IDSecurityV1Owner() == nil || pkey.GetW3IDSecurityV1Owner().Get() == nil || pkey.GetW3IDSecurityV1Owner().Get().String() != forOwner.String() {
+			continue
+		}
+
+		if pkey.GetW3IDSecurityV1PublicKeyPem() == nil {
+			continue
+		}
+
+		pkeyPem := pkey.GetW3IDSecurityV1PublicKeyPem().Get()
+		if pkeyPem == "" {
+			continue
+		}
+
+		block, _ := pem.Decode([]byte(pkeyPem))
+		if block == nil || block.Type != "PUBLIC KEY" {
+			return nil, nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")
+		}
+
+		p, err := x509.ParsePKIXPublicKey(block.Bytes)
+		if err != nil {
+			return nil, nil, fmt.Errorf("could not parse public key from block bytes: %s", err)
+		}
+		if p == nil {
+			return nil, nil, errors.New("returned public key was empty")
+		}
+
+		if publicKey, ok := p.(*rsa.PublicKey); ok {
+			return publicKey, pkeyID, nil
+		}
+	}
+	return nil, nil, errors.New("couldn't find public key")
+}
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
new file mode 100644
index 000000000..5e3b6b052
--- /dev/null
+++ b/internal/typeutils/astointernal.go
@@ -0,0 +1,164 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) {
+	// first check if we actually already know this account
+	uriProp := accountable.GetJSONLDId()
+	if uriProp == nil || !uriProp.IsIRI() {
+		return nil, errors.New("no id property found on person, or id was not an iri")
+	}
+	uri := uriProp.GetIRI()
+
+	acct := >smodel.Account{}
+	err := c.db.GetWhere("uri", uri.String(), acct)
+	if err == nil {
+		// we already know this account so we can skip generating it
+		return acct, nil
+	}
+	if _, ok := err.(db.ErrNoEntries); !ok {
+		// we don't know the account and there's been a real error
+		return nil, fmt.Errorf("error getting account with uri %s from the database: %s", uri.String(), err)
+	}
+
+	// we don't know the account so we need to generate it from the person -- at least we already have the URI!
+	acct = >smodel.Account{}
+	acct.URI = uri.String()
+
+	// Username aka preferredUsername
+	// We need this one so bail if it's not set.
+	username, err := extractPreferredUsername(accountable)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't extract username: %s", err)
+	}
+	acct.Username = username
+
+	// Domain
+	acct.Domain = uri.Host
+
+	// avatar aka icon
+	// if this one isn't extractable in a format we recognise we'll just skip it
+	if avatarURL, err := extractIconURL(accountable); err == nil {
+		acct.AvatarRemoteURL = avatarURL.String()
+	}
+
+	// header aka image
+	// if this one isn't extractable in a format we recognise we'll just skip it
+	if headerURL, err := extractImageURL(accountable); err == nil {
+		acct.HeaderRemoteURL = headerURL.String()
+	}
+
+	// display name aka name
+	// we default to the username, but take the more nuanced name property if it exists
+	acct.DisplayName = username
+	if displayName, err := extractName(accountable); err == nil {
+		acct.DisplayName = displayName
+	}
+
+	// TODO: fields aka attachment array
+
+	// note aka summary
+	note, err := extractSummary(accountable)
+	if err == nil && note != "" {
+		acct.Note = note
+	}
+
+	// check for bot and actor type
+	switch gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) {
+	case gtsmodel.ActivityStreamsPerson, gtsmodel.ActivityStreamsGroup, gtsmodel.ActivityStreamsOrganization:
+		// people, groups, and organizations aren't bots
+		acct.Bot = false
+		// apps and services are
+	case gtsmodel.ActivityStreamsApplication, gtsmodel.ActivityStreamsService:
+		acct.Bot = true
+	default:
+		// we don't know what this is!
+		return nil, fmt.Errorf("type name %s not recognised or not convertible to gtsmodel.ActivityStreamsActor", accountable.GetTypeName())
+	}
+	acct.ActorType = gtsmodel.ActivityStreamsActor(accountable.GetTypeName())
+
+	// TODO: locked aka manuallyApprovesFollowers
+
+	// discoverable
+	// default to false -- take custom value if it's set though
+	acct.Discoverable = false
+	discoverable, err := extractDiscoverable(accountable)
+	if err == nil {
+		acct.Discoverable = discoverable
+	}
+
+	// url property
+	url, err := extractURL(accountable)
+	if err != nil {
+		return nil, fmt.Errorf("could not extract url for person with id %s: %s", uri.String(), err)
+	}
+	acct.URL = url.String()
+
+	// InboxURI
+	if accountable.GetActivityStreamsInbox() == nil || accountable.GetActivityStreamsInbox().GetIRI() == nil {
+		return nil, fmt.Errorf("person with id %s had no inbox uri", uri.String())
+	}
+	acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String()
+
+	// OutboxURI
+	if accountable.GetActivityStreamsOutbox() == nil || accountable.GetActivityStreamsOutbox().GetIRI() == nil {
+		return nil, fmt.Errorf("person with id %s had no outbox uri", uri.String())
+	}
+	acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String()
+
+	// FollowingURI
+	if accountable.GetActivityStreamsFollowing() == nil || accountable.GetActivityStreamsFollowing().GetIRI() == nil {
+		return nil, fmt.Errorf("person with id %s had no following uri", uri.String())
+	}
+	acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String()
+
+	// FollowersURI
+	if accountable.GetActivityStreamsFollowers() == nil || accountable.GetActivityStreamsFollowers().GetIRI() == nil {
+		return nil, fmt.Errorf("person with id %s had no followers uri", uri.String())
+	}
+	acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String()
+
+	// FeaturedURI
+	// very much optional
+	if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil {
+		acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String()
+	}
+
+	// TODO: FeaturedTagsURI
+
+	// TODO: alsoKnownAs
+
+	// publicKey
+	pkey, pkeyURL, err := extractPublicKeyForOwner(accountable, uri)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't get public key for person %s: %s", uri.String(), err)
+	}
+	acct.PublicKey = pkey
+	acct.PublicKeyURI = pkeyURL.String()
+
+	return acct, nil
+}
diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go
new file mode 100644
index 000000000..1cd66a0ab
--- /dev/null
+++ b/internal/typeutils/astointernal_test.go
@@ -0,0 +1,206 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/go-fed/activity/streams"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ASToInternalTestSuite struct {
+	ConverterStandardTestSuite
+}
+
+const (
+	gargronAsActivityJson = `{
+		"@context": [
+		  "https://www.w3.org/ns/activitystreams",
+		  "https://w3id.org/security/v1",
+		  {
+			"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+			"toot": "http://joinmastodon.org/ns#",
+			"featured": {
+			  "@id": "toot:featured",
+			  "@type": "@id"
+			},
+			"featuredTags": {
+			  "@id": "toot:featuredTags",
+			  "@type": "@id"
+			},
+			"alsoKnownAs": {
+			  "@id": "as:alsoKnownAs",
+			  "@type": "@id"
+			},
+			"movedTo": {
+			  "@id": "as:movedTo",
+			  "@type": "@id"
+			},
+			"schema": "http://schema.org#",
+			"PropertyValue": "schema:PropertyValue",
+			"value": "schema:value",
+			"IdentityProof": "toot:IdentityProof",
+			"discoverable": "toot:discoverable",
+			"Device": "toot:Device",
+			"Ed25519Signature": "toot:Ed25519Signature",
+			"Ed25519Key": "toot:Ed25519Key",
+			"Curve25519Key": "toot:Curve25519Key",
+			"EncryptedMessage": "toot:EncryptedMessage",
+			"publicKeyBase64": "toot:publicKeyBase64",
+			"deviceId": "toot:deviceId",
+			"claim": {
+			  "@type": "@id",
+			  "@id": "toot:claim"
+			},
+			"fingerprintKey": {
+			  "@type": "@id",
+			  "@id": "toot:fingerprintKey"
+			},
+			"identityKey": {
+			  "@type": "@id",
+			  "@id": "toot:identityKey"
+			},
+			"devices": {
+			  "@type": "@id",
+			  "@id": "toot:devices"
+			},
+			"messageFranking": "toot:messageFranking",
+			"messageType": "toot:messageType",
+			"cipherText": "toot:cipherText",
+			"suspended": "toot:suspended",
+			"focalPoint": {
+			  "@container": "@list",
+			  "@id": "toot:focalPoint"
+			}
+		  }
+		],
+		"id": "https://mastodon.social/users/Gargron",
+		"type": "Person",
+		"following": "https://mastodon.social/users/Gargron/following",
+		"followers": "https://mastodon.social/users/Gargron/followers",
+		"inbox": "https://mastodon.social/users/Gargron/inbox",
+		"outbox": "https://mastodon.social/users/Gargron/outbox",
+		"featured": "https://mastodon.social/users/Gargron/collections/featured",
+		"featuredTags": "https://mastodon.social/users/Gargron/collections/tags",
+		"preferredUsername": "Gargron",
+		"name": "Eugen",
+		"summary": "
Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.
",
+		"url": "https://mastodon.social/@Gargron",
+		"manuallyApprovesFollowers": false,
+		"discoverable": true,
+		"devices": "https://mastodon.social/users/Gargron/collections/devices",
+		"alsoKnownAs": [
+		  "https://tooting.ai/users/Gargron"
+		],
+		"publicKey": {
+		  "id": "https://mastodon.social/users/Gargron#main-key",
+		  "owner": "https://mastodon.social/users/Gargron",
+		  "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
+		},
+		"tag": [],
+		"attachment": [
+		  {
+			"type": "PropertyValue",
+			"name": "Patreon",
+			"value": "https://www.patreon.com/mastodon"
+		  },
+		  {
+			"type": "PropertyValue",
+			"name": "Homepage",
+			"value": "https://zeonfederated.com"
+		  },
+		  {
+			"type": "IdentityProof",
+			"name": "gargron",
+			"signatureAlgorithm": "keybase",
+			"signatureValue": "5cfc20c7018f2beefb42a68836da59a792e55daa4d118498c9b1898de7e845690f"
+		  }
+		],
+		"endpoints": {
+		  "sharedInbox": "https://mastodon.social/inbox"
+		},
+		"icon": {
+		  "type": "Image",
+		  "mediaType": "image/jpeg",
+		  "url": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg"
+		},
+		"image": {
+		  "type": "Image",
+		  "mediaType": "image/png",
+		  "url": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png"
+		}
+	  }`
+)
+
+func (suite *ASToInternalTestSuite) SetupSuite() {
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.log = testrig.NewTestLog()
+	suite.accounts = testrig.NewTestAccounts()
+	suite.people = testrig.NewTestFediPeople()
+	suite.typeconverter = typeutils.NewConverter(suite.config, suite.db)
+}
+
+func (suite *ASToInternalTestSuite) SetupTest() {
+	testrig.StandardDBSetup(suite.db)
+}
+
+func (suite *ASToInternalTestSuite) TestParsePerson() {
+
+	testPerson := suite.people["new_person_1"]
+
+	acct, err := suite.typeconverter.ASRepresentationToAccount(testPerson)
+	assert.NoError(suite.T(), err)
+
+	fmt.Printf("%+v", acct)
+	// TODO: write assertions here, rn we're just eyeballing the output
+}
+
+func (suite *ASToInternalTestSuite) TestParseGargron() {
+	m := make(map[string]interface{})
+	err := json.Unmarshal([]byte(gargronAsActivityJson), &m)
+	assert.NoError(suite.T(), err)
+
+	t, err := streams.ToType(context.Background(), m)
+	assert.NoError(suite.T(), err)
+
+	rep, ok := t.(typeutils.Accountable)
+	assert.True(suite.T(), ok)
+
+	acct, err := suite.typeconverter.ASRepresentationToAccount(rep)
+	assert.NoError(suite.T(), err)
+
+	fmt.Printf("%+v", acct)
+	// TODO: write assertions here, rn we're just eyeballing the output
+}
+
+func (suite *ASToInternalTestSuite) TearDownTest() {
+	testrig.StandardDBTeardown(suite.db)
+}
+
+func TestASToInternalTestSuite(t *testing.T) {
+	suite.Run(t, new(ASToInternalTestSuite))
+}
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
new file mode 100644
index 000000000..5118386a9
--- /dev/null
+++ b/internal/typeutils/converter.go
@@ -0,0 +1,113 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils
+
+import (
+	"github.com/go-fed/activity/streams/vocab"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// TypeConverter is an interface for the common action of converting between apimodule (frontend, serializable) models,
+// internal gts models used in the database, and activitypub models used in federation.
+//
+// It requires access to the database because many of the conversions require pulling out database entries and counting them etc.
+// That said, it *absolutely should not* manipulate database entries in any way, only examine them.
+type TypeConverter interface {
+	/*
+		INTERNAL (gts) MODEL TO FRONTEND (mastodon) MODEL
+	*/
+
+	// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
+	// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
+	// so serve it only to an authorized user who should have permission to see it.
+	AccountToMastoSensitive(account *gtsmodel.Account) (*model.Account, error)
+
+	// AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error
+	// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
+	// In other words, this is the public record that the server has of an account.
+	AccountToMastoPublic(account *gtsmodel.Account) (*model.Account, error)
+
+	// AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error
+	// if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields
+	// (such as client id and client secret), so serve it only to an authorized user who should have permission to see it.
+	AppToMastoSensitive(application *gtsmodel.Application) (*model.Application, error)
+
+	// AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error
+	// if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive
+	// fields sanitized so that it can be served to non-authorized accounts without revealing any private information.
+	AppToMastoPublic(application *gtsmodel.Application) (*model.Application, error)
+
+	// AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API.
+	AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (model.Attachment, error)
+
+	// MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API.
+	MentionToMasto(m *gtsmodel.Mention) (model.Mention, error)
+
+	// EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API.
+	EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error)
+
+	// TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API.
+	TagToMasto(t *gtsmodel.Tag) (model.Tag, error)
+
+	// StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API.
+	StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*model.Status, error)
+
+	// VisToMasto converts a gts visibility into its mastodon equivalent
+	VisToMasto(m gtsmodel.Visibility) model.Visibility
+
+	/*
+		FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL
+	*/
+
+	// MastoVisToVis converts a mastodon visibility into its gts equivalent.
+	MastoVisToVis(m model.Visibility) gtsmodel.Visibility
+
+	/*
+		ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL
+	*/
+
+	// ASPersonToAccount converts a remote account/person/application representation into a gts model account
+	ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error)
+
+	/*
+		INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL
+	*/
+
+	// AccountToAS converts a gts model account into an activity streams person, suitable for federation
+	AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error)
+
+	// StatusToAS converts a gts model status into an activity streams note, suitable for federation
+	StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error)
+}
+
+type converter struct {
+	config *config.Config
+	db     db.DB
+}
+
+// NewConverter returns a new Converter
+func NewConverter(config *config.Config, db db.DB) TypeConverter {
+	return &converter{
+		config: config,
+		db:     db,
+	}
+}
diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go
new file mode 100644
index 000000000..b2272f50c
--- /dev/null
+++ b/internal/typeutils/converter_test.go
@@ -0,0 +1,40 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils_test
+
+import (
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type ConverterStandardTestSuite struct {
+	suite.Suite
+	config   *config.Config
+	db       db.DB
+	log      *logrus.Logger
+	accounts map[string]*gtsmodel.Account
+	people   map[string]typeutils.Accountable
+
+	typeconverter typeutils.TypeConverter
+}
diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go
new file mode 100644
index 000000000..6bb45d61b
--- /dev/null
+++ b/internal/typeutils/frontendtointernal.go
@@ -0,0 +1,39 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils
+
+import (
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// MastoVisToVis converts a mastodon visibility into its gts equivalent.
+func (c *converter) MastoVisToVis(m model.Visibility) gtsmodel.Visibility {
+	switch m {
+	case model.VisibilityPublic:
+		return gtsmodel.VisibilityPublic
+	case model.VisibilityUnlisted:
+		return gtsmodel.VisibilityUnlocked
+	case model.VisibilityPrivate:
+		return gtsmodel.VisibilityFollowersOnly
+	case model.VisibilityDirect:
+		return gtsmodel.VisibilityDirect
+	}
+	return ""
+}
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
new file mode 100644
index 000000000..73c121155
--- /dev/null
+++ b/internal/typeutils/internaltoas.go
@@ -0,0 +1,260 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"net/url"
+
+	"github.com/go-fed/activity/streams"
+	"github.com/go-fed/activity/streams/vocab"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Converts a gts model account into an Activity Streams person type, following
+// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/
+func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) {
+	person := streams.NewActivityStreamsPerson()
+
+	// id should be the activitypub URI of this user
+	// something like https://example.org/users/example_user
+	profileIDURI, err := url.Parse(a.URI)
+	if err != nil {
+		return nil, err
+	}
+	idProp := streams.NewJSONLDIdProperty()
+	idProp.SetIRI(profileIDURI)
+	person.SetJSONLDId(idProp)
+
+	// following
+	// The URI for retrieving a list of accounts this user is following
+	followingURI, err := url.Parse(a.FollowingURI)
+	if err != nil {
+		return nil, err
+	}
+	followingProp := streams.NewActivityStreamsFollowingProperty()
+	followingProp.SetIRI(followingURI)
+	person.SetActivityStreamsFollowing(followingProp)
+
+	// followers
+	// The URI for retrieving a list of this user's followers
+	followersURI, err := url.Parse(a.FollowersURI)
+	if err != nil {
+		return nil, err
+	}
+	followersProp := streams.NewActivityStreamsFollowersProperty()
+	followersProp.SetIRI(followersURI)
+	person.SetActivityStreamsFollowers(followersProp)
+
+	// inbox
+	// the activitypub inbox of this user for accepting messages
+	inboxURI, err := url.Parse(a.InboxURI)
+	if err != nil {
+		return nil, err
+	}
+	inboxProp := streams.NewActivityStreamsInboxProperty()
+	inboxProp.SetIRI(inboxURI)
+	person.SetActivityStreamsInbox(inboxProp)
+
+	// outbox
+	// the activitypub outbox of this user for serving messages
+	outboxURI, err := url.Parse(a.OutboxURI)
+	if err != nil {
+		return nil, err
+	}
+	outboxProp := streams.NewActivityStreamsOutboxProperty()
+	outboxProp.SetIRI(outboxURI)
+	person.SetActivityStreamsOutbox(outboxProp)
+
+	// featured posts
+	// Pinned posts.
+	featuredURI, err := url.Parse(a.FeaturedCollectionURI)
+	if err != nil {
+		return nil, err
+	}
+	featuredProp := streams.NewTootFeaturedProperty()
+	featuredProp.SetIRI(featuredURI)
+	person.SetTootFeatured(featuredProp)
+
+	// featuredTags
+	// NOT IMPLEMENTED
+
+	// preferredUsername
+	// Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI.
+	preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty()
+	preferredUsernameProp.SetXMLSchemaString(a.Username)
+	person.SetActivityStreamsPreferredUsername(preferredUsernameProp)
+
+	// name
+	// Used as profile display name.
+	nameProp := streams.NewActivityStreamsNameProperty()
+	if a.Username != "" {
+		nameProp.AppendXMLSchemaString(a.DisplayName)
+	} else {
+		nameProp.AppendXMLSchemaString(a.Username)
+	}
+	person.SetActivityStreamsName(nameProp)
+
+	// summary
+	// Used as profile bio.
+	if a.Note != "" {
+		summaryProp := streams.NewActivityStreamsSummaryProperty()
+		summaryProp.AppendXMLSchemaString(a.Note)
+		person.SetActivityStreamsSummary(summaryProp)
+	}
+
+	// url
+	// Used as profile link.
+	profileURL, err := url.Parse(a.URL)
+	if err != nil {
+		return nil, err
+	}
+	urlProp := streams.NewActivityStreamsUrlProperty()
+	urlProp.AppendIRI(profileURL)
+	person.SetActivityStreamsUrl(urlProp)
+
+	// manuallyApprovesFollowers
+	// Will be shown as a locked account.
+	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+	// discoverable
+	// Will be shown in the profile directory.
+	discoverableProp := streams.NewTootDiscoverableProperty()
+	discoverableProp.Set(a.Discoverable)
+	person.SetTootDiscoverable(discoverableProp)
+
+	// devices
+	// NOT IMPLEMENTED, probably won't implement
+
+	// alsoKnownAs
+	// Required for Move activity.
+	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+	// publicKey
+	// Required for signatures.
+	publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
+
+	// create the public key
+	publicKey := streams.NewW3IDSecurityV1PublicKey()
+
+	// set ID for the public key
+	publicKeyIDProp := streams.NewJSONLDIdProperty()
+	publicKeyURI, err := url.Parse(a.PublicKeyURI)
+	if err != nil {
+		return nil, err
+	}
+	publicKeyIDProp.SetIRI(publicKeyURI)
+	publicKey.SetJSONLDId(publicKeyIDProp)
+
+	// set owner for the public key
+	publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty()
+	publicKeyOwnerProp.SetIRI(profileIDURI)
+	publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp)
+
+	// set the pem key itself
+	encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey)
+	if err != nil {
+		return nil, err
+	}
+	publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+		Type:  "PUBLIC KEY",
+		Bytes: encodedPublicKey,
+	})
+	publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
+	publicKeyPEMProp.Set(string(publicKeyBytes))
+	publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp)
+
+	// append the public key to the public key property
+	publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey)
+
+	// set the public key property on the Person
+	person.SetW3IDSecurityV1PublicKey(publicKeyProp)
+
+	// tag
+	// TODO: Any tags used in the summary of this profile
+
+	// attachment
+	// Used for profile fields.
+	// TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue
+
+	// endpoints
+	// NOT IMPLEMENTED -- this is for shared inbox which we don't use
+
+	// icon
+	// Used as profile avatar.
+	if a.AvatarMediaAttachmentID != "" {
+		iconProperty := streams.NewActivityStreamsIconProperty()
+
+		iconImage := streams.NewActivityStreamsImage()
+
+		avatar := >smodel.MediaAttachment{}
+		if err := c.db.GetByID(a.AvatarMediaAttachmentID, avatar); err != nil {
+			return nil, err
+		}
+
+		mediaType := streams.NewActivityStreamsMediaTypeProperty()
+		mediaType.Set(avatar.File.ContentType)
+		iconImage.SetActivityStreamsMediaType(mediaType)
+
+		avatarURLProperty := streams.NewActivityStreamsUrlProperty()
+		avatarURL, err := url.Parse(avatar.URL)
+		if err != nil {
+			return nil, err
+		}
+		avatarURLProperty.AppendIRI(avatarURL)
+		iconImage.SetActivityStreamsUrl(avatarURLProperty)
+
+		iconProperty.AppendActivityStreamsImage(iconImage)
+		person.SetActivityStreamsIcon(iconProperty)
+	}
+
+	// image
+	// Used as profile header.
+	if a.HeaderMediaAttachmentID != "" {
+		headerProperty := streams.NewActivityStreamsImageProperty()
+
+		headerImage := streams.NewActivityStreamsImage()
+
+		header := >smodel.MediaAttachment{}
+		if err := c.db.GetByID(a.HeaderMediaAttachmentID, header); err != nil {
+			return nil, err
+		}
+
+		mediaType := streams.NewActivityStreamsMediaTypeProperty()
+		mediaType.Set(header.File.ContentType)
+		headerImage.SetActivityStreamsMediaType(mediaType)
+
+		headerURLProperty := streams.NewActivityStreamsUrlProperty()
+		headerURL, err := url.Parse(header.URL)
+		if err != nil {
+			return nil, err
+		}
+		headerURLProperty.AppendIRI(headerURL)
+		headerImage.SetActivityStreamsUrl(headerURLProperty)
+
+		headerProperty.AppendActivityStreamsImage(headerImage)
+	}
+
+	return person, nil
+}
+
+func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) {
+	return nil, nil
+}
diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go
new file mode 100644
index 000000000..8eb827e35
--- /dev/null
+++ b/internal/typeutils/internaltoas_test.go
@@ -0,0 +1,76 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package typeutils_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/go-fed/activity/streams"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+	"github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type InternalToASTestSuite struct {
+	ConverterStandardTestSuite
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *InternalToASTestSuite) SetupSuite() {
+	// setup standard items
+	suite.config = testrig.NewTestConfig()
+	suite.db = testrig.NewTestDB()
+	suite.log = testrig.NewTestLog()
+	suite.accounts = testrig.NewTestAccounts()
+	suite.people = testrig.NewTestFediPeople()
+	suite.typeconverter = typeutils.NewConverter(suite.config, suite.db)
+}
+
+func (suite *InternalToASTestSuite) SetupTest() {
+	testrig.StandardDBSetup(suite.db)
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *InternalToASTestSuite) TearDownTest() {
+	testrig.StandardDBTeardown(suite.db)
+}
+
+func (suite *InternalToASTestSuite) TestAccountToAS() {
+	testAccount := suite.accounts["local_account_1"] // take zork for this test
+
+	asPerson, err := suite.typeconverter.AccountToAS(testAccount)
+	assert.NoError(suite.T(), err)
+
+	ser, err := streams.Serialize(asPerson)
+	assert.NoError(suite.T(), err)
+
+	bytes, err := json.Marshal(ser)
+	assert.NoError(suite.T(), err)
+
+	fmt.Println(string(bytes))
+	// TODO: write assertions here, rn we're just eyeballing the output
+}
+
+func TestInternalToASTestSuite(t *testing.T) {
+	suite.Run(t, new(InternalToASTestSuite))
+}
diff --git a/internal/mastotypes/converter.go b/internal/typeutils/internaltofrontend.go
similarity index 73%
rename from internal/mastotypes/converter.go
rename to internal/typeutils/internaltofrontend.go
index e689b62da..9456ef531 100644
--- a/internal/mastotypes/converter.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -16,72 +16,18 @@
    along with this program.  If not, see .
 */
 
-package mastotypes
+package typeutils
 
 import (
 	"fmt"
 	"time"
 
-	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-	"github.com/superseriousbusiness/gotosocial/internal/util"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 )
 
-// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database.
-// It requires access to the database because many of the conversions require pulling out database entries and counting them etc.
-type Converter interface {
-	// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
-	// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
-	// so serve it only to an authorized user who should have permission to see it.
-	AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error)
-
-	// AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error
-	// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
-	// In other words, this is the public record that the server has of an account.
-	AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error)
-
-	// AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error
-	// if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields
-	// (such as client id and client secret), so serve it only to an authorized user who should have permission to see it.
-	AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error)
-
-	// AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error
-	// if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive
-	// fields sanitized so that it can be served to non-authorized accounts without revealing any private information.
-	AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error)
-
-	// AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API.
-	AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error)
-
-	// MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API.
-	MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error)
-
-	// EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API.
-	EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error)
-
-	// TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API.
-	TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error)
-
-	// StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API.
-	StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error)
-}
-
-type converter struct {
-	config *config.Config
-	db     db.DB
-}
-
-// New returns a new Converter
-func New(config *config.Config, db db.DB) Converter {
-	return &converter{
-		config: config,
-		db:     db,
-	}
-}
-
-func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) {
+func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account, error) {
 	// we can build this sensitive account easily by first getting the public account....
 	mastoAccount, err := c.AccountToMastoPublic(a)
 	if err != nil {
@@ -102,8 +48,8 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Ac
 		frc = len(fr)
 	}
 
-	mastoAccount.Source = &mastotypes.Source{
-		Privacy:             util.ParseMastoVisFromGTSVis(a.Privacy),
+	mastoAccount.Source = &model.Source{
+		Privacy:             c.VisToMasto(a.Privacy),
 		Sensitive:           a.Sensitive,
 		Language:            a.Language,
 		Note:                a.Note,
@@ -114,7 +60,7 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Ac
 	return mastoAccount, nil
 }
 
-func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) {
+func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) {
 	// count followers
 	followers := []gtsmodel.Follow{}
 	if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil {
@@ -174,7 +120,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou
 	aviURLStatic := avi.Thumbnail.URL
 
 	header := >smodel.MediaAttachment{}
-	if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil {
+	if err := c.db.GetHeaderForAccountID(header, a.ID); err != nil {
 		if _, ok := err.(db.ErrNoEntries); !ok {
 			return nil, fmt.Errorf("error getting header: %s", err)
 		}
@@ -183,9 +129,9 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou
 	headerURLStatic := header.Thumbnail.URL
 
 	// get the fields set on this account
-	fields := []mastotypes.Field{}
+	fields := []model.Field{}
 	for _, f := range a.Fields {
-		mField := mastotypes.Field{
+		mField := model.Field{
 			Name:  f.Name,
 			Value: f.Value,
 		}
@@ -204,7 +150,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou
 		acct = a.Username
 	}
 
-	return &mastotypes.Account{
+	return &model.Account{
 		ID:             a.ID,
 		Username:       a.Username,
 		Acct:           acct,
@@ -227,8 +173,8 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou
 	}, nil
 }
 
-func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) {
-	return &mastotypes.Application{
+func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*model.Application, error) {
+	return &model.Application{
 		ID:           a.ID,
 		Name:         a.Name,
 		Website:      a.Website,
@@ -239,35 +185,35 @@ func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Ap
 	}, nil
 }
 
-func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) {
-	return &mastotypes.Application{
+func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*model.Application, error) {
+	return &model.Application{
 		Name:    a.Name,
 		Website: a.Website,
 	}, nil
 }
 
-func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) {
-	return mastotypes.Attachment{
+func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (model.Attachment, error) {
+	return model.Attachment{
 		ID:               a.ID,
 		Type:             string(a.Type),
 		URL:              a.URL,
 		PreviewURL:       a.Thumbnail.URL,
 		RemoteURL:        a.RemoteURL,
 		PreviewRemoteURL: a.Thumbnail.RemoteURL,
-		Meta: mastotypes.MediaMeta{
-			Original: mastotypes.MediaDimensions{
+		Meta: model.MediaMeta{
+			Original: model.MediaDimensions{
 				Width:  a.FileMeta.Original.Width,
 				Height: a.FileMeta.Original.Height,
 				Size:   fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height),
 				Aspect: float32(a.FileMeta.Original.Aspect),
 			},
-			Small: mastotypes.MediaDimensions{
+			Small: model.MediaDimensions{
 				Width:  a.FileMeta.Small.Width,
 				Height: a.FileMeta.Small.Height,
 				Size:   fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height),
 				Aspect: float32(a.FileMeta.Small.Aspect),
 			},
-			Focus: mastotypes.MediaFocus{
+			Focus: model.MediaFocus{
 				X: a.FileMeta.Focus.X,
 				Y: a.FileMeta.Focus.Y,
 			},
@@ -277,10 +223,10 @@ func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.A
 	}, nil
 }
 
-func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) {
+func (c *converter) MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) {
 	target := >smodel.Account{}
 	if err := c.db.GetByID(m.TargetAccountID, target); err != nil {
-		return mastotypes.Mention{}, err
+		return model.Mention{}, err
 	}
 
 	var local bool
@@ -295,7 +241,7 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err
 		acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain)
 	}
 
-	return mastotypes.Mention{
+	return model.Mention{
 		ID:       target.ID,
 		Username: target.Username,
 		URL:      target.URL,
@@ -303,8 +249,8 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err
 	}, nil
 }
 
-func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) {
-	return mastotypes.Emoji{
+func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) {
+	return model.Emoji{
 		Shortcode:       e.Shortcode,
 		URL:             e.ImageURL,
 		StaticURL:       e.ImageStaticURL,
@@ -313,10 +259,10 @@ func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) {
 	}, nil
 }
 
-func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) {
+func (c *converter) TagToMasto(t *gtsmodel.Tag) (model.Tag, error) {
 	tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name)
 
-	return mastotypes.Tag{
+	return model.Tag{
 		Name: t.Name,
 		URL:  tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯
 	}, nil
@@ -328,7 +274,7 @@ func (c *converter) StatusToMasto(
 	requestingAccount *gtsmodel.Account,
 	boostOfAccount *gtsmodel.Account,
 	replyToAccount *gtsmodel.Account,
-	reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) {
+	reblogOfStatus *gtsmodel.Status) (*model.Status, error) {
 
 	repliesCount, err := c.db.GetReplyCountForStatus(s)
 	if err != nil {
@@ -380,9 +326,9 @@ func (c *converter) StatusToMasto(
 		}
 	}
 
-	var mastoRebloggedStatus *mastotypes.Status // TODO
+	var mastoRebloggedStatus *model.Status // TODO
 
-	var mastoApplication *mastotypes.Application
+	var mastoApplication *model.Application
 	if s.CreatedWithApplicationID != "" {
 		gtsApplication := >smodel.Application{}
 		if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil {
@@ -399,7 +345,7 @@ func (c *converter) StatusToMasto(
 		return nil, fmt.Errorf("error parsing account of status author: %s", err)
 	}
 
-	mastoAttachments := []mastotypes.Attachment{}
+	mastoAttachments := []model.Attachment{}
 	// the status might already have some gts attachments on it if it's not been pulled directly from the database
 	// if so, we can directly convert the gts attachments into masto ones
 	if s.GTSMediaAttachments != nil {
@@ -426,7 +372,7 @@ func (c *converter) StatusToMasto(
 		}
 	}
 
-	mastoMentions := []mastotypes.Mention{}
+	mastoMentions := []model.Mention{}
 	// the status might already have some gts mentions on it if it's not been pulled directly from the database
 	// if so, we can directly convert the gts mentions into masto ones
 	if s.GTSMentions != nil {
@@ -453,7 +399,7 @@ func (c *converter) StatusToMasto(
 		}
 	}
 
-	mastoTags := []mastotypes.Tag{}
+	mastoTags := []model.Tag{}
 	// the status might already have some gts tags on it if it's not been pulled directly from the database
 	// if so, we can directly convert the gts tags into masto ones
 	if s.GTSTags != nil {
@@ -480,7 +426,7 @@ func (c *converter) StatusToMasto(
 		}
 	}
 
-	mastoEmojis := []mastotypes.Emoji{}
+	mastoEmojis := []model.Emoji{}
 	// the status might already have some gts emojis on it if it's not been pulled directly from the database
 	// if so, we can directly convert the gts emojis into masto ones
 	if s.GTSEmojis != nil {
@@ -507,17 +453,17 @@ func (c *converter) StatusToMasto(
 		}
 	}
 
-	var mastoCard *mastotypes.Card
-	var mastoPoll *mastotypes.Poll
+	var mastoCard *model.Card
+	var mastoPoll *model.Poll
 
-	return &mastotypes.Status{
+	return &model.Status{
 		ID:                 s.ID,
 		CreatedAt:          s.CreatedAt.Format(time.RFC3339),
 		InReplyToID:        s.InReplyToID,
 		InReplyToAccountID: s.InReplyToAccountID,
 		Sensitive:          s.Sensitive,
 		SpoilerText:        s.ContentWarning,
-		Visibility:         util.ParseMastoVisFromGTSVis(s.Visibility),
+		Visibility:         c.VisToMasto(s.Visibility),
 		Language:           s.Language,
 		URI:                s.URI,
 		URL:                s.URL,
@@ -542,3 +488,18 @@ func (c *converter) StatusToMasto(
 		Text:               s.Text,
 	}, nil
 }
+
+// VisToMasto converts a gts visibility into its mastodon equivalent
+func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility {
+	switch m {
+	case gtsmodel.VisibilityPublic:
+		return model.VisibilityPublic
+	case gtsmodel.VisibilityUnlocked:
+		return model.VisibilityUnlisted
+	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
+		return model.VisibilityPrivate
+	case gtsmodel.VisibilityDirect:
+		return model.VisibilityDirect
+	}
+	return ""
+}
diff --git a/internal/util/parse.go b/internal/util/parse.go
deleted file mode 100644
index f0bcff5dc..000000000
--- a/internal/util/parse.go
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
-   GoToSocial
-   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
-   This program is free software: you can redistribute it and/or modify
-   it under the terms of the GNU Affero General Public License as published by
-   the Free Software Foundation, either version 3 of the License, or
-   (at your option) any later version.
-
-   This program is distributed in the hope that it will be useful,
-   but WITHOUT ANY WARRANTY; without even the implied warranty of
-   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-   GNU Affero General Public License for more details.
-
-   You should have received a copy of the GNU Affero General Public License
-   along with this program.  If not, see .
-*/
-
-package util
-
-import (
-	"fmt"
-
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-)
-
-// URIs contains a bunch of URIs and URLs for a user, host, account, etc.
-type URIs struct {
-	HostURL     string
-	UserURL     string
-	StatusesURL string
-
-	UserURI       string
-	StatusesURI   string
-	InboxURI      string
-	OutboxURI     string
-	FollowersURI  string
-	CollectionURI string
-}
-
-// GenerateURIs throws together a bunch of URIs for the given username, with the given protocol and host.
-func GenerateURIs(username string, protocol string, host string) *URIs {
-	hostURL := fmt.Sprintf("%s://%s", protocol, host)
-	userURL := fmt.Sprintf("%s/@%s", hostURL, username)
-	statusesURL := fmt.Sprintf("%s/statuses", userURL)
-
-	userURI := fmt.Sprintf("%s/users/%s", hostURL, username)
-	statusesURI := fmt.Sprintf("%s/statuses", userURI)
-	inboxURI := fmt.Sprintf("%s/inbox", userURI)
-	outboxURI := fmt.Sprintf("%s/outbox", userURI)
-	followersURI := fmt.Sprintf("%s/followers", userURI)
-	collectionURI := fmt.Sprintf("%s/collections/featured", userURI)
-	return &URIs{
-		HostURL:     hostURL,
-		UserURL:     userURL,
-		StatusesURL: statusesURL,
-
-		UserURI:       userURI,
-		StatusesURI:   statusesURI,
-		InboxURI:      inboxURI,
-		OutboxURI:     outboxURI,
-		FollowersURI:  followersURI,
-		CollectionURI: collectionURI,
-	}
-}
-
-// ParseGTSVisFromMastoVis converts a mastodon visibility into its gts equivalent.
-func ParseGTSVisFromMastoVis(m mastotypes.Visibility) gtsmodel.Visibility {
-	switch m {
-	case mastotypes.VisibilityPublic:
-		return gtsmodel.VisibilityPublic
-	case mastotypes.VisibilityUnlisted:
-		return gtsmodel.VisibilityUnlocked
-	case mastotypes.VisibilityPrivate:
-		return gtsmodel.VisibilityFollowersOnly
-	case mastotypes.VisibilityDirect:
-		return gtsmodel.VisibilityDirect
-	}
-	return ""
-}
-
-// ParseMastoVisFromGTSVis converts a gts visibility into its mastodon equivalent
-func ParseMastoVisFromGTSVis(m gtsmodel.Visibility) mastotypes.Visibility {
-	switch m {
-	case gtsmodel.VisibilityPublic:
-		return mastotypes.VisibilityPublic
-	case gtsmodel.VisibilityUnlocked:
-		return mastotypes.VisibilityUnlisted
-	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
-		return mastotypes.VisibilityPrivate
-	case gtsmodel.VisibilityDirect:
-		return mastotypes.VisibilityDirect
-	}
-	return ""
-}
diff --git a/internal/util/regexes.go b/internal/util/regexes.go
index 60b397d86..a59bd678a 100644
--- a/internal/util/regexes.go
+++ b/internal/util/regexes.go
@@ -18,19 +18,78 @@
 
 package util
 
-import "regexp"
+import (
+	"fmt"
+	"regexp"
+)
+
+const (
+	minimumPasswordEntropy      = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator
+	minimumReasonLength         = 40
+	maximumReasonLength         = 500
+	maximumEmailLength          = 256
+	maximumUsernameLength       = 64
+	maximumPasswordLength       = 64
+	maximumEmojiShortcodeLength = 30
+	maximumHashtagLength        = 30
+)
 
 var (
 	// mention regex can be played around with here: https://regex101.com/r/qwM9D3/1
-	mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)`
-	mentionRegex       = regexp.MustCompile(mentionRegexString)
+	mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)`
+	mentionFinderRegex       = regexp.MustCompile(mentionFinderRegexString)
+
 	// hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1
-	hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)`
-	hashtagRegex       = regexp.MustCompile(hashtagRegexString)
-	// emoji regex can be played with here: https://regex101.com/r/478XGM/1
-	emojiRegexString = `(?: |^|\W)?:([a-zA-Z0-9_]{2,30}):(?:\b|\r)?`
-	emojiRegex       = regexp.MustCompile(emojiRegexString)
+	hashtagFinderRegexString = fmt.Sprintf(`(?: |^|\W)?#([a-zA-Z0-9]{1,%d})(?:\b|\r)`, maximumHashtagLength)
+	hashtagFinderRegex       = regexp.MustCompile(hashtagFinderRegexString)
+
 	// emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1
-	emojiShortcodeString = `^[a-z0-9_]{2,30}$`
-	emojiShortcodeRegex  = regexp.MustCompile(emojiShortcodeString)
+	emojiShortcodeRegexString     = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumEmojiShortcodeLength)
+	emojiShortcodeValidationRegex = regexp.MustCompile(fmt.Sprintf("^%s$", emojiShortcodeRegexString))
+
+	// emoji regex can be played with here: https://regex101.com/r/478XGM/1
+	emojiFinderRegexString = fmt.Sprintf(`(?: |^|\W)?:(%s):(?:\b|\r)?`, emojiShortcodeRegexString)
+	emojiFinderRegex       = regexp.MustCompile(emojiFinderRegexString)
+
+	// usernameRegexString defines an acceptable username on this instance
+	usernameRegexString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength)
+	// usernameValidationRegex can be used to validate usernames of new signups
+	usernameValidationRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameRegexString))
+
+	userPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, UsersPath, usernameRegexString)
+	// userPathRegex parses a path that validates and captures the username part from eg /users/example_username
+	userPathRegex = regexp.MustCompile(userPathRegexString)
+
+	inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath)
+	// inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox
+	inboxPathRegex = regexp.MustCompile(inboxPathRegexString)
+
+	outboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, OutboxPath)
+	// outboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/outbox
+	outboxPathRegex = regexp.MustCompile(outboxPathRegexString)
+
+	actorPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, ActorsPath, usernameRegexString)
+	// actorPathRegex parses a path that validates and captures the username part from eg /actors/example_username
+	actorPathRegex = regexp.MustCompile(actorPathRegexString)
+
+	followersPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowersPath)
+	// followersPathRegex parses a path that validates and captures the username part from eg /users/example_username/followers
+	followersPathRegex = regexp.MustCompile(followersPathRegexString)
+
+	followingPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowingPath)
+	// followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following
+	followingPathRegex = regexp.MustCompile(followingPathRegexString)
+
+	likedPathRegexString = fmt.Sprintf(`^/?%s/%s/%s$`, UsersPath, usernameRegexString, LikedPath)
+	// followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked
+	likedPathRegex = regexp.MustCompile(likedPathRegexString)
+
+	// see https://ihateregex.io/expr/uuid/
+	uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}`
+
+	statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString)
+	// statusesPathRegex parses a path that validates and captures the username part and the uuid part
+	// from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000.
+	// The regex can be played with here: https://regex101.com/r/G9zuxQ/1
+	statusesPathRegex = regexp.MustCompile(statusesPathRegexString)
 )
diff --git a/internal/util/status.go b/internal/util/statustools.go
similarity index 84%
rename from internal/util/status.go
rename to internal/util/statustools.go
index e4b3ec6a5..5591f185a 100644
--- a/internal/util/status.go
+++ b/internal/util/statustools.go
@@ -31,10 +31,10 @@ import (
 // The case of the returned mentions will be lowered, for consistency.
 func DeriveMentions(status string) []string {
 	mentionedAccounts := []string{}
-	for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) {
+	for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) {
 		mentionedAccounts = append(mentionedAccounts, m[1])
 	}
-	return Lower(Unique(mentionedAccounts))
+	return lower(unique(mentionedAccounts))
 }
 
 // DeriveHashtags takes a plaintext (ie., not html-formatted) status,
@@ -43,10 +43,10 @@ func DeriveMentions(status string) []string {
 // tags will be lowered, for consistency.
 func DeriveHashtags(status string) []string {
 	tags := []string{}
-	for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) {
+	for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) {
 		tags = append(tags, m[1])
 	}
-	return Lower(Unique(tags))
+	return lower(unique(tags))
 }
 
 // DeriveEmojis takes a plaintext (ie., not html-formatted) status,
@@ -55,14 +55,14 @@ func DeriveHashtags(status string) []string {
 // emojis will be lowered, for consistency.
 func DeriveEmojis(status string) []string {
 	emojis := []string{}
-	for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) {
+	for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) {
 		emojis = append(emojis, m[1])
 	}
-	return Lower(Unique(emojis))
+	return lower(unique(emojis))
 }
 
-// Unique returns a deduplicated version of a given string slice.
-func Unique(s []string) []string {
+// unique returns a deduplicated version of a given string slice.
+func unique(s []string) []string {
 	keys := make(map[string]bool)
 	list := []string{}
 	for _, entry := range s {
@@ -74,8 +74,8 @@ func Unique(s []string) []string {
 	return list
 }
 
-// Lower lowercases all strings in a given string slice
-func Lower(s []string) []string {
+// lower lowercases all strings in a given string slice
+func lower(s []string) []string {
 	new := []string{}
 	for _, i := range s {
 		new = append(new, strings.ToLower(i))
diff --git a/internal/util/status_test.go b/internal/util/statustools_test.go
similarity index 91%
rename from internal/util/status_test.go
rename to internal/util/statustools_test.go
index 72bd3e885..7c9af2cbd 100644
--- a/internal/util/status_test.go
+++ b/internal/util/statustools_test.go
@@ -16,13 +16,14 @@
    along with this program.  If not, see .
 */
 
-package util
+package util_test
 
 import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 type StatusTestSuite struct {
@@ -41,7 +42,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() {
 	here is a duplicate mention: @hello@test.lgbt
 	`
 
-	menchies := DeriveMentions(statusText)
+	menchies := util.DeriveMentions(statusText)
 	assert.Len(suite.T(), menchies, 4)
 	assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0])
 	assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1])
@@ -51,7 +52,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() {
 
 func (suite *StatusTestSuite) TestDeriveMentionsEmpty() {
 	statusText := ``
-	menchies := DeriveMentions(statusText)
+	menchies := util.DeriveMentions(statusText)
 	assert.Len(suite.T(), menchies, 0)
 }
 
@@ -66,7 +67,7 @@ func (suite *StatusTestSuite) TestDeriveHashtagsOK() {
 
 #111111 thisalsoshouldn'twork#### ##`
 
-	tags := DeriveHashtags(statusText)
+	tags := util.DeriveHashtags(statusText)
 	assert.Len(suite.T(), tags, 5)
 	assert.Equal(suite.T(), "testing123", tags[0])
 	assert.Equal(suite.T(), "also", tags[1])
@@ -89,7 +90,7 @@ Here's some normal text with an :emoji: at the end
 :underscores_ok_too:
 `
 
-	tags := DeriveEmojis(statusText)
+	tags := util.DeriveEmojis(statusText)
 	assert.Len(suite.T(), tags, 7)
 	assert.Equal(suite.T(), "test", tags[0])
 	assert.Equal(suite.T(), "another", tags[1])
diff --git a/internal/util/uri.go b/internal/util/uri.go
new file mode 100644
index 000000000..9b96edc61
--- /dev/null
+++ b/internal/util/uri.go
@@ -0,0 +1,218 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package util
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+)
+
+const (
+	// UsersPath is for serving users info
+	UsersPath = "users"
+	// ActorsPath is for serving actors info
+	ActorsPath = "actors"
+	// StatusesPath is for serving statuses
+	StatusesPath = "statuses"
+	// InboxPath represents the webfinger inbox location
+	InboxPath = "inbox"
+	// OutboxPath represents the webfinger outbox location
+	OutboxPath = "outbox"
+	// FollowersPath represents the webfinger followers location
+	FollowersPath = "followers"
+	// FollowingPath represents the webfinger following location
+	FollowingPath = "following"
+	// LikedPath represents the webfinger liked location
+	LikedPath = "liked"
+	// CollectionsPath represents the webfinger collections location
+	CollectionsPath = "collections"
+	// FeaturedPath represents the webfinger featured location
+	FeaturedPath = "featured"
+	// PublicKeyPath is for serving an account's public key
+	PublicKeyPath = "publickey"
+)
+
+// APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains
+type APContextKey string
+
+const (
+	// APActivity can be used to set and retrieve the actual go-fed pub.Activity within a context.
+	APActivity APContextKey = "activity"
+	// APAccount can be used the set and retrieve the account being interacted with
+	APAccount APContextKey = "account"
+	// APRequestingAccount can be used to set and retrieve the account of an incoming federation request.
+	APRequestingAccount APContextKey = "requestingAccount"
+	// APRequestingPublicKeyID can be used to set and retrieve the public key ID of an incoming federation request.
+	APRequestingPublicKeyID APContextKey = "requestingPublicKeyID"
+)
+
+type ginContextKey struct{}
+
+// GinContextKey is used solely for setting and retrieving the gin context from a context.Context
+var GinContextKey = &ginContextKey{}
+
+// UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc.
+type UserURIs struct {
+	// The web URL of the instance host, eg https://example.org
+	HostURL string
+	// The web URL of the user, eg., https://example.org/@example_user
+	UserURL string
+	// The web URL for statuses of this user, eg., https://example.org/@example_user/statuses
+	StatusesURL string
+
+	// The webfinger URI of this user, eg., https://example.org/users/example_user
+	UserURI string
+	// The webfinger URI for this user's statuses, eg., https://example.org/users/example_user/statuses
+	StatusesURI string
+	// The webfinger URI for this user's activitypub inbox, eg., https://example.org/users/example_user/inbox
+	InboxURI string
+	// The webfinger URI for this user's activitypub outbox, eg., https://example.org/users/example_user/outbox
+	OutboxURI string
+	// The webfinger URI for this user's followers, eg., https://example.org/users/example_user/followers
+	FollowersURI string
+	// The webfinger URI for this user's following, eg., https://example.org/users/example_user/following
+	FollowingURI string
+	// The webfinger URI for this user's liked posts eg., https://example.org/users/example_user/liked
+	LikedURI string
+	// The webfinger URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured
+	CollectionURI string
+	// The URI for this user's public key, eg., https://example.org/users/example_user/publickey
+	PublicKeyURI string
+}
+
+// GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host.
+func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs {
+	// The below URLs are used for serving web requests
+	hostURL := fmt.Sprintf("%s://%s", protocol, host)
+	userURL := fmt.Sprintf("%s/@%s", hostURL, username)
+	statusesURL := fmt.Sprintf("%s/%s", userURL, StatusesPath)
+
+	// the below URIs are used in ActivityPub and Webfinger
+	userURI := fmt.Sprintf("%s/%s/%s", hostURL, UsersPath, username)
+	statusesURI := fmt.Sprintf("%s/%s", userURI, StatusesPath)
+	inboxURI := fmt.Sprintf("%s/%s", userURI, InboxPath)
+	outboxURI := fmt.Sprintf("%s/%s", userURI, OutboxPath)
+	followersURI := fmt.Sprintf("%s/%s", userURI, FollowersPath)
+	followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath)
+	likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath)
+	collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath)
+	publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath)
+
+	return &UserURIs{
+		HostURL:     hostURL,
+		UserURL:     userURL,
+		StatusesURL: statusesURL,
+
+		UserURI:       userURI,
+		StatusesURI:   statusesURI,
+		InboxURI:      inboxURI,
+		OutboxURI:     outboxURI,
+		FollowersURI:  followersURI,
+		FollowingURI:  followingURI,
+		LikedURI:      likedURI,
+		CollectionURI: collectionURI,
+		PublicKeyURI:  publicKeyURI,
+	}
+}
+
+// IsUserPath returns true if the given URL path corresponds to eg /users/example_username
+func IsUserPath(id *url.URL) bool {
+	return userPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsInboxPath returns true if the given URL path corresponds to eg /users/example_username/inbox
+func IsInboxPath(id *url.URL) bool {
+	return inboxPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsOutboxPath returns true if the given URL path corresponds to eg /users/example_username/outbox
+func IsOutboxPath(id *url.URL) bool {
+	return outboxPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsInstanceActorPath returns true if the given URL path corresponds to eg /actors/example_username
+func IsInstanceActorPath(id *url.URL) bool {
+	return actorPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers
+func IsFollowersPath(id *url.URL) bool {
+	return followersPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsFollowingPath returns true if the given URL path corresponds to eg /users/example_username/following
+func IsFollowingPath(id *url.URL) bool {
+	return followingPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked
+func IsLikedPath(id *url.URL) bool {
+	return likedPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+func IsStatusesPath(id *url.URL) bool {
+	return statusesPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// ParseStatusesPath returns the username and uuid from a path such as /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+func ParseStatusesPath(id *url.URL) (username string, uuid string, err error) {
+	matches := statusesPathRegex.FindStringSubmatch(id.Path)
+	if len(matches) != 3 {
+		err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches))
+		return
+	}
+	username = matches[1]
+	uuid = matches[2]
+	return
+}
+
+// ParseUserPath returns the username from a path such as /users/example_username
+func ParseUserPath(id *url.URL) (username string, err error) {
+	matches := userPathRegex.FindStringSubmatch(id.Path)
+	if len(matches) != 2 {
+		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches))
+		return
+	}
+	username = matches[1]
+	return
+}
+
+// ParseInboxPath returns the username from a path such as /users/example_username/inbox
+func ParseInboxPath(id *url.URL) (username string, err error) {
+	matches := inboxPathRegex.FindStringSubmatch(id.Path)
+	if len(matches) != 2 {
+		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches))
+		return
+	}
+	username = matches[1]
+	return
+}
+
+// ParseOutboxPath returns the username from a path such as /users/example_username/outbox
+func ParseOutboxPath(id *url.URL) (username string, err error) {
+	matches := outboxPathRegex.FindStringSubmatch(id.Path)
+	if len(matches) != 2 {
+		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches))
+		return
+	}
+	username = matches[1]
+	return
+}
diff --git a/internal/util/validation.go b/internal/util/validation.go
index acf0e68cd..d392231bb 100644
--- a/internal/util/validation.go
+++ b/internal/util/validation.go
@@ -22,45 +22,22 @@ import (
 	"errors"
 	"fmt"
 	"net/mail"
-	"regexp"
 
 	pwv "github.com/wagslane/go-password-validator"
 	"golang.org/x/text/language"
 )
 
-const (
-	// MinimumPasswordEntropy dictates password strength. See https://github.com/wagslane/go-password-validator
-	MinimumPasswordEntropy = 60
-	// MinimumReasonLength is the length of chars we expect as a bare minimum effort
-	MinimumReasonLength = 40
-	// MaximumReasonLength is the maximum amount of chars we're happy to accept
-	MaximumReasonLength = 500
-	// MaximumEmailLength is the maximum length of an email address we're happy to accept
-	MaximumEmailLength = 256
-	// MaximumUsernameLength is the maximum length of a username we're happy to accept
-	MaximumUsernameLength = 64
-	// MaximumPasswordLength is the maximum length of a password we're happy to accept
-	MaximumPasswordLength = 64
-	// NewUsernameRegexString is string representation of the regular expression for validating usernames
-	NewUsernameRegexString = `^[a-z0-9_]+$`
-)
-
-var (
-	// NewUsernameRegex is the compiled regex for validating new usernames
-	NewUsernameRegex = regexp.MustCompile(NewUsernameRegexString)
-)
-
 // ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok.
 func ValidateNewPassword(password string) error {
 	if password == "" {
 		return errors.New("no password provided")
 	}
 
-	if len(password) > MaximumPasswordLength {
-		return fmt.Errorf("password should be no more than %d chars", MaximumPasswordLength)
+	if len(password) > maximumPasswordLength {
+		return fmt.Errorf("password should be no more than %d chars", maximumPasswordLength)
 	}
 
-	return pwv.Validate(password, MinimumPasswordEntropy)
+	return pwv.Validate(password, minimumPasswordEntropy)
 }
 
 // ValidateUsername makes sure that a given username is valid (ie., letters, numbers, underscores, check length).
@@ -70,11 +47,11 @@ func ValidateUsername(username string) error {
 		return errors.New("no username provided")
 	}
 
-	if len(username) > MaximumUsernameLength {
-		return fmt.Errorf("username should be no more than %d chars but '%s' was %d", MaximumUsernameLength, username, len(username))
+	if len(username) > maximumUsernameLength {
+		return fmt.Errorf("username should be no more than %d chars but '%s' was %d", maximumUsernameLength, username, len(username))
 	}
 
-	if !NewUsernameRegex.MatchString(username) {
+	if !usernameValidationRegex.MatchString(username) {
 		return fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", username)
 	}
 
@@ -88,8 +65,8 @@ func ValidateEmail(email string) error {
 		return errors.New("no email provided")
 	}
 
-	if len(email) > MaximumEmailLength {
-		return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", MaximumEmailLength, email, len(email))
+	if len(email) > maximumEmailLength {
+		return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", maximumEmailLength, email, len(email))
 	}
 
 	_, err := mail.ParseAddress(email)
@@ -118,12 +95,12 @@ func ValidateSignUpReason(reason string, reasonRequired bool) error {
 		return errors.New("no reason provided")
 	}
 
-	if len(reason) < MinimumReasonLength {
-		return fmt.Errorf("reason should be at least %d chars but '%s' was %d", MinimumReasonLength, reason, len(reason))
+	if len(reason) < minimumReasonLength {
+		return fmt.Errorf("reason should be at least %d chars but '%s' was %d", minimumReasonLength, reason, len(reason))
 	}
 
-	if len(reason) > MaximumReasonLength {
-		return fmt.Errorf("reason should be no more than %d chars but given reason was %d", MaximumReasonLength, len(reason))
+	if len(reason) > maximumReasonLength {
+		return fmt.Errorf("reason should be no more than %d chars but given reason was %d", maximumReasonLength, len(reason))
 	}
 	return nil
 }
@@ -150,7 +127,7 @@ func ValidatePrivacy(privacy string) error {
 // for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters,
 // lowercase a-z, numbers, and underscores.
 func ValidateEmojiShortcode(shortcode string) error {
-	if !emojiShortcodeRegex.MatchString(shortcode) {
+	if !emojiShortcodeValidationRegex.MatchString(shortcode) {
 		return fmt.Errorf("shortcode %s did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only", shortcode)
 	}
 	return nil
diff --git a/internal/util/validation_test.go b/internal/util/validation_test.go
index dbac5e248..73f5cb977 100644
--- a/internal/util/validation_test.go
+++ b/internal/util/validation_test.go
@@ -16,7 +16,7 @@
    along with this program.  If not, see .
 */
 
-package util
+package util_test
 
 import (
 	"errors"
@@ -25,6 +25,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 type ValidationTestSuite struct {
@@ -42,42 +43,42 @@ func (suite *ValidationTestSuite) TestCheckPasswordStrength() {
 	strongPassword := "3dX5@Zc%mV*W2MBNEy$@"
 	var err error
 
-	err = ValidateNewPassword(empty)
+	err = util.ValidateNewPassword(empty)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("no password provided"), err)
 	}
 
-	err = ValidateNewPassword(terriblePassword)
+	err = util.ValidateNewPassword(terriblePassword)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"), err)
 	}
 
-	err = ValidateNewPassword(weakPassword)
+	err = util.ValidateNewPassword(weakPassword)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using numbers or using a longer password"), err)
 	}
 
-	err = ValidateNewPassword(shortPassword)
+	err = util.ValidateNewPassword(shortPassword)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err)
 	}
 
-	err = ValidateNewPassword(specialPassword)
+	err = util.ValidateNewPassword(specialPassword)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err)
 	}
 
-	err = ValidateNewPassword(longPassword)
+	err = util.ValidateNewPassword(longPassword)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateNewPassword(tooLong)
+	err = util.ValidateNewPassword(tooLong)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("password should be no more than 64 chars"), err)
 	}
 
-	err = ValidateNewPassword(strongPassword)
+	err = util.ValidateNewPassword(strongPassword)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
@@ -94,42 +95,42 @@ func (suite *ValidationTestSuite) TestValidateUsername() {
 	goodUsername := "this_is_a_good_username"
 	var err error
 
-	err = ValidateUsername(empty)
+	err = util.ValidateUsername(empty)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("no username provided"), err)
 	}
 
-	err = ValidateUsername(tooLong)
+	err = util.ValidateUsername(tooLong)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("username should be no more than 64 chars but '%s' was 66", tooLong), err)
 	}
 
-	err = ValidateUsername(withSpaces)
+	err = util.ValidateUsername(withSpaces)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", withSpaces), err)
 	}
 
-	err = ValidateUsername(weirdChars)
+	err = util.ValidateUsername(weirdChars)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", weirdChars), err)
 	}
 
-	err = ValidateUsername(leadingSpace)
+	err = util.ValidateUsername(leadingSpace)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", leadingSpace), err)
 	}
 
-	err = ValidateUsername(trailingSpace)
+	err = util.ValidateUsername(trailingSpace)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", trailingSpace), err)
 	}
 
-	err = ValidateUsername(newlines)
+	err = util.ValidateUsername(newlines)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", newlines), err)
 	}
 
-	err = ValidateUsername(goodUsername)
+	err = util.ValidateUsername(goodUsername)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
@@ -144,32 +145,32 @@ func (suite *ValidationTestSuite) TestValidateEmail() {
 	emailAddress := "thisis.actually@anemail.address"
 	var err error
 
-	err = ValidateEmail(empty)
+	err = util.ValidateEmail(empty)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("no email provided"), err)
 	}
 
-	err = ValidateEmail(notAnEmailAddress)
+	err = util.ValidateEmail(notAnEmailAddress)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err)
 	}
 
-	err = ValidateEmail(almostAnEmailAddress)
+	err = util.ValidateEmail(almostAnEmailAddress)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("mail: no angle-addr"), err)
 	}
 
-	err = ValidateEmail(aWebsite)
+	err = util.ValidateEmail(aWebsite)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err)
 	}
 
-	err = ValidateEmail(tooLong)
+	err = util.ValidateEmail(tooLong)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), fmt.Errorf("email address should be no more than 256 chars but '%s' was 286", tooLong), err)
 	}
 
-	err = ValidateEmail(emailAddress)
+	err = util.ValidateEmail(emailAddress)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
@@ -187,47 +188,47 @@ func (suite *ValidationTestSuite) TestValidateLanguage() {
 	german := "de"
 	var err error
 
-	err = ValidateLanguage(empty)
+	err = util.ValidateLanguage(empty)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("no language provided"), err)
 	}
 
-	err = ValidateLanguage(notALanguage)
+	err = util.ValidateLanguage(notALanguage)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err)
 	}
 
-	err = ValidateLanguage(english)
+	err = util.ValidateLanguage(english)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateLanguage(capitalEnglish)
+	err = util.ValidateLanguage(capitalEnglish)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateLanguage(arabic3Letters)
+	err = util.ValidateLanguage(arabic3Letters)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateLanguage(mixedCapsEnglish)
+	err = util.ValidateLanguage(mixedCapsEnglish)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateLanguage(englishUS)
+	err = util.ValidateLanguage(englishUS)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err)
 	}
 
-	err = ValidateLanguage(dutch)
+	err = util.ValidateLanguage(dutch)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateLanguage(german)
+	err = util.ValidateLanguage(german)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
@@ -241,43 +242,43 @@ func (suite *ValidationTestSuite) TestValidateReason() {
 	var err error
 
 	// check with no reason required
-	err = ValidateSignUpReason(empty, false)
+	err = util.ValidateSignUpReason(empty, false)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateSignUpReason(badReason, false)
+	err = util.ValidateSignUpReason(badReason, false)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateSignUpReason(tooLong, false)
+	err = util.ValidateSignUpReason(tooLong, false)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
-	err = ValidateSignUpReason(goodReason, false)
+	err = util.ValidateSignUpReason(goodReason, false)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
 
 	// check with reason required
-	err = ValidateSignUpReason(empty, true)
+	err = util.ValidateSignUpReason(empty, true)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("no reason provided"), err)
 	}
 
-	err = ValidateSignUpReason(badReason, true)
+	err = util.ValidateSignUpReason(badReason, true)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("reason should be at least 40 chars but 'because' was 7"), err)
 	}
 
-	err = ValidateSignUpReason(tooLong, true)
+	err = util.ValidateSignUpReason(tooLong, true)
 	if assert.Error(suite.T(), err) {
 		assert.Equal(suite.T(), errors.New("reason should be no more than 500 chars but given reason was 600"), err)
 	}
 
-	err = ValidateSignUpReason(goodReason, true)
+	err = util.ValidateSignUpReason(goodReason, true)
 	if assert.NoError(suite.T(), err) {
 		assert.Equal(suite.T(), nil, err)
 	}
diff --git a/testrig/actions.go b/testrig/actions.go
index 1caa18581..7ed75b18f 100644
--- a/testrig/actions.go
+++ b/testrig/actions.go
@@ -19,24 +19,26 @@
 package testrig
 
 import (
+	"bytes"
 	"context"
 	"fmt"
+	"io/ioutil"
+	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
 
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/action"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/admin"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/app"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/auth"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
-	mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/security"
-	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
-	"github.com/superseriousbusiness/gotosocial/internal/cache"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/app"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/auth"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
+	mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/security"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/federation"
 	"github.com/superseriousbusiness/gotosocial/internal/gotosocial"
@@ -44,33 +46,39 @@ import (
 
 // Run creates and starts a gotosocial testrig server
 var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error {
+	c := NewTestConfig()
 	dbService := NewTestDB()
 	router := NewTestRouter()
 	storageBackend := NewTestStorage()
-	mediaHandler := NewTestMediaHandler(dbService, storageBackend)
-	oauthServer := NewTestOauthServer(dbService)
-	distributor := NewTestDistributor()
-	if err := distributor.Start(); err != nil {
-		return fmt.Errorf("error starting distributor: %s", err)
-	}
-	mastoConverter := NewTestMastoConverter(dbService)
 
-	c := NewTestConfig()
+	typeConverter := NewTestTypeConverter(dbService)
+	transportController := NewTestTransportController(NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+		r := ioutil.NopCloser(bytes.NewReader([]byte{}))
+		return &http.Response{
+			StatusCode: 200,
+			Body:       r,
+		}, nil
+	}))
+	federator := federation.NewFederator(dbService, transportController, c, log, typeConverter)
+	processor := NewTestProcessor(dbService, storageBackend, federator)
+	if err := processor.Start(); err != nil {
+		return fmt.Errorf("error starting processor: %s", err)
+	}
 
 	StandardDBSetup(dbService)
 	StandardStorageSetup(storageBackend, "./testrig/media")
 
 	// build client api modules
-	authModule := auth.New(oauthServer, dbService, log)
-	accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log)
-	appsModule := app.New(oauthServer, dbService, mastoConverter, log)
-	mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log)
-	fileServerModule := fileserver.New(c, dbService, storageBackend, log)
-	adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log)
-	statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log)
+	authModule := auth.New(c, dbService, NewTestOauthServer(dbService), log)
+	accountModule := account.New(c, processor, log)
+	appsModule := app.New(c, processor, log)
+	mm := mediaModule.New(c, processor, log)
+	fileServerModule := fileserver.New(c, processor, log)
+	adminModule := admin.New(c, processor, log)
+	statusModule := status.New(c, processor, log)
 	securityModule := security.New(c, log)
 
-	apiModules := []apimodule.ClientAPIModule{
+	apis := []api.ClientModule{
 		// modules with middleware go first
 		securityModule,
 		authModule,
@@ -84,20 +92,13 @@ var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logr
 		statusModule,
 	}
 
-	for _, m := range apiModules {
+	for _, m := range apis {
 		if err := m.Route(router); err != nil {
 			return fmt.Errorf("routing error: %s", err)
 		}
-		if err := m.CreateTables(dbService); err != nil {
-			return fmt.Errorf("table creation error: %s", err)
-		}
 	}
 
-	// if err := dbService.CreateInstanceAccount(); err != nil {
-	// 	return fmt.Errorf("error creating instance account: %s", err)
-	// }
-
-	gts, err := gotosocial.New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c)
+	gts, err := gotosocial.New(dbService, router, federator, c)
 	if err != nil {
 		return fmt.Errorf("error creating gotosocial service: %s", err)
 	}
diff --git a/testrig/db.go b/testrig/db.go
index 5974eae69..4d22ab3c8 100644
--- a/testrig/db.go
+++ b/testrig/db.go
@@ -23,7 +23,7 @@ import (
 
 	"github.com/sirupsen/logrus"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 )
 
@@ -54,7 +54,7 @@ func NewTestDB() db.DB {
 	config := NewTestConfig()
 	l := logrus.New()
 	l.SetLevel(logrus.TraceLevel)
-	testDB, err := db.New(context.Background(), config, l)
+	testDB, err := db.NewPostgresService(context.Background(), config, l)
 	if err != nil {
 		panic(err)
 	}
diff --git a/testrig/federator.go b/testrig/federator.go
new file mode 100644
index 000000000..63ad520db
--- /dev/null
+++ b/testrig/federator.go
@@ -0,0 +1,29 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package testrig
+
+import (
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/transport"
+)
+
+func NewTestFederator(db db.DB, tc transport.Controller) federation.Federator {
+	return federation.NewFederator(db, tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db))
+}
diff --git a/testrig/media/test-jpeg.jpg b/testrig/media/test-jpeg.jpg
new file mode 100644
index 000000000..a9ab154d4
Binary files /dev/null and b/testrig/media/test-jpeg.jpg differ
diff --git a/testrig/processor.go b/testrig/processor.go
new file mode 100644
index 000000000..9aa8e2509
--- /dev/null
+++ b/testrig/processor.go
@@ -0,0 +1,31 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package testrig
+
+import (
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
+	"github.com/superseriousbusiness/gotosocial/internal/storage"
+)
+
+// NewTestProcessor returns a Processor suitable for testing purposes
+func NewTestProcessor(db db.DB, storage storage.Storage, federator federation.Federator) message.Processor {
+	return message.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, db, NewTestLog())
+}
diff --git a/testrig/testmodels.go b/testrig/testmodels.go
index 0d95ef21d..e550c66f7 100644
--- a/testrig/testmodels.go
+++ b/testrig/testmodels.go
@@ -19,13 +19,26 @@
 package testrig
 
 import (
+	"bytes"
+	"context"
+	"crypto"
 	"crypto/rand"
 	"crypto/rsa"
+	"crypto/x509"
+	"encoding/json"
+	"encoding/pem"
+	"io/ioutil"
 	"net"
+	"net/http"
+	"net/url"
 	"time"
 
-	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+	"github.com/go-fed/activity/pub"
+	"github.com/go-fed/activity/streams"
+	"github.com/go-fed/activity/streams/vocab"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 )
 
 // NewTestTokens returns a map of tokens keyed according to which account the token belongs to.
@@ -274,15 +287,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
 			URI:                     "http://localhost:8080/users/weed_lord420",
 			URL:                     "http://localhost:8080/@weed_lord420",
 			LastWebfingeredAt:       time.Time{},
-			InboxURL:                "http://localhost:8080/users/weed_lord420/inbox",
-			OutboxURL:               "http://localhost:8080/users/weed_lord420/outbox",
-			SharedInboxURL:          "",
-			FollowersURL:            "http://localhost:8080/users/weed_lord420/followers",
-			FeaturedCollectionURL:   "http://localhost:8080/users/weed_lord420/collections/featured",
+			InboxURI:                "http://localhost:8080/users/weed_lord420/inbox",
+			OutboxURI:               "http://localhost:8080/users/weed_lord420/outbox",
+			FollowersURI:            "http://localhost:8080/users/weed_lord420/followers",
+			FollowingURI:            "http://localhost:8080/users/weed_lord420/following",
+			FeaturedCollectionURI:   "http://localhost:8080/users/weed_lord420/collections/featured",
 			ActorType:               gtsmodel.ActivityStreamsPerson,
 			AlsoKnownAs:             "",
 			PrivateKey:              &rsa.PrivateKey{},
 			PublicKey:               &rsa.PublicKey{},
+			PublicKeyURI:            "http://localhost:8080/users/weed_lord420#main-key",
 			SensitizedAt:            time.Time{},
 			SilencedAt:              time.Time{},
 			SuspendedAt:             time.Time{},
@@ -310,12 +324,13 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
 			Language:                "en",
 			URI:                     "http://localhost:8080/users/admin",
 			URL:                     "http://localhost:8080/@admin",
+			PublicKeyURI:            "http://localhost:8080/users/admin#main-key",
 			LastWebfingeredAt:       time.Time{},
-			InboxURL:                "http://localhost:8080/users/admin/inbox",
-			OutboxURL:               "http://localhost:8080/users/admin/outbox",
-			SharedInboxURL:          "",
-			FollowersURL:            "http://localhost:8080/users/admin/followers",
-			FeaturedCollectionURL:   "http://localhost:8080/users/admin/collections/featured",
+			InboxURI:                "http://localhost:8080/users/admin/inbox",
+			OutboxURI:               "http://localhost:8080/users/admin/outbox",
+			FollowersURI:            "http://localhost:8080/users/admin/followers",
+			FollowingURI:            "http://localhost:8080/users/admin/following",
+			FeaturedCollectionURI:   "http://localhost:8080/users/admin/collections/featured",
 			ActorType:               gtsmodel.ActivityStreamsPerson,
 			AlsoKnownAs:             "",
 			PrivateKey:              &rsa.PrivateKey{},
@@ -348,15 +363,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
 			URI:                     "http://localhost:8080/users/the_mighty_zork",
 			URL:                     "http://localhost:8080/@the_mighty_zork",
 			LastWebfingeredAt:       time.Time{},
-			InboxURL:                "http://localhost:8080/users/the_mighty_zork/inbox",
-			OutboxURL:               "http://localhost:8080/users/the_mighty_zork/outbox",
-			SharedInboxURL:          "",
-			FollowersURL:            "http://localhost:8080/users/the_mighty_zork/followers",
-			FeaturedCollectionURL:   "http://localhost:8080/users/the_mighty_zork/collections/featured",
+			InboxURI:                "http://localhost:8080/users/the_mighty_zork/inbox",
+			OutboxURI:               "http://localhost:8080/users/the_mighty_zork/outbox",
+			FollowersURI:            "http://localhost:8080/users/the_mighty_zork/followers",
+			FollowingURI:            "http://localhost:8080/users/the_mighty_zork/following",
+			FeaturedCollectionURI:   "http://localhost:8080/users/the_mighty_zork/collections/featured",
 			ActorType:               gtsmodel.ActivityStreamsPerson,
 			AlsoKnownAs:             "",
 			PrivateKey:              &rsa.PrivateKey{},
 			PublicKey:               &rsa.PublicKey{},
+			PublicKeyURI:            "http://localhost:8080/users/the_mighty_zork#main-key",
 			SensitizedAt:            time.Time{},
 			SilencedAt:              time.Time{},
 			SuspendedAt:             time.Time{},
@@ -385,15 +401,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
 			URI:                     "http://localhost:8080/users/1happyturtle",
 			URL:                     "http://localhost:8080/@1happyturtle",
 			LastWebfingeredAt:       time.Time{},
-			InboxURL:                "http://localhost:8080/users/1happyturtle/inbox",
-			OutboxURL:               "http://localhost:8080/users/1happyturtle/outbox",
-			SharedInboxURL:          "",
-			FollowersURL:            "http://localhost:8080/users/1happyturtle/followers",
-			FeaturedCollectionURL:   "http://localhost:8080/users/1happyturtle/collections/featured",
+			InboxURI:                "http://localhost:8080/users/1happyturtle/inbox",
+			OutboxURI:               "http://localhost:8080/users/1happyturtle/outbox",
+			FollowersURI:            "http://localhost:8080/users/1happyturtle/followers",
+			FollowingURI:            "http://localhost:8080/users/1happyturtle/following",
+			FeaturedCollectionURI:   "http://localhost:8080/users/1happyturtle/collections/featured",
 			ActorType:               gtsmodel.ActivityStreamsPerson,
 			AlsoKnownAs:             "",
 			PrivateKey:              &rsa.PrivateKey{},
 			PublicKey:               &rsa.PublicKey{},
+			PublicKeyURI:            "http://localhost:8080/users/1happyturtle#main-key",
 			SensitizedAt:            time.Time{},
 			SilencedAt:              time.Time{},
 			SuspendedAt:             time.Time{},
@@ -426,18 +443,19 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
 			Discoverable:          true,
 			Sensitive:             false,
 			Language:              "en",
-			URI:                   "https://fossbros-anonymous.io/users/foss_satan",
-			URL:                   "https://fossbros-anonymous.io/@foss_satan",
+			URI:                   "http://fossbros-anonymous.io/users/foss_satan",
+			URL:                   "http://fossbros-anonymous.io/@foss_satan",
 			LastWebfingeredAt:     time.Time{},
-			InboxURL:              "https://fossbros-anonymous.io/users/foss_satan/inbox",
-			OutboxURL:             "https://fossbros-anonymous.io/users/foss_satan/outbox",
-			SharedInboxURL:        "",
-			FollowersURL:          "https://fossbros-anonymous.io/users/foss_satan/followers",
-			FeaturedCollectionURL: "https://fossbros-anonymous.io/users/foss_satan/collections/featured",
+			InboxURI:              "http://fossbros-anonymous.io/users/foss_satan/inbox",
+			OutboxURI:             "http://fossbros-anonymous.io/users/foss_satan/outbox",
+			FollowersURI:          "http://fossbros-anonymous.io/users/foss_satan/followers",
+			FollowingURI:          "http://fossbros-anonymous.io/users/foss_satan/following",
+			FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured",
 			ActorType:             gtsmodel.ActivityStreamsPerson,
 			AlsoKnownAs:           "",
-			PrivateKey:            &rsa.PrivateKey{},
-			PublicKey:             nil,
+			PrivateKey:            nil,
+			PublicKey:             &rsa.PublicKey{},
+			PublicKeyURI:          "http://fossbros-anonymous.io/users/foss_satan#main-key",
 			SensitizedAt:          time.Time{},
 			SilencedAt:            time.Time{},
 			SuspendedAt:           time.Time{},
@@ -468,10 +486,10 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
 		}
 		pub := &priv.PublicKey
 
-		// only local accounts get a private key
-		if v.Domain == "" {
-			v.PrivateKey = priv
-		}
+		// normally only local accounts get a private key (obviously)
+		// but for testing purposes and signing requests, we'll give
+		// remote accounts a private key as well
+		v.PrivateKey = priv
 		v.PublicKey = pub
 	}
 	return accounts
@@ -676,25 +694,26 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
 func NewTestEmojis() map[string]*gtsmodel.Emoji {
 	return map[string]*gtsmodel.Emoji{
 		"rainbow": {
-			ID:                   "a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
-			Shortcode:            "rainbow",
-			Domain:               "",
-			CreatedAt:            time.Now(),
-			UpdatedAt:            time.Now(),
-			ImageRemoteURL:       "",
-			ImageStaticRemoteURL: "",
-			ImageURL:             "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
-			ImagePath:            "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
-			ImageStaticURL:       "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
-			ImageStaticPath:      "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
-			ImageContentType:     "image/png",
-			ImageFileSize:        36702,
-			ImageStaticFileSize:  10413,
-			ImageUpdatedAt:       time.Now(),
-			Disabled:             false,
-			URI:                  "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
-			VisibleInPicker:      true,
-			CategoryID:           "",
+			ID:                     "a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
+			Shortcode:              "rainbow",
+			Domain:                 "",
+			CreatedAt:              time.Now(),
+			UpdatedAt:              time.Now(),
+			ImageRemoteURL:         "",
+			ImageStaticRemoteURL:   "",
+			ImageURL:               "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+			ImagePath:              "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+			ImageStaticURL:         "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+			ImageStaticPath:        "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+			ImageContentType:       "image/png",
+			ImageStaticContentType: "image/png",
+			ImageFileSize:          36702,
+			ImageStaticFileSize:    10413,
+			ImageUpdatedAt:         time.Now(),
+			Disabled:               false,
+			URI:                    "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
+			VisibleInPicker:        true,
+			CategoryID:             "",
 		},
 	}
 }
@@ -993,3 +1012,436 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave {
 		},
 	}
 }
+
+type ActivityWithSignature struct {
+	Activity        pub.Activity
+	SignatureHeader string
+	DigestHeader    string
+	DateHeader      string
+}
+
+// NewTestActivities returns a bunch of pub.Activity types for use in testing the federation protocols.
+// A struct of accounts needs to be passed in because the activities will also be bundled along with
+// their requesting signatures.
+func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature {
+	dmForZork := newNote(
+		URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6"),
+		URLMustParse("https://fossbros-anonymous.io/@foss_satan/5424b153-4553-4f30-9358-7b92f7cd42f6"),
+		"hey zork here's a new private note for you",
+		"new note for zork",
+		URLMustParse("https://fossbros-anonymous.io/users/foss_satan"),
+		[]*url.URL{URLMustParse("http://localhost:8080/users/the_mighty_zork")},
+		nil,
+		true)
+	createDmForZork := wrapNoteInCreate(
+		URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6/activity"),
+		URLMustParse("https://fossbros-anonymous.io/users/foss_satan"),
+		time.Now(),
+		dmForZork)
+	sig, digest, date := getSignatureForActivity(createDmForZork, accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].InboxURI))
+
+	return map[string]ActivityWithSignature{
+		"dm_for_zork": {
+			Activity:        createDmForZork,
+			SignatureHeader: sig,
+			DigestHeader:    digest,
+			DateHeader:      date,
+		},
+	}
+}
+
+// NewTestFediPeople returns a bunch of activity pub Person representations for testing converters and so on.
+func NewTestFediPeople() map[string]typeutils.Accountable {
+	new_person_1priv, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		panic(err)
+	}
+	new_person_1pub := &new_person_1priv.PublicKey
+
+	return map[string]typeutils.Accountable{
+		"new_person_1": newPerson(
+			URLMustParse("https://unknown-instance.com/users/brand_new_person"),
+			URLMustParse("https://unknown-instance.com/users/brand_new_person/following"),
+			URLMustParse("https://unknown-instance.com/users/brand_new_person/followers"),
+			URLMustParse("https://unknown-instance.com/users/brand_new_person/inbox"),
+			URLMustParse("https://unknown-instance.com/users/brand_new_person/outbox"),
+			URLMustParse("https://unknown-instance.com/users/brand_new_person/collections/featured"),
+			"brand_new_person",
+			"Geoff Brando New Personson",
+			"hey I'm a new person, your instance hasn't seen me yet uwu",
+			URLMustParse("https://unknown-instance.com/@brand_new_person"),
+			true,
+			URLMustParse("https://unknown-instance.com/users/brand_new_person#main-key"),
+			new_person_1pub,
+			URLMustParse("https://unknown-instance.com/media/some_avatar_filename.jpeg"),
+			"image/jpeg",
+			URLMustParse("https://unknown-instance.com/media/some_header_filename.jpeg"),
+			"image/png",
+		),
+	}
+}
+
+func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature {
+	sig, digest, date := getSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].URI))
+	return map[string]ActivityWithSignature{
+		"foss_satan_dereference_zork": {
+			SignatureHeader: sig,
+			DigestHeader:    digest,
+			DateHeader:      date,
+		},
+	}
+}
+
+// getSignatureForActivity does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive
+// the HTTP Signature for the given activity, public key ID, private key, and destination.
+func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) {
+	// create a client that basically just pulls the signature out of the request and sets it
+	client := &mockHTTPClient{
+		do: func(req *http.Request) (*http.Response, error) {
+			signatureHeader = req.Header.Get("Signature")
+			digestHeader = req.Header.Get("Digest")
+			dateHeader = req.Header.Get("Date")
+			r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out
+			return &http.Response{
+				StatusCode: 200,
+				Body:       r,
+			}, nil
+		},
+	}
+
+	// use the client to create a new transport
+	c := NewTestTransportController(client)
+	tp, err := c.NewTransport(pubKeyID, privkey)
+	if err != nil {
+		panic(err)
+	}
+
+	// convert the activity into json bytes
+	m, err := activity.Serialize()
+	if err != nil {
+		panic(err)
+	}
+	bytes, err := json.Marshal(m)
+	if err != nil {
+		panic(err)
+	}
+
+	// trigger the delivery function, which will trigger the 'do' function of the recorder above
+	if err := tp.Deliver(context.Background(), bytes, destination); err != nil {
+		panic(err)
+	}
+
+	// headers should now be populated
+	return
+}
+
+// getSignatureForDereference does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive
+// the HTTP Signature for the given derefence GET request using public key ID, private key, and destination.
+func getSignatureForDereference(pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) {
+	// create a client that basically just pulls the signature out of the request and sets it
+	client := &mockHTTPClient{
+		do: func(req *http.Request) (*http.Response, error) {
+			signatureHeader = req.Header.Get("Signature")
+			digestHeader = req.Header.Get("Digest")
+			dateHeader = req.Header.Get("Date")
+			r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out
+			return &http.Response{
+				StatusCode: 200,
+				Body:       r,
+			}, nil
+		},
+	}
+
+	// use the client to create a new transport
+	c := NewTestTransportController(client)
+	tp, err := c.NewTransport(pubKeyID, privkey)
+	if err != nil {
+		panic(err)
+	}
+
+	// trigger the delivery function, which will trigger the 'do' function of the recorder above
+	if _, err := tp.Dereference(context.Background(), destination); err != nil {
+		panic(err)
+	}
+
+	// headers should now be populated
+	return
+}
+
+func newPerson(
+	profileIDURI *url.URL,
+	followingURI *url.URL,
+	followersURI *url.URL,
+	inboxURI *url.URL,
+	outboxURI *url.URL,
+	featuredURI *url.URL,
+	username string,
+	displayName string,
+	note string,
+	profileURL *url.URL,
+	discoverable bool,
+	publicKeyURI *url.URL,
+	pkey *rsa.PublicKey,
+	avatarURL *url.URL,
+	avatarContentType string,
+	headerURL *url.URL,
+	headerContentType string) typeutils.Accountable {
+	person := streams.NewActivityStreamsPerson()
+
+	// id should be the activitypub URI of this user
+	// something like https://example.org/users/example_user
+	idProp := streams.NewJSONLDIdProperty()
+	idProp.SetIRI(profileIDURI)
+	person.SetJSONLDId(idProp)
+
+	// following
+	// The URI for retrieving a list of accounts this user is following
+	followingProp := streams.NewActivityStreamsFollowingProperty()
+	followingProp.SetIRI(followingURI)
+	person.SetActivityStreamsFollowing(followingProp)
+
+	// followers
+	// The URI for retrieving a list of this user's followers
+	followersProp := streams.NewActivityStreamsFollowersProperty()
+	followersProp.SetIRI(followersURI)
+	person.SetActivityStreamsFollowers(followersProp)
+
+	// inbox
+	// the activitypub inbox of this user for accepting messages
+	inboxProp := streams.NewActivityStreamsInboxProperty()
+	inboxProp.SetIRI(inboxURI)
+	person.SetActivityStreamsInbox(inboxProp)
+
+	// outbox
+	// the activitypub outbox of this user for serving messages
+	outboxProp := streams.NewActivityStreamsOutboxProperty()
+	outboxProp.SetIRI(outboxURI)
+	person.SetActivityStreamsOutbox(outboxProp)
+
+	// featured posts
+	// Pinned posts.
+	featuredProp := streams.NewTootFeaturedProperty()
+	featuredProp.SetIRI(featuredURI)
+	person.SetTootFeatured(featuredProp)
+
+	// featuredTags
+	// NOT IMPLEMENTED
+
+	// preferredUsername
+	// Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI.
+	preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty()
+	preferredUsernameProp.SetXMLSchemaString(username)
+	person.SetActivityStreamsPreferredUsername(preferredUsernameProp)
+
+	// name
+	// Used as profile display name.
+	nameProp := streams.NewActivityStreamsNameProperty()
+	if displayName != "" {
+		nameProp.AppendXMLSchemaString(displayName)
+	} else {
+		nameProp.AppendXMLSchemaString(username)
+	}
+	person.SetActivityStreamsName(nameProp)
+
+	// summary
+	// Used as profile bio.
+	if note != "" {
+		summaryProp := streams.NewActivityStreamsSummaryProperty()
+		summaryProp.AppendXMLSchemaString(note)
+		person.SetActivityStreamsSummary(summaryProp)
+	}
+
+	// url
+	// Used as profile link.
+	urlProp := streams.NewActivityStreamsUrlProperty()
+	urlProp.AppendIRI(profileURL)
+	person.SetActivityStreamsUrl(urlProp)
+
+	// manuallyApprovesFollowers
+	// Will be shown as a locked account.
+	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+	// discoverable
+	// Will be shown in the profile directory.
+	discoverableProp := streams.NewTootDiscoverableProperty()
+	discoverableProp.Set(discoverable)
+	person.SetTootDiscoverable(discoverableProp)
+
+	// devices
+	// NOT IMPLEMENTED, probably won't implement
+
+	// alsoKnownAs
+	// Required for Move activity.
+	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+	// publicKey
+	// Required for signatures.
+	publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
+
+	// create the public key
+	publicKey := streams.NewW3IDSecurityV1PublicKey()
+
+	// set ID for the public key
+	publicKeyIDProp := streams.NewJSONLDIdProperty()
+	publicKeyIDProp.SetIRI(publicKeyURI)
+	publicKey.SetJSONLDId(publicKeyIDProp)
+
+	// set owner for the public key
+	publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty()
+	publicKeyOwnerProp.SetIRI(profileIDURI)
+	publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp)
+
+	// set the pem key itself
+	encodedPublicKey, err := x509.MarshalPKIXPublicKey(pkey)
+	if err != nil {
+		panic(err)
+	}
+	publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+		Type:  "PUBLIC KEY",
+		Bytes: encodedPublicKey,
+	})
+	publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
+	publicKeyPEMProp.Set(string(publicKeyBytes))
+	publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp)
+
+	// append the public key to the public key property
+	publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey)
+
+	// set the public key property on the Person
+	person.SetW3IDSecurityV1PublicKey(publicKeyProp)
+
+	// tag
+	// TODO: Any tags used in the summary of this profile
+
+	// attachment
+	// Used for profile fields.
+	// TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue
+
+	// endpoints
+	// NOT IMPLEMENTED -- this is for shared inbox which we don't use
+
+	// icon
+	// Used as profile avatar.
+	iconProperty := streams.NewActivityStreamsIconProperty()
+	iconImage := streams.NewActivityStreamsImage()
+	mediaType := streams.NewActivityStreamsMediaTypeProperty()
+	mediaType.Set(avatarContentType)
+	iconImage.SetActivityStreamsMediaType(mediaType)
+	avatarURLProperty := streams.NewActivityStreamsUrlProperty()
+	avatarURLProperty.AppendIRI(avatarURL)
+	iconImage.SetActivityStreamsUrl(avatarURLProperty)
+	iconProperty.AppendActivityStreamsImage(iconImage)
+	person.SetActivityStreamsIcon(iconProperty)
+
+	// image
+	// Used as profile header.
+	headerProperty := streams.NewActivityStreamsImageProperty()
+	headerImage := streams.NewActivityStreamsImage()
+	headerMediaType := streams.NewActivityStreamsMediaTypeProperty()
+	mediaType.Set(headerContentType)
+	headerImage.SetActivityStreamsMediaType(headerMediaType)
+	headerURLProperty := streams.NewActivityStreamsUrlProperty()
+	headerURLProperty.AppendIRI(headerURL)
+	headerImage.SetActivityStreamsUrl(headerURLProperty)
+	headerProperty.AppendActivityStreamsImage(headerImage)
+
+	return person
+}
+
+// newNote returns a new activity streams note for the given parameters
+func newNote(
+	noteID *url.URL,
+	noteURL *url.URL,
+	noteContent string,
+	noteSummary string,
+	noteAttributedTo *url.URL,
+	noteTo []*url.URL,
+	noteCC []*url.URL,
+	noteSensitive bool) vocab.ActivityStreamsNote {
+
+	// create the note itself
+	note := streams.NewActivityStreamsNote()
+
+	// set id
+	if noteID != nil {
+		id := streams.NewJSONLDIdProperty()
+		id.Set(noteID)
+		note.SetJSONLDId(id)
+	}
+
+	// set noteURL
+	if noteURL != nil {
+		url := streams.NewActivityStreamsUrlProperty()
+		url.AppendIRI(noteURL)
+		note.SetActivityStreamsUrl(url)
+	}
+
+	// set noteContent
+	if noteContent != "" {
+		content := streams.NewActivityStreamsContentProperty()
+		content.AppendXMLSchemaString(noteContent)
+		note.SetActivityStreamsContent(content)
+	}
+
+	// set noteSummary (aka content warning)
+	if noteSummary != "" {
+		summary := streams.NewActivityStreamsSummaryProperty()
+		summary.AppendXMLSchemaString(noteSummary)
+		note.SetActivityStreamsSummary(summary)
+	}
+
+	// set noteAttributedTo (the url of the author of the note)
+	if noteAttributedTo != nil {
+		attributedTo := streams.NewActivityStreamsAttributedToProperty()
+		attributedTo.AppendIRI(noteAttributedTo)
+		note.SetActivityStreamsAttributedTo(attributedTo)
+	}
+
+	return note
+}
+
+// wrapNoteInCreate wraps the given activity streams note in a Create activity streams action
+func wrapNoteInCreate(createID *url.URL, createActor *url.URL, createPublished time.Time, createNote vocab.ActivityStreamsNote) vocab.ActivityStreamsCreate {
+	// create the.... create
+	create := streams.NewActivityStreamsCreate()
+
+	// set createID
+	if createID != nil {
+		id := streams.NewJSONLDIdProperty()
+		id.Set(createID)
+		create.SetJSONLDId(id)
+	}
+
+	// set createActor
+	if createActor != nil {
+		actor := streams.NewActivityStreamsActorProperty()
+		actor.AppendIRI(createActor)
+		create.SetActivityStreamsActor(actor)
+	}
+
+	// set createPublished (time)
+	if !createPublished.IsZero() {
+		published := streams.NewActivityStreamsPublishedProperty()
+		published.Set(createPublished)
+		create.SetActivityStreamsPublished(published)
+	}
+
+	// setCreateTo
+	if createNote.GetActivityStreamsTo() != nil {
+		create.SetActivityStreamsTo(createNote.GetActivityStreamsTo())
+	}
+
+	// setCreateCC
+	if createNote.GetActivityStreamsCc() != nil {
+		create.SetActivityStreamsCc(createNote.GetActivityStreamsCc())
+	}
+
+	// set createNote
+	if createNote != nil {
+		note := streams.NewActivityStreamsObjectProperty()
+		note.AppendActivityStreamsNote(createNote)
+		create.SetActivityStreamsObject(note)
+	}
+
+	return create
+}
diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go
new file mode 100644
index 000000000..f2b5b93f7
--- /dev/null
+++ b/testrig/transportcontroller.go
@@ -0,0 +1,73 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see .
+*/
+
+package testrig
+
+import (
+	"bytes"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/go-fed/activity/pub"
+	"github.com/superseriousbusiness/gotosocial/internal/federation"
+	"github.com/superseriousbusiness/gotosocial/internal/transport"
+)
+
+// NewTestTransportController returns a test transport controller with the given http client.
+//
+// Obviously for testing purposes you should not be making actual http calls to other servers.
+// To obviate this, use the function NewMockHTTPClient in this package to return a mock http
+// client that doesn't make any remote calls but just returns whatever you tell it to.
+//
+// Unlike the other test interfaces provided in this package, you'll probably want to call this function
+// PER TEST rather than per suite, so that the do function can be set on a test by test (or even more granular)
+// basis.
+func NewTestTransportController(client pub.HttpClient) transport.Controller {
+	return transport.NewController(NewTestConfig(), &federation.Clock{}, client, NewTestLog())
+}
+
+// NewMockHTTPClient returns a client that conforms to the pub.HttpClient interface,
+// but will always just execute the given `do` function, allowing responses to be mocked.
+//
+// If 'do' is nil, then a no-op function will be used instead, that just returns status 200.
+//
+// Note that you should never ever make ACTUAL http calls with this thing.
+func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error)) pub.HttpClient {
+	if do == nil {
+		return &mockHTTPClient{
+			do: func(req *http.Request) (*http.Response, error) {
+				r := ioutil.NopCloser(bytes.NewReader([]byte{}))
+				return &http.Response{
+					StatusCode: 200,
+					Body:       r,
+				}, nil
+			},
+		}
+	}
+	return &mockHTTPClient{
+		do: do,
+	}
+}
+
+type mockHTTPClient struct {
+	do func(req *http.Request) (*http.Response, error)
+}
+
+func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
+	return m.do(req)
+}
diff --git a/testrig/mastoconverter.go b/testrig/typeconverter.go
similarity index 75%
rename from testrig/mastoconverter.go
rename to testrig/typeconverter.go
index 10bdbdc95..9d49e6c99 100644
--- a/testrig/mastoconverter.go
+++ b/testrig/typeconverter.go
@@ -20,10 +20,10 @@ package testrig
 
 import (
 	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 )
 
-// NewTestMastoConverter returned a mastotypes converter with the given db and the default test config
-func NewTestMastoConverter(db db.DB) mastotypes.Converter {
-	return mastotypes.New(NewTestConfig(), db)
+// NewTestTypeConverter returned a type converter with the given db and the default test config
+func NewTestTypeConverter(db db.DB) typeutils.TypeConverter {
+	return typeutils.NewConverter(NewTestConfig(), db)
 }
diff --git a/testrig/util.go b/testrig/util.go
index 96a979342..0fb8aa887 100644
--- a/testrig/util.go
+++ b/testrig/util.go
@@ -22,6 +22,7 @@ import (
 	"bytes"
 	"io"
 	"mime/multipart"
+	"net/url"
 	"os"
 )
 
@@ -62,3 +63,13 @@ func CreateMultipartFormData(fieldName string, fileName string, extraFields map[
 	}
 	return b, w, nil
 }
+
+// URLMustParse tries to parse the given URL and panics if it can't.
+// Should only be used in tests.
+func URLMustParse(stringURL string) *url.URL {
+	u, err := url.Parse(stringURL)
+	if err != nil {
+		panic(err)
+	}
+	return u
+}