From 8ab2b19a946251f258446d22f420d401f61d22f6 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Fri, 26 Jul 2024 12:04:28 +0200
Subject: [feature] Federate interaction policies + Accepts; enforce policies
 (#3138)
* [feature] Federate interaction policies + Accepts; enforce policies
* use Acceptable type
* fix index
* remove appendIRIStrs
* add GetAccept federatingdb function
* lock on object IRI
---
 internal/ap/ap_test.go                             |  63 +++
 internal/ap/extract.go                             | 131 ++++++
 internal/ap/extractpolicy_test.go                  | 137 +++++++
 internal/ap/interfaces.go                          |  44 +++
 internal/ap/normalize.go                           | 101 +++++
 internal/ap/properties.go                          |  21 +
 internal/ap/resolve.go                             |  55 ++-
 internal/ap/serialize.go                           |   3 +-
 internal/api/activitypub/users/acceptget.go        |  55 +++
 internal/api/activitypub/users/user.go             |   4 +
 .../20240716151327_interaction_policy.go           |  71 ++++
 .../federation/dereferencing/status_permitted.go   | 262 +++++++++++-
 internal/federation/federatingdb/accept.go         | 439 +++++++++++++++++----
 internal/federation/federatingdb/db.go             |   7 +
 internal/federation/federatingdb/get.go            |   8 +
 internal/gtsmodel/status.go                        |   1 +
 internal/gtsmodel/statusfave.go                    |   1 +
 internal/processing/fedi/accept.go                 |  84 ++++
 internal/processing/processor.go                   |   3 +-
 internal/processing/status/boost.go                |  11 +-
 internal/processing/status/create.go               |  11 +-
 internal/processing/status/fave.go                 |  21 +-
 internal/processing/workers/federate.go            | 217 +++++++++-
 internal/processing/workers/fromclientapi.go       | 192 +++++++++
 internal/processing/workers/fromfediapi.go         | 227 ++++++++++-
 internal/processing/workers/surfacenotify.go       | 222 +++++++++--
 internal/processing/workers/util.go                | 129 ++++++
 internal/regexes/regexes.go                        |   6 +
 internal/transport/controller.go                   |  32 ++
 internal/transport/dereference.go                  |  23 +-
 internal/typeutils/astointernal.go                 |  20 +-
 internal/typeutils/internaltoas.go                 | 281 +++++++++++++
 internal/typeutils/internaltoas_test.go            | 243 +++++++++---
 internal/typeutils/wrap_test.go                    |  25 +-
 internal/uris/uri.go                               |  14 +
 35 files changed, 2941 insertions(+), 223 deletions(-)
 create mode 100644 internal/ap/extractpolicy_test.go
 create mode 100644 internal/api/activitypub/users/acceptget.go
 create mode 100644 internal/db/bundb/migrations/20240716151327_interaction_policy.go
 create mode 100644 internal/processing/fedi/accept.go
(limited to 'internal')
diff --git a/internal/ap/ap_test.go b/internal/ap/ap_test.go
index 0a9f66ca6..f982e4443 100644
--- a/internal/ap/ap_test.go
+++ b/internal/ap/ap_test.go
@@ -28,6 +28,7 @@ import (
 	"github.com/superseriousbusiness/activity/streams"
 	"github.com/superseriousbusiness/activity/streams/vocab"
 	"github.com/superseriousbusiness/gotosocial/internal/ap"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/testrig"
 )
 
@@ -103,6 +104,66 @@ func noteWithMentions1() vocab.ActivityStreamsNote {
 
 	note.SetActivityStreamsContent(content)
 
+	policy := streams.NewGoToSocialInteractionPolicy()
+
+	// Set canLike.
+	canLike := streams.NewGoToSocialCanLike()
+
+	// Anyone can like.
+	canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty()
+	canLikeAlwaysProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI))
+	canLike.SetGoToSocialAlways(canLikeAlwaysProp)
+
+	// Empty approvalRequired.
+	canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty()
+	canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp)
+
+	// Set canLike on the policy.
+	canLikeProp := streams.NewGoToSocialCanLikeProperty()
+	canLikeProp.AppendGoToSocialCanLike(canLike)
+	policy.SetGoToSocialCanLike(canLikeProp)
+
+	// Build canReply.
+	canReply := streams.NewGoToSocialCanReply()
+
+	// Anyone can reply.
+	canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty()
+	canReplyAlwaysProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI))
+	canReply.SetGoToSocialAlways(canReplyAlwaysProp)
+
+	// Set empty approvalRequired.
+	canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty()
+	canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp)
+
+	// Set canReply on the policy.
+	canReplyProp := streams.NewGoToSocialCanReplyProperty()
+	canReplyProp.AppendGoToSocialCanReply(canReply)
+	policy.SetGoToSocialCanReply(canReplyProp)
+
+	// Build canAnnounce.
+	canAnnounce := streams.NewGoToSocialCanAnnounce()
+
+	// Only f0x and dumpsterqueer can announce.
+	canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty()
+	canAnnounceAlwaysProp.AppendIRI(testrig.URLMustParse("https://gts.superseriousbusiness.org/users/dumpsterqueer"))
+	canAnnounceAlwaysProp.AppendIRI(testrig.URLMustParse("https://gts.superseriousbusiness.org/users/f0x"))
+	canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp)
+
+	// Public requires approval to announce.
+	canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty()
+	canAnnounceApprovalRequiredProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI))
+	canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp)
+
+	// Set canAnnounce on the policy.
+	canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty()
+	canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce)
+	policy.SetGoToSocialCanAnnounce(canAnnounceProp)
+
+	// Set the policy on the note.
+	policyProp := streams.NewGoToSocialInteractionPolicyProperty()
+	policyProp.AppendGoToSocialInteractionPolicy(policy)
+	note.SetGoToSocialInteractionPolicy(policyProp)
+
 	return note
 }
 
@@ -296,6 +357,7 @@ type APTestSuite struct {
 	addressable3      ap.Addressable
 	addressable4      vocab.ActivityStreamsAnnounce
 	addressable5      ap.Addressable
+	testAccounts      map[string]*gtsmodel.Account
 }
 
 func (suite *APTestSuite) jsonToType(rawJson string) (vocab.Type, map[string]interface{}) {
@@ -336,4 +398,5 @@ func (suite *APTestSuite) SetupTest() {
 	suite.addressable3 = addressable3()
 	suite.addressable4 = addressable4()
 	suite.addressable5 = addressable5()
+	suite.testAccounts = testrig.NewTestAccounts()
 }
diff --git a/internal/ap/extract.go b/internal/ap/extract.go
index e0c90c5d7..ce1e2d421 100644
--- a/internal/ap/extract.go
+++ b/internal/ap/extract.go
@@ -1057,6 +1057,137 @@ func ExtractVisibility(addressable Addressable, actorFollowersURI string) (gtsmo
 	return visibility, nil
 }
 
+// ExtractInteractionPolicy extracts a *gtsmodel.InteractionPolicy
+// from the given Statusable created by by the given *gtsmodel.Account.
+//
+// Will be nil (default policy) for Statusables that have no policy
+// set on them, or have a null policy. In such a case, the caller
+// should assume the default policy for the status's visibility level.
+func ExtractInteractionPolicy(
+	statusable Statusable,
+	owner *gtsmodel.Account,
+) *gtsmodel.InteractionPolicy {
+	policyProp := statusable.GetGoToSocialInteractionPolicy()
+	if policyProp == nil || policyProp.Len() != 1 {
+		return nil
+	}
+
+	policyPropIter := policyProp.At(0)
+	if !policyPropIter.IsGoToSocialInteractionPolicy() {
+		return nil
+	}
+
+	policy := policyPropIter.Get()
+	if policy == nil {
+		return nil
+	}
+
+	return >smodel.InteractionPolicy{
+		CanLike:     extractCanLike(policy.GetGoToSocialCanLike(), owner),
+		CanReply:    extractCanReply(policy.GetGoToSocialCanReply(), owner),
+		CanAnnounce: extractCanAnnounce(policy.GetGoToSocialCanAnnounce(), owner),
+	}
+}
+
+func extractCanLike(
+	prop vocab.GoToSocialCanLikeProperty,
+	owner *gtsmodel.Account,
+) gtsmodel.PolicyRules {
+	if prop == nil || prop.Len() != 1 {
+		return gtsmodel.PolicyRules{}
+	}
+
+	propIter := prop.At(0)
+	if !propIter.IsGoToSocialCanLike() {
+		return gtsmodel.PolicyRules{}
+	}
+
+	withRules := propIter.Get()
+	if withRules == nil {
+		return gtsmodel.PolicyRules{}
+	}
+
+	return gtsmodel.PolicyRules{
+		Always:       extractPolicyValues(withRules.GetGoToSocialAlways(), owner),
+		WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner),
+	}
+}
+
+func extractCanReply(
+	prop vocab.GoToSocialCanReplyProperty,
+	owner *gtsmodel.Account,
+) gtsmodel.PolicyRules {
+	if prop == nil || prop.Len() != 1 {
+		return gtsmodel.PolicyRules{}
+	}
+
+	propIter := prop.At(0)
+	if !propIter.IsGoToSocialCanReply() {
+		return gtsmodel.PolicyRules{}
+	}
+
+	withRules := propIter.Get()
+	if withRules == nil {
+		return gtsmodel.PolicyRules{}
+	}
+
+	return gtsmodel.PolicyRules{
+		Always:       extractPolicyValues(withRules.GetGoToSocialAlways(), owner),
+		WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner),
+	}
+}
+
+func extractCanAnnounce(
+	prop vocab.GoToSocialCanAnnounceProperty,
+	owner *gtsmodel.Account,
+) gtsmodel.PolicyRules {
+	if prop == nil || prop.Len() != 1 {
+		return gtsmodel.PolicyRules{}
+	}
+
+	propIter := prop.At(0)
+	if !propIter.IsGoToSocialCanAnnounce() {
+		return gtsmodel.PolicyRules{}
+	}
+
+	withRules := propIter.Get()
+	if withRules == nil {
+		return gtsmodel.PolicyRules{}
+	}
+
+	return gtsmodel.PolicyRules{
+		Always:       extractPolicyValues(withRules.GetGoToSocialAlways(), owner),
+		WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner),
+	}
+}
+
+func extractPolicyValues[T WithIRI](
+	prop Property[T],
+	owner *gtsmodel.Account,
+) gtsmodel.PolicyValues {
+	iris := getIRIs(prop)
+	PolicyValues := make(gtsmodel.PolicyValues, 0, len(iris))
+
+	for _, iri := range iris {
+		switch iriStr := iri.String(); iriStr {
+		case pub.PublicActivityPubIRI:
+			PolicyValues = append(PolicyValues, gtsmodel.PolicyValuePublic)
+		case owner.FollowersURI:
+			PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers)
+		case owner.FollowingURI:
+			PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers)
+		case owner.URI:
+			PolicyValues = append(PolicyValues, gtsmodel.PolicyValueAuthor)
+		default:
+			if iri.Scheme == "http" || iri.Scheme == "https" {
+				PolicyValues = append(PolicyValues, gtsmodel.PolicyValue(iriStr))
+			}
+		}
+	}
+
+	return PolicyValues
+}
+
 // ExtractSensitive extracts whether or not an item should
 // be marked as sensitive according to its ActivityStreams
 // sensitive property.
diff --git a/internal/ap/extractpolicy_test.go b/internal/ap/extractpolicy_test.go
new file mode 100644
index 000000000..3d5e75c41
--- /dev/null
+++ b/internal/ap/extractpolicy_test.go
@@ -0,0 +1,137 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 ap_test
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/ap"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+type ExtractPolicyTestSuite struct {
+	APTestSuite
+}
+
+func (suite *ExtractPolicyTestSuite) TestExtractPolicy() {
+	rawNote := `{
+  "@context": [
+    "https://gotosocial.org/ns",
+    "https://www.w3.org/ns/activitystreams"
+  ],
+  "content": "hey @f0x and @dumpsterqueer",
+  "contentMap": {
+    "en": "hey @f0x and @dumpsterqueer",
+    "fr": "bonjour @f0x et @dumpsterqueer"
+  },
+  "interactionPolicy": {
+    "canLike": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canReply": {
+      "always": [
+        "http://localhost:8080/users/the_mighty_zork",
+        "http://localhost:8080/users/the_mighty_zork/followers",
+        "https://gts.superseriousbusiness.org/users/dumpsterqueer",
+        "https://gts.superseriousbusiness.org/users/f0x"
+      ],
+      "approvalRequired": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ]
+    },
+    "canAnnounce": {
+      "always": [
+        "http://localhost:8080/users/the_mighty_zork"
+      ],
+      "approvalRequired": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ]
+    }
+  },
+  "tag": [
+    {
+      "href": "https://gts.superseriousbusiness.org/users/dumpsterqueer",
+      "name": "@dumpsterqueer@superseriousbusiness.org",
+      "type": "Mention"
+    },
+    {
+      "href": "https://gts.superseriousbusiness.org/users/f0x",
+      "name": "@f0x@superseriousbusiness.org",
+      "type": "Mention"
+    }
+  ],
+  "type": "Note"
+}`
+
+	statusable, err := ap.ResolveStatusable(
+		context.Background(),
+		io.NopCloser(
+			bytes.NewBufferString(rawNote),
+		),
+	)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	policy := ap.ExtractInteractionPolicy(
+		statusable,
+		// Zork didn't actually create
+		// this status but nevermind.
+		suite.testAccounts["local_account_1"],
+	)
+
+	expectedPolicy := >smodel.InteractionPolicy{
+		CanLike: gtsmodel.PolicyRules{
+			Always: gtsmodel.PolicyValues{
+				gtsmodel.PolicyValuePublic,
+			},
+			WithApproval: gtsmodel.PolicyValues{},
+		},
+		CanReply: gtsmodel.PolicyRules{
+			Always: gtsmodel.PolicyValues{
+				gtsmodel.PolicyValueAuthor,
+				gtsmodel.PolicyValueFollowers,
+				"https://gts.superseriousbusiness.org/users/dumpsterqueer",
+				"https://gts.superseriousbusiness.org/users/f0x",
+			},
+			WithApproval: gtsmodel.PolicyValues{
+				gtsmodel.PolicyValuePublic,
+			},
+		},
+		CanAnnounce: gtsmodel.PolicyRules{
+			Always: gtsmodel.PolicyValues{
+				gtsmodel.PolicyValueAuthor,
+			},
+			WithApproval: gtsmodel.PolicyValues{
+				gtsmodel.PolicyValuePublic,
+			},
+		},
+	}
+	suite.EqualValues(expectedPolicy, policy)
+}
+
+func TestExtractPolicyTestSuite(t *testing.T) {
+	suite.Run(t, &ExtractPolicyTestSuite{})
+}
diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go
index 8f2e17c09..a721fa997 100644
--- a/internal/ap/interfaces.go
+++ b/internal/ap/interfaces.go
@@ -124,6 +124,24 @@ func ToPollOptionable(t vocab.Type) (PollOptionable, bool) {
 	return note, true
 }
 
+// IsAccept returns whether AS vocab type name
+// is something that can be cast to Accept.
+func IsAcceptable(typeName string) bool {
+	return typeName == ActivityAccept
+}
+
+// ToAcceptable safely tries to cast vocab.Type as vocab.ActivityStreamsAccept.
+//
+// TODO: Add additional "Accept" types here, eg., "ApproveReply" from
+// https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md
+func ToAcceptable(t vocab.Type) (vocab.ActivityStreamsAccept, bool) {
+	acceptable, ok := t.(vocab.ActivityStreamsAccept)
+	if !ok || !IsAcceptable(t.GetTypeName()) {
+		return nil, false
+	}
+	return acceptable, true
+}
+
 // Activityable represents the minimum activitypub interface for representing an 'activity'.
 // (see: IsActivityable() for types implementing this, though you MUST make sure to check
 // the typeName as this bare interface may be implementable by non-Activityable types).
@@ -188,6 +206,8 @@ type Statusable interface {
 	WithAttachment
 	WithTag
 	WithReplies
+	WithInteractionPolicy
+	WithApprovedBy
 }
 
 // Pollable represents the minimum activitypub interface for representing a 'poll' (it's a subset of a status).
@@ -217,6 +237,12 @@ type PollOptionable interface {
 	WithAttributedTo
 }
 
+// Acceptable represents the minimum activitypub
+// interface for representing an Accept.
+type Acceptable interface {
+	Activityable
+}
+
 // Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'. (see: IsAttachmentable).
 // This interface is fulfilled by: Audio, Document, Image, Video
 type Attachmentable interface {
@@ -657,3 +683,21 @@ type WithVotersCount interface {
 	GetTootVotersCount() vocab.TootVotersCountProperty
 	SetTootVotersCount(vocab.TootVotersCountProperty)
 }
+
+// WithReplies represents an object with GoToSocialInteractionPolicy.
+type WithInteractionPolicy interface {
+	GetGoToSocialInteractionPolicy() vocab.GoToSocialInteractionPolicyProperty
+	SetGoToSocialInteractionPolicy(vocab.GoToSocialInteractionPolicyProperty)
+}
+
+// WithPolicyRules represents an activity with always and approvalRequired properties.
+type WithPolicyRules interface {
+	GetGoToSocialAlways() vocab.GoToSocialAlwaysProperty
+	GetGoToSocialApprovalRequired() vocab.GoToSocialApprovalRequiredProperty
+}
+
+// WithApprovedBy represents a Statusable with the approvedBy property.
+type WithApprovedBy interface {
+	GetGoToSocialApprovedBy() vocab.GoToSocialApprovedByProperty
+	SetGoToSocialApprovedBy(vocab.GoToSocialApprovedByProperty)
+}
diff --git a/internal/ap/normalize.go b/internal/ap/normalize.go
index bef6d93b0..30d8515a5 100644
--- a/internal/ap/normalize.go
+++ b/internal/ap/normalize.go
@@ -575,6 +575,107 @@ func NormalizeOutgoingContentProp(item WithContent, rawJSON map[string]interface
 	}
 }
 
+// NormalizeOutgoingInteractionPolicyProp replaces single-entry interactionPolicy values
+// with single-entry arrays, for better compatibility with other AP implementations.
+//
+// Ie:
+//
+//	"interactionPolicy": {
+//		"canAnnounce": {
+//			"always": "https://www.w3.org/ns/activitystreams#Public",
+//			"approvalRequired": []
+//		},
+//		"canLike": {
+//			"always": "https://www.w3.org/ns/activitystreams#Public",
+//			"approvalRequired": []
+//		},
+//		"canReply": {
+//			"always": "https://www.w3.org/ns/activitystreams#Public",
+//			"approvalRequired": []
+//		}
+//	}
+//
+// becomes:
+//
+//	"interactionPolicy": {
+//		"canAnnounce": {
+//			"always": [
+//				"https://www.w3.org/ns/activitystreams#Public"
+//			],
+//			"approvalRequired": []
+//		},
+//		"canLike": {
+//			"always": [
+//				"https://www.w3.org/ns/activitystreams#Public"
+//			],
+//			"approvalRequired": []
+//		},
+//		"canReply": {
+//			"always": [
+//				"https://www.w3.org/ns/activitystreams#Public"
+//			],
+//			"approvalRequired": []
+//		}
+//	}
+//
+// Noop for items with no attachments, or with attachments that are already a slice.
+func NormalizeOutgoingInteractionPolicyProp(item WithInteractionPolicy, rawJSON map[string]interface{}) {
+	policy, ok := rawJSON["interactionPolicy"]
+	if !ok {
+		// No 'interactionPolicy',
+		// nothing to change.
+		return
+	}
+
+	policyMap, ok := policy.(map[string]interface{})
+	if !ok {
+		// Malformed 'interactionPolicy',
+		// nothing to change.
+		return
+	}
+
+	for _, rulesKey := range []string{
+		"canLike",
+		"canReply",
+		"canAnnounce",
+	} {
+		// Either "canAnnounce",
+		// "canLike", or "canApprove"
+		rulesVal, ok := policyMap[rulesKey]
+		if !ok {
+			// Not set.
+			return
+		}
+
+		rulesValMap, ok := rulesVal.(map[string]interface{})
+		if !ok {
+			// Malformed or not
+			// present skip.
+			return
+		}
+
+		for _, PolicyValuesKey := range []string{
+			"always",
+			"approvalRequired",
+		} {
+			PolicyValuesVal, ok := rulesValMap[PolicyValuesKey]
+			if !ok {
+				// Not set.
+				continue
+			}
+
+			if _, ok := PolicyValuesVal.([]interface{}); ok {
+				// Already slice,
+				// nothing to change.
+				continue
+			}
+
+			// Coerce single-object to slice.
+			rulesValMap[PolicyValuesKey] = []interface{}{PolicyValuesVal}
+		}
+	}
+}
+
 // NormalizeOutgoingObjectProp normalizes each Object entry in the rawJSON of the given
 // item by calling custom serialization / normalization functions on them in turn.
 //
diff --git a/internal/ap/properties.go b/internal/ap/properties.go
index 1bd8c303e..38e58ebc0 100644
--- a/internal/ap/properties.go
+++ b/internal/ap/properties.go
@@ -520,6 +520,27 @@ func SetManuallyApprovesFollowers(with WithManuallyApprovesFollowers, manuallyAp
 	mafProp.Set(manuallyApprovesFollowers)
 }
 
+// GetApprovedBy returns the URL contained in
+// the ApprovedBy property of 'with', if set.
+func GetApprovedBy(with WithApprovedBy) *url.URL {
+	mafProp := with.GetGoToSocialApprovedBy()
+	if mafProp == nil || !mafProp.IsIRI() {
+		return nil
+	}
+	return mafProp.Get()
+}
+
+// SetApprovedBy sets the given url
+// on the ApprovedBy property of 'with'.
+func SetApprovedBy(with WithApprovedBy, approvedBy *url.URL) {
+	abProp := with.GetGoToSocialApprovedBy()
+	if abProp == nil {
+		abProp = streams.NewGoToSocialApprovedByProperty()
+		with.SetGoToSocialApprovedBy(abProp)
+	}
+	abProp.Set(approvedBy)
+}
+
 // extractIRIs extracts just the AP IRIs from an iterable
 // property that may contain types (with IRIs) or just IRIs.
 //
diff --git a/internal/ap/resolve.go b/internal/ap/resolve.go
index b2e866b6f..76a8809c3 100644
--- a/internal/ap/resolve.go
+++ b/internal/ap/resolve.go
@@ -37,6 +37,8 @@ func ResolveIncomingActivity(r *http.Request) (pub.Activity, bool, gtserror.With
 	// Get "raw" map
 	// destination.
 	raw := getMap()
+	// Release.
+	defer putMap(raw)
 
 	// Decode data as JSON into 'raw' map
 	// and get the resolved AS vocab.Type.
@@ -79,9 +81,6 @@ func ResolveIncomingActivity(r *http.Request) (pub.Activity, bool, gtserror.With
 	// (see: https://github.com/superseriousbusiness/gotosocial/issues/1661)
 	NormalizeIncomingActivity(activity, raw)
 
-	// Release.
-	putMap(raw)
-
 	return activity, true, nil
 }
 
@@ -93,6 +92,8 @@ func ResolveStatusable(ctx context.Context, body io.ReadCloser) (Statusable, err
 	// Get "raw" map
 	// destination.
 	raw := getMap()
+	// Release.
+	defer putMap(raw)
 
 	// Decode data as JSON into 'raw' map
 	// and get the resolved AS vocab.Type.
@@ -121,9 +122,6 @@ func ResolveStatusable(ctx context.Context, body io.ReadCloser) (Statusable, err
 	NormalizeIncomingSummary(statusable, raw)
 	NormalizeIncomingName(statusable, raw)
 
-	// Release.
-	putMap(raw)
-
 	return statusable, nil
 }
 
@@ -135,6 +133,8 @@ func ResolveAccountable(ctx context.Context, body io.ReadCloser) (Accountable, e
 	// Get "raw" map
 	// destination.
 	raw := getMap()
+	// Release.
+	defer putMap(raw)
 
 	// Decode data as JSON into 'raw' map
 	// and get the resolved AS vocab.Type.
@@ -153,9 +153,6 @@ func ResolveAccountable(ctx context.Context, body io.ReadCloser) (Accountable, e
 
 	NormalizeIncomingSummary(accountable, raw)
 
-	// Release.
-	putMap(raw)
-
 	return accountable, nil
 }
 
@@ -165,6 +162,8 @@ func ResolveCollection(ctx context.Context, body io.ReadCloser) (CollectionItera
 	// Get "raw" map
 	// destination.
 	raw := getMap()
+	// Release.
+	defer putMap(raw)
 
 	// Decode data as JSON into 'raw' map
 	// and get the resolved AS vocab.Type.
@@ -174,9 +173,6 @@ func ResolveCollection(ctx context.Context, body io.ReadCloser) (CollectionItera
 		return nil, gtserror.SetWrongType(err)
 	}
 
-	// Release.
-	putMap(raw)
-
 	// Cast as as Collection-like.
 	return ToCollectionIterator(t)
 }
@@ -187,6 +183,8 @@ func ResolveCollectionPage(ctx context.Context, body io.ReadCloser) (CollectionP
 	// Get "raw" map
 	// destination.
 	raw := getMap()
+	// Release.
+	defer putMap(raw)
 
 	// Decode data as JSON into 'raw' map
 	// and get the resolved AS vocab.Type.
@@ -196,13 +194,40 @@ func ResolveCollectionPage(ctx context.Context, body io.ReadCloser) (CollectionP
 		return nil, gtserror.SetWrongType(err)
 	}
 
-	// Release.
-	putMap(raw)
-
 	// Cast as as CollectionPage-like.
 	return ToCollectionPageIterator(t)
 }
 
+// ResolveAcceptable tries to resolve the given reader
+// into an ActivityStreams Acceptable representation.
+func ResolveAcceptable(
+	ctx context.Context,
+	body io.ReadCloser,
+) (Acceptable, error) {
+	// Get "raw" map
+	// destination.
+	raw := getMap()
+	// Release.
+	defer putMap(raw)
+
+	// Decode data as JSON into 'raw' map
+	// and get the resolved AS vocab.Type.
+	// (this handles close of given body).
+	t, err := decodeType(ctx, body, raw)
+	if err != nil {
+		return nil, gtserror.SetWrongType(err)
+	}
+
+	// Attempt to cast as acceptable.
+	acceptable, ok := ToAcceptable(t)
+	if !ok {
+		err := gtserror.Newf("cannot resolve vocab type %T as acceptable", t)
+		return nil, gtserror.SetWrongType(err)
+	}
+
+	return acceptable, nil
+}
+
 // emptydest is an empty JSON decode
 // destination useful for "noop" decodes
 // to check underlying reader is empty.
diff --git a/internal/ap/serialize.go b/internal/ap/serialize.go
index b13ebb340..3e5a92d79 100644
--- a/internal/ap/serialize.go
+++ b/internal/ap/serialize.go
@@ -37,7 +37,7 @@ import (
 //   - OrderedCollection:       'orderedItems' property will always be made into an array.
 //   - OrderedCollectionPage:   'orderedItems' property will always be made into an array.
 //   - Any Accountable type:    'attachment' property will always be made into an array.
-//   - Any Statusable type:     'attachment' property will always be made into an array; 'content' and 'contentMap' will be normalized.
+//   - Any Statusable type:     'attachment' property will always be made into an array; 'content', 'contentMap', and 'interactionPolicy' will be normalized.
 //   - Any Activityable type:   any 'object's set on an activity will be custom serialized as above.
 func Serialize(t vocab.Type) (m map[string]interface{}, e error) {
 	switch tn := t.GetTypeName(); {
@@ -153,6 +153,7 @@ func serializeStatusable(t vocab.Type, includeContext bool) (map[string]interfac
 
 	NormalizeOutgoingAttachmentProp(statusable, data)
 	NormalizeOutgoingContentProp(statusable, data)
+	NormalizeOutgoingInteractionPolicyProp(statusable, data)
 
 	return data, nil
 }
diff --git a/internal/api/activitypub/users/acceptget.go b/internal/api/activitypub/users/acceptget.go
new file mode 100644
index 000000000..c2b438330
--- /dev/null
+++ b/internal/api/activitypub/users/acceptget.go
@@ -0,0 +1,55 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 users
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+// AcceptGETHandler serves an interactionApproval as an ActivityStreams Accept.
+func (m *Module) AcceptGETHandler(c *gin.Context) {
+	username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
+	if errWithCode != nil {
+		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+		return
+	}
+
+	acceptID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
+	if errWithCode != nil {
+		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+		return
+	}
+
+	contentType, err := apiutil.NegotiateAccept(c, apiutil.ActivityPubHeaders...)
+	if err != nil {
+		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+		return
+	}
+
+	resp, errWithCode := m.processor.Fedi().AcceptGet(c.Request.Context(), username, acceptID)
+	if errWithCode != nil {
+		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+		return
+	}
+
+	apiutil.JSONType(c, http.StatusOK, contentType, resp)
+}
diff --git a/internal/api/activitypub/users/user.go b/internal/api/activitypub/users/user.go
index 5e3d5d187..5122e610e 100644
--- a/internal/api/activitypub/users/user.go
+++ b/internal/api/activitypub/users/user.go
@@ -21,6 +21,7 @@ import (
 	"net/http"
 
 	"github.com/gin-gonic/gin"
+	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
 	"github.com/superseriousbusiness/gotosocial/internal/processing"
 	"github.com/superseriousbusiness/gotosocial/internal/uris"
 )
@@ -55,6 +56,8 @@ const (
 	StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey
 	// StatusRepliesPath is for serving the replies collection of a status.
 	StatusRepliesPath = StatusPath + "/replies"
+	// AcceptPath is for serving accepts of a status.
+	AcceptPath = BasePath + "/" + uris.AcceptsPath + "/:" + apiutil.IDKey
 )
 
 type Module struct {
@@ -76,4 +79,5 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
 	attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler)
 	attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler)
 	attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler)
+	attachHandler(http.MethodGet, AcceptPath, m.AcceptGETHandler)
 }
diff --git a/internal/db/bundb/migrations/20240716151327_interaction_policy.go b/internal/db/bundb/migrations/20240716151327_interaction_policy.go
new file mode 100644
index 000000000..fb0d1d752
--- /dev/null
+++ b/internal/db/bundb/migrations/20240716151327_interaction_policy.go
@@ -0,0 +1,71 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 migrations
+
+import (
+	"context"
+
+	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/uptrace/bun"
+)
+
+func init() {
+	up := func(ctx context.Context, db *bun.DB) error {
+		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+			if _, err := tx.
+				NewCreateTable().
+				Model(>smodel.InteractionApproval{}).
+				IfNotExists().
+				Exec(ctx); err != nil {
+				return err
+			}
+
+			if _, err := tx.
+				NewCreateIndex().
+				Table("interaction_approvals").
+				Index("interaction_approvals_account_id_idx").
+				Column("account_id").
+				IfNotExists().
+				Exec(ctx); err != nil {
+				return err
+			}
+
+			if _, err := tx.
+				NewCreateIndex().
+				Table("interaction_approvals").
+				Index("interaction_approvals_interacting_account_id_idx").
+				Column("interacting_account_id").
+				IfNotExists().
+				Exec(ctx); err != nil {
+				return err
+			}
+
+			return nil
+		})
+	}
+
+	down := func(ctx context.Context, db *bun.DB) error {
+		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+			return nil
+		})
+	}
+
+	if err := Migrations.Register(up, down); err != nil {
+		panic(err)
+	}
+}
diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go
index 5c16f9f15..97cd61e93 100644
--- a/internal/federation/dereferencing/status_permitted.go
+++ b/internal/federation/dereferencing/status_permitted.go
@@ -19,10 +19,13 @@ package dereferencing
 
 import (
 	"context"
+	"net/url"
 
+	"github.com/superseriousbusiness/gotosocial/internal/ap"
 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/log"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 // isPermittedStatus returns whether the given status
@@ -147,12 +150,67 @@ func (d *Dereferencer) isPermittedReply(
 		return onFalse()
 	}
 
-	// TODO in next PR: check conditional /
-	// with approval and deref Accept.
-	if !replyable.Permitted() {
+	if replyable.Permitted() &&
+		!replyable.MatchedOnCollection() {
+		// Replier is permitted to do this
+		// interaction, and didn't match on
+		// a collection so we don't need to
+		// do further checking.
+		return true, nil
+	}
+
+	// Replier is permitted to do this
+	// interaction pending approval, or
+	// permitted but matched on a collection.
+	//
+	// Check if we can dereference
+	// an Accept that grants approval.
+
+	if status.ApprovedByURI == "" {
+		// Status doesn't claim to be approved.
+		//
+		// For replies to local statuses that's
+		// fine, we can put it in the DB pending
+		// approval, and continue processing it.
+		//
+		// If permission was granted based on a match
+		// with a followers or following collection,
+		// we can mark it as PreApproved so the processor
+		// sends an accept out for it immediately.
+		//
+		// For replies to remote statuses, though
+		// we should be polite and just drop it.
+		if inReplyTo.IsLocal() {
+			status.PendingApproval = util.Ptr(true)
+			status.PreApproved = replyable.MatchedOnCollection()
+			return true, nil
+		}
+
+		return onFalse()
+	}
+
+	// Status claims to be approved, check
+	// this by dereferencing the Accept and
+	// inspecting the return value.
+	if err := d.validateApprovedBy(
+		ctx,
+		requestUser,
+		status.ApprovedByURI,
+		status.URI,
+		inReplyTo.AccountURI,
+	); err != nil {
+		// Error dereferencing means we couldn't
+		// get the Accept right now or it wasn't
+		// valid, so we shouldn't store this status.
+		//
+		// Do log the error though as it may be
+		// interesting for admins to see.
+		log.Info(ctx, "rejecting reply with undereferenceable ApprovedByURI: %v", err)
 		return onFalse()
 	}
 
+	// Status has been approved.
+	status.PendingApproval = util.Ptr(false)
 	return true, nil
 }
 
@@ -206,11 +264,203 @@ func (d *Dereferencer) isPermittedBoost(
 		return onFalse()
 	}
 
-	// TODO in next PR: check conditional /
-	// with approval and deref Accept.
-	if !boostable.Permitted() {
+	if boostable.Permitted() &&
+		!boostable.MatchedOnCollection() {
+		// Booster is permitted to do this
+		// interaction, and didn't match on
+		// a collection so we don't need to
+		// do further checking.
+		return true, nil
+	}
+
+	// Booster is permitted to do this
+	// interaction pending approval, or
+	// permitted but matched on a collection.
+	//
+	// Check if we can dereference
+	// an Accept that grants approval.
+
+	if status.ApprovedByURI == "" {
+		// Status doesn't claim to be approved.
+		//
+		// For boosts of local statuses that's
+		// fine, we can put it in the DB pending
+		// approval, and continue processing it.
+		//
+		// If permission was granted based on a match
+		// with a followers or following collection,
+		// we can mark it as PreApproved so the processor
+		// sends an accept out for it immediately.
+		//
+		// For boosts of remote statuses, though
+		// we should be polite and just drop it.
+		if boostOf.IsLocal() {
+			status.PendingApproval = util.Ptr(true)
+			status.PreApproved = boostable.MatchedOnCollection()
+			return true, nil
+		}
+
 		return onFalse()
 	}
 
+	// Boost claims to be approved, check
+	// this by dereferencing the Accept and
+	// inspecting the return value.
+	if err := d.validateApprovedBy(
+		ctx,
+		requestUser,
+		status.ApprovedByURI,
+		status.URI,
+		boostOf.AccountURI,
+	); err != nil {
+		// Error dereferencing means we couldn't
+		// get the Accept right now or it wasn't
+		// valid, so we shouldn't store this status.
+		//
+		// Do log the error though as it may be
+		// interesting for admins to see.
+		log.Info(ctx, "rejecting boost with undereferenceable ApprovedByURI: %v", err)
+		return onFalse()
+	}
+
+	// Status has been approved.
+	status.PendingApproval = util.Ptr(false)
 	return true, nil
 }
+
+// validateApprovedBy dereferences the activitystreams Accept at
+// the specified IRI, and checks the Accept for validity against
+// the provided expectedObject and expectedActor.
+//
+// Will return either nil if everything looked OK, or an error if
+// something went wrong during deref, or if the dereffed Accept
+// did not meet expectations.
+func (d *Dereferencer) validateApprovedBy(
+	ctx context.Context,
+	requestUser string,
+	approvedByURIStr string, // Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03"
+	expectedObject string, // Eg., "https://some.instance.example.org/users/someone_else/statuses/01J27414TWV9F7DC39FN8ABB5R"
+	expectedActor string, // Eg., "https://example.org/users/someone"
+) error {
+	approvedByURI, err := url.Parse(approvedByURIStr)
+	if err != nil {
+		err := gtserror.Newf("error parsing approvedByURI: %w", err)
+		return err
+	}
+
+	// Don't make calls to the remote if it's blocked.
+	if blocked, err := d.state.DB.IsDomainBlocked(ctx, approvedByURI.Host); blocked || err != nil {
+		err := gtserror.Newf("domain %s is blocked", approvedByURI.Host)
+		return err
+	}
+
+	transport, err := d.transportController.NewTransportForUsername(ctx, requestUser)
+	if err != nil {
+		err := gtserror.Newf("error creating transport: %w", err)
+		return err
+	}
+
+	// Make the call to resolve into an Acceptable.
+	rsp, err := transport.Dereference(ctx, approvedByURI)
+	if err != nil {
+		err := gtserror.Newf("error dereferencing %s: %w", approvedByURIStr, err)
+		return err
+	}
+
+	acceptable, err := ap.ResolveAcceptable(ctx, rsp.Body)
+
+	// Tidy up rsp body.
+	_ = rsp.Body.Close()
+
+	if err != nil {
+		err := gtserror.Newf("error resolving Accept %s: %w", approvedByURIStr, err)
+		return err
+	}
+
+	// Extract the URI/ID of the Accept.
+	acceptURI := ap.GetJSONLDId(acceptable)
+	acceptURIStr := acceptURI.String()
+
+	// Check whether input URI and final returned URI
+	// have changed (i.e. we followed some redirects).
+	rspURL := rsp.Request.URL
+	rspURLStr := rspURL.String()
+	if rspURLStr != approvedByURIStr {
+		// Final URI was different from approvedByURIStr.
+		//
+		// Make sure it's at least on the same host as
+		// what we expected (ie., we weren't redirected
+		// across domains), and make sure it's the same
+		// as the ID of the Accept we were returned.
+		if rspURL.Host != approvedByURI.Host {
+			err := gtserror.Newf(
+				"final dereference host %s did not match approvedByURI host %s",
+				rspURL.Host, approvedByURI.Host,
+			)
+			return err
+		}
+
+		if acceptURIStr != rspURLStr {
+			err := gtserror.Newf(
+				"final dereference uri %s did not match returned Accept ID/URI %s",
+				rspURLStr, acceptURIStr,
+			)
+			return err
+		}
+	}
+
+	// Ensure the Accept URI has the same host
+	// as the Accept Actor, so we know we're
+	// not dealing with someone on a different
+	// domain just pretending to be the Actor.
+	actorIRIs := ap.GetActorIRIs(acceptable)
+	if len(actorIRIs) != 1 {
+		err := gtserror.New("resolved Accept actor(s) length was not 1")
+		return gtserror.SetMalformed(err)
+	}
+
+	actorIRI := actorIRIs[0]
+	actorStr := actorIRI.String()
+
+	if actorIRI.Host != acceptURI.Host {
+		err := gtserror.Newf(
+			"Accept Actor %s was not the same host as Accept %s",
+			actorStr, acceptURIStr,
+		)
+		return err
+	}
+
+	// Ensure the Accept Actor is who we expect
+	// it to be, and not someone else trying to
+	// do an Accept for an interaction with a
+	// statusable they don't own.
+	if actorStr != expectedActor {
+		err := gtserror.Newf(
+			"Accept Actor %s was not the same as expected actor %s",
+			actorStr, expectedActor,
+		)
+		return err
+	}
+
+	// Ensure the Accept Object is what we expect
+	// it to be, ie., it's Accepting the interaction
+	// we need it to Accept, and not something else.
+	objectIRIs := ap.GetObjectIRIs(acceptable)
+	if len(objectIRIs) != 1 {
+		err := gtserror.New("resolved Accept object(s) length was not 1")
+		return err
+	}
+
+	objectIRI := objectIRIs[0]
+	objectStr := objectIRI.String()
+
+	if objectStr != expectedObject {
+		err := gtserror.Newf(
+			"resolved Accept Object uri %s was not the same as expected object %s",
+			objectStr, expectedObject,
+		)
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go
index e26e5955b..60b9cfe58 100644
--- a/internal/federation/federatingdb/accept.go
+++ b/internal/federation/federatingdb/accept.go
@@ -20,16 +20,31 @@ package federatingdb
 import (
 	"context"
 	"errors"
-	"fmt"
+	"net/url"
 
 	"codeberg.org/gruf/go-logger/v2/level"
 	"github.com/superseriousbusiness/activity/streams/vocab"
 	"github.com/superseriousbusiness/gotosocial/internal/ap"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/log"
 	"github.com/superseriousbusiness/gotosocial/internal/messages"
 	"github.com/superseriousbusiness/gotosocial/internal/uris"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
+func (f *federatingDB) GetAccept(
+	ctx context.Context,
+	acceptIRI *url.URL,
+) (vocab.ActivityStreamsAccept, error) {
+	approval, err := f.state.DB.GetInteractionApprovalByURI(ctx, acceptIRI.String())
+	if err != nil {
+		return nil, err
+	}
+	return f.converter.InteractionApprovalToASAccept(ctx, approval)
+}
+
 func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error {
 	if log.Level() >= level.DEBUG {
 		i, err := marshalItem(accept)
@@ -55,100 +70,382 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
 		return nil
 	}
 
-	// Iterate all provided objects in the activity.
-	for _, object := range ap.ExtractObjects(accept) {
+	activityID := ap.GetJSONLDId(accept)
+	if activityID == nil {
+		// We need an ID.
+		const text = "Accept had no id property"
+		return gtserror.NewErrorBadRequest(errors.New(text), text)
+	}
 
-		// Check and handle any vocab.Type objects.
-		if objType := object.GetType(); objType != nil {
-			switch objType.GetTypeName() { //nolint:gocritic
+	// Iterate all provided objects in the activity,
+	// handling the ones we know how to handle.
+	for _, object := range ap.ExtractObjects(accept) {
+		if asType := object.GetType(); asType != nil {
+			// Check and handle any
+			// vocab.Type objects.
+			// nolint:gocritic
+			switch asType.GetTypeName() {
 
+			// ACCEPT FOLLOW
 			case ap.ActivityFollow:
-				// Cast the vocab.Type object to known AS type.
-				asFollow := objType.(vocab.ActivityStreamsFollow)
-
-				// convert the follow to something we can understand
-				gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow)
-				if err != nil {
-					return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err)
+				if err := f.acceptFollowType(
+					ctx,
+					asType,
+					receivingAcct,
+					requestingAcct,
+				); err != nil {
+					return err
 				}
+			}
 
-				// Make sure the creator of the original follow
-				// is the same as whatever inbox this landed in.
-				if gtsFollow.AccountID != receivingAcct.ID {
-					return errors.New("ACCEPT: follow account and inbox account were not the same")
-				}
+		} else if object.IsIRI() {
+			// Check and handle any
+			// IRI type objects.
+			switch objIRI := object.GetIRI(); {
 
-				// Make sure the target of the original follow
-				// is the same as the account making the request.
-				if gtsFollow.TargetAccountID != requestingAcct.ID {
-					return errors.New("ACCEPT: follow target account and requesting account were not the same")
+			// ACCEPT FOLLOW
+			case uris.IsFollowPath(objIRI):
+				if err := f.acceptFollowIRI(
+					ctx,
+					objIRI.String(),
+					receivingAcct,
+					requestingAcct,
+				); err != nil {
+					return err
 				}
 
-				follow, err := f.state.DB.AcceptFollowRequest(ctx, gtsFollow.AccountID, gtsFollow.TargetAccountID)
-				if err != nil {
+			// ACCEPT STATUS (reply/boost)
+			case uris.IsStatusesPath(objIRI):
+				if err := f.acceptStatusIRI(
+					ctx,
+					activityID.String(),
+					objIRI.String(),
+					receivingAcct,
+					requestingAcct,
+				); err != nil {
 					return err
 				}
 
-				f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
-					APObjectType:   ap.ActivityFollow,
-					APActivityType: ap.ActivityAccept,
-					GTSModel:       follow,
-					Receiving:      receivingAcct,
-					Requesting:     requestingAcct,
-				})
+			// ACCEPT LIKE
+			case uris.IsLikePath(objIRI):
+				if err := f.acceptLikeIRI(
+					ctx,
+					activityID.String(),
+					objIRI.String(),
+					receivingAcct,
+					requestingAcct,
+				); err != nil {
+					return err
+				}
 			}
-
-			continue
 		}
+	}
 
-		// Check and handle any
-		// IRI type objects.
-		if object.IsIRI() {
+	return nil
+}
 
-			// Extract IRI from object.
-			iri := object.GetIRI()
-			if !uris.IsFollowPath(iri) {
-				continue
-			}
+func (f *federatingDB) acceptFollowType(
+	ctx context.Context,
+	asType vocab.Type,
+	receivingAcct *gtsmodel.Account,
+	requestingAcct *gtsmodel.Account,
+) error {
+	// Cast the vocab.Type object to known AS type.
+	asFollow := asType.(vocab.ActivityStreamsFollow)
 
-			// Serialize IRI.
-			iriStr := iri.String()
+	// Reconstruct the follow.
+	follow, err := f.converter.ASFollowToFollow(ctx, asFollow)
+	if err != nil {
+		err := gtserror.Newf("error converting Follow to *gtsmodel.Follow: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
 
-			// ACCEPT FOLLOW
-			followReq, err := f.state.DB.GetFollowRequestByURI(ctx, iriStr)
-			if err != nil {
-				return fmt.Errorf("ACCEPT: couldn't get follow request with id %s from the database: %s", iriStr, err)
-			}
+	// Lock on the Follow URI
+	// as we may be updating it.
+	unlock := f.state.FedLocks.Lock(follow.URI)
+	defer unlock()
 
-			// Make sure the creator of the original follow
-			// is the same as whatever inbox this landed in.
-			if followReq.AccountID != receivingAcct.ID {
-				return errors.New("ACCEPT: follow account and inbox account were not the same")
-			}
+	// Make sure the creator of the original follow
+	// is the same as whatever inbox this landed in.
+	if follow.AccountID != receivingAcct.ID {
+		const text = "Follow account and inbox account were not the same"
+		return gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
+	}
 
-			// Make sure the target of the original follow
-			// is the same as the account making the request.
-			if followReq.TargetAccountID != requestingAcct.ID {
-				return errors.New("ACCEPT: follow target account and requesting account were not the same")
-			}
+	// Make sure the target of the original follow
+	// is the same as the account making the request.
+	if follow.TargetAccountID != requestingAcct.ID {
+		const text = "Follow target account and requesting account were not the same"
+		return gtserror.NewErrorForbidden(errors.New(text), text)
+	}
 
-			follow, err := f.state.DB.AcceptFollowRequest(ctx, followReq.AccountID, followReq.TargetAccountID)
-			if err != nil {
-				return err
-			}
+	// Accept and get the populated follow back.
+	follow, err = f.state.DB.AcceptFollowRequest(
+		ctx,
+		follow.AccountID,
+		follow.TargetAccountID,
+	)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := gtserror.Newf("db error accepting follow request: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	if follow == nil {
+		// There was no follow request
+		// to accept, just return 202.
+		return nil
+	}
 
-			f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
-				APObjectType:   ap.ActivityFollow,
-				APActivityType: ap.ActivityAccept,
-				GTSModel:       follow,
-				Receiving:      receivingAcct,
-				Requesting:     requestingAcct,
-			})
+	// Send the accepted follow through
+	// the processor to do side effects.
+	f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+		APObjectType:   ap.ActivityFollow,
+		APActivityType: ap.ActivityAccept,
+		GTSModel:       follow,
+		Receiving:      receivingAcct,
+		Requesting:     requestingAcct,
+	})
 
-			continue
-		}
+	return nil
+}
+
+func (f *federatingDB) acceptFollowIRI(
+	ctx context.Context,
+	objectIRI string,
+	receivingAcct *gtsmodel.Account,
+	requestingAcct *gtsmodel.Account,
+) error {
+	// Lock on this potential Follow
+	// URI as we may be updating it.
+	unlock := f.state.FedLocks.Lock(objectIRI)
+	defer unlock()
 
+	// Get the follow req from the db.
+	followReq, err := f.state.DB.GetFollowRequestByURI(ctx, objectIRI)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := gtserror.Newf("db error getting follow request: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	if followReq == nil {
+		// We didn't have a follow request
+		// with this URI, so nothing to do.
+		// Just return.
+		return nil
+	}
+
+	// Make sure the creator of the original follow
+	// is the same as whatever inbox this landed in.
+	if followReq.AccountID != receivingAcct.ID {
+		const text = "Follow account and inbox account were not the same"
+		return gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
+	}
+
+	// Make sure the target of the original follow
+	// is the same as the account making the request.
+	if followReq.TargetAccountID != requestingAcct.ID {
+		const text = "Follow target account and requesting account were not the same"
+		return gtserror.NewErrorForbidden(errors.New(text), text)
+	}
+
+	// Accept and get the populated follow back.
+	follow, err := f.state.DB.AcceptFollowRequest(
+		ctx,
+		followReq.AccountID,
+		followReq.TargetAccountID,
+	)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := gtserror.Newf("db error accepting follow request: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	if follow == nil {
+		// There was no follow request
+		// to accept, just return 202.
+		return nil
 	}
 
+	// Send the accepted follow through
+	// the processor to do side effects.
+	f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+		APObjectType:   ap.ActivityFollow,
+		APActivityType: ap.ActivityAccept,
+		GTSModel:       follow,
+		Receiving:      receivingAcct,
+		Requesting:     requestingAcct,
+	})
+
+	return nil
+}
+
+func (f *federatingDB) acceptStatusIRI(
+	ctx context.Context,
+	activityID string,
+	objectIRI string,
+	receivingAcct *gtsmodel.Account,
+	requestingAcct *gtsmodel.Account,
+) error {
+	// Lock on this potential status
+	// URI as we may be updating it.
+	unlock := f.state.FedLocks.Lock(objectIRI)
+	defer unlock()
+
+	// Get the status from the db.
+	status, err := f.state.DB.GetStatusByURI(ctx, objectIRI)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := gtserror.Newf("db error getting status: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	if status == nil {
+		// We didn't have a status with
+		// this URI, so nothing to do.
+		// Just return.
+		return nil
+	}
+
+	if !status.IsLocal() {
+		// We don't process Accepts of statuses
+		// that weren't created on our instance.
+		// Just return.
+		return nil
+	}
+
+	if util.PtrOrValue(status.PendingApproval, false) {
+		// Status doesn't need approval or it's
+		// already been approved by an Accept.
+		// Just return.
+		return nil
+	}
+
+	// Make sure the creator of the original status
+	// is the same as the inbox processing the Accept;
+	// this also ensures the status is local.
+	if status.AccountID != receivingAcct.ID {
+		const text = "status author account and inbox account were not the same"
+		return gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
+	}
+
+	// Make sure the target of the interaction (reply/boost)
+	// is the same as the account doing the Accept.
+	if status.BoostOfAccountID != requestingAcct.ID &&
+		status.InReplyToAccountID != requestingAcct.ID {
+		const text = "status reply to or boost of account and requesting account were not the same"
+		return gtserror.NewErrorForbidden(errors.New(text), text)
+	}
+
+	// Mark the status as approved by this Accept URI.
+	status.PendingApproval = util.Ptr(false)
+	status.ApprovedByURI = activityID
+	if err := f.state.DB.UpdateStatus(
+		ctx,
+		status,
+		"pending_approval",
+		"approved_by_uri",
+	); err != nil {
+		err := gtserror.Newf("db error accepting status: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	var apObjectType string
+	if status.InReplyToID != "" {
+		// Accepting a Reply.
+		apObjectType = ap.ObjectNote
+	} else {
+		// Accepting an Announce.
+		apObjectType = ap.ActivityAnnounce
+	}
+
+	// Send the now-approved status through to the
+	// fedi worker again to process side effects.
+	f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+		APObjectType:   apObjectType,
+		APActivityType: ap.ActivityAccept,
+		GTSModel:       status,
+		Receiving:      receivingAcct,
+		Requesting:     requestingAcct,
+	})
+
+	return nil
+}
+
+func (f *federatingDB) acceptLikeIRI(
+	ctx context.Context,
+	activityID string,
+	objectIRI string,
+	receivingAcct *gtsmodel.Account,
+	requestingAcct *gtsmodel.Account,
+) error {
+	// Lock on this potential Like
+	// URI as we may be updating it.
+	unlock := f.state.FedLocks.Lock(objectIRI)
+	defer unlock()
+
+	// Get the fave from the db.
+	fave, err := f.state.DB.GetStatusFaveByURI(ctx, objectIRI)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := gtserror.Newf("db error getting fave: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	if fave == nil {
+		// We didn't have a fave with
+		// this URI, so nothing to do.
+		// Just return.
+		return nil
+	}
+
+	if !fave.Account.IsLocal() {
+		// We don't process Accepts of Likes
+		// that weren't created on our instance.
+		// Just return.
+		return nil
+	}
+
+	if !util.PtrOrValue(fave.PendingApproval, false) {
+		// Like doesn't need approval or it's
+		// already been approved by an Accept.
+		// Just return.
+		return nil
+	}
+
+	// Make sure the creator of the original Like
+	// is the same as the inbox processing the Accept;
+	// this also ensures the Like is local.
+	if fave.AccountID != receivingAcct.ID {
+		const text = "fave creator account and inbox account were not the same"
+		return gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
+	}
+
+	// Make sure the target of the Like is the
+	// same as the account doing the Accept.
+	if fave.TargetAccountID != requestingAcct.ID {
+		const text = "status fave target account and requesting account were not the same"
+		return gtserror.NewErrorForbidden(errors.New(text), text)
+	}
+
+	// Mark the fave as approved by this Accept URI.
+	fave.PendingApproval = util.Ptr(false)
+	fave.ApprovedByURI = activityID
+	if err := f.state.DB.UpdateStatusFave(
+		ctx,
+		fave,
+		"pending_approval",
+		"approved_by_uri",
+	); err != nil {
+		err := gtserror.Newf("db error accepting status: %w", err)
+		return gtserror.NewErrorInternalError(err)
+	}
+
+	// Send the now-approved fave through to the
+	// fedi worker again to process side effects.
+	f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+		APObjectType:   ap.ActivityLike,
+		APActivityType: ap.ActivityAccept,
+		GTSModel:       fave,
+		Receiving:      receivingAcct,
+		Requesting:     requestingAcct,
+	})
+
 	return nil
 }
diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go
index 12bd5a376..3388d7a03 100644
--- a/internal/federation/federatingdb/db.go
+++ b/internal/federation/federatingdb/db.go
@@ -19,6 +19,7 @@ package federatingdb
 
 import (
 	"context"
+	"net/url"
 
 	"github.com/superseriousbusiness/activity/pub"
 	"github.com/superseriousbusiness/activity/streams/vocab"
@@ -43,6 +44,12 @@ type DB interface {
 	Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error
 	Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error
 	Move(ctx context.Context, move vocab.ActivityStreamsMove) error
+
+	/*
+		Extra/convenience functionality.
+	*/
+
+	GetAccept(ctx context.Context, acceptIRI *url.URL) (vocab.ActivityStreamsAccept, error)
 }
 
 // FederatingDB uses the given state interface
diff --git a/internal/federation/federatingdb/get.go b/internal/federation/federatingdb/get.go
index eba58853f..5dcebb877 100644
--- a/internal/federation/federatingdb/get.go
+++ b/internal/federation/federatingdb/get.go
@@ -37,22 +37,30 @@ func (f *federatingDB) Get(ctx context.Context, id *url.URL) (value vocab.Type,
 	l.Debug("entering Get")
 
 	switch {
+
 	case uris.IsUserPath(id):
 		acct, err := f.state.DB.GetAccountByURI(ctx, id.String())
 		if err != nil {
 			return nil, err
 		}
 		return f.converter.AccountToAS(ctx, acct)
+
 	case uris.IsStatusesPath(id):
 		status, err := f.state.DB.GetStatusByURI(ctx, id.String())
 		if err != nil {
 			return nil, err
 		}
 		return f.converter.StatusToAS(ctx, status)
+
 	case uris.IsFollowersPath(id):
 		return f.Followers(ctx, id)
+
 	case uris.IsFollowingPath(id):
 		return f.Following(ctx, id)
+
+	case uris.IsAcceptsPath(id):
+		return f.GetAccept(ctx, id)
+
 	default:
 		return nil, fmt.Errorf("federatingDB: could not Get %s", id.String())
 	}
diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go
index 221663ccd..5f50fb046 100644
--- a/internal/gtsmodel/status.go
+++ b/internal/gtsmodel/status.go
@@ -68,6 +68,7 @@ type Status struct {
 	Federated                *bool              `bun:",notnull"`                                                    // This status will be federated beyond the local timeline(s)
 	InteractionPolicy        *InteractionPolicy `bun:""`                                                            // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
 	PendingApproval          *bool              `bun:",nullzero,notnull,default:false"`                             // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
+	PreApproved              bool               `bun:"-"`                                                           // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
 	ApprovedByURI            string             `bun:",nullzero"`                                                   // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
 }
 
diff --git a/internal/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go
index 644b3ca63..9d6c6335b 100644
--- a/internal/gtsmodel/statusfave.go
+++ b/internal/gtsmodel/statusfave.go
@@ -32,5 +32,6 @@ type StatusFave struct {
 	Status          *Status   `bun:"-"`                                                             // the faved status
 	URI             string    `bun:",nullzero,notnull,unique"`                                      // ActivityPub URI of this fave
 	PendingApproval *bool     `bun:",nullzero,notnull,default:false"`                               // If true then Like must be Approved by the like-ee before being fully distributed.
+	PreApproved     bool      `bun:"-"`                                                             // If true, then fave targets a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
 	ApprovedByURI   string    `bun:",nullzero"`                                                     // URI of an Accept Activity that approves this Like.
 }
diff --git a/internal/processing/fedi/accept.go b/internal/processing/fedi/accept.go
new file mode 100644
index 000000000..72d810f94
--- /dev/null
+++ b/internal/processing/fedi/accept.go
@@ -0,0 +1,84 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 fedi
+
+import (
+	"context"
+	"errors"
+
+	"github.com/superseriousbusiness/gotosocial/internal/ap"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+// AcceptGet handles the getting of a fedi/activitypub
+// representation of a local interaction approval.
+//
+// It performs appropriate authentication before
+// returning a JSON serializable interface.
+func (p *Processor) AcceptGet(
+	ctx context.Context,
+	requestedUser string,
+	approvalID string,
+) (interface{}, gtserror.WithCode) {
+	// Authenticate incoming request, getting related accounts.
+	auth, errWithCode := p.authenticate(ctx, requestedUser)
+	if errWithCode != nil {
+		return nil, errWithCode
+	}
+
+	if auth.handshakingURI != nil {
+		// We're currently handshaking, which means
+		// we don't know this account yet. This should
+		// be a very rare race condition.
+		err := gtserror.Newf("network race handshaking %s", auth.handshakingURI)
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	receivingAcct := auth.receivingAcct
+
+	approval, err := p.state.DB.GetInteractionApprovalByID(ctx, approvalID)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := gtserror.Newf("db error getting approval %s: %w", approvalID, err)
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	if approval.AccountID != receivingAcct.ID {
+		const text = "approval does not belong to receiving account"
+		return nil, gtserror.NewErrorNotFound(errors.New(text))
+	}
+
+	if approval == nil {
+		err := gtserror.Newf("approval %s not found", approvalID)
+		return nil, gtserror.NewErrorNotFound(err)
+	}
+
+	accept, err := p.converter.InteractionApprovalToASAccept(ctx, approval)
+	if err != nil {
+		err := gtserror.Newf("error converting approval: %w", err)
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	data, err := ap.Serialize(accept)
+	if err != nil {
+		err := gtserror.Newf("error serializing accept: %w", err)
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	return data, nil
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 5afcf0721..0afe8356b 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -177,8 +177,7 @@ func NewProcessor(
 	visFilter *visibility.Filter,
 	intFilter *interaction.Filter,
 ) *Processor {
-	var parseMentionFunc = GetParseMentionFunc(state, federator)
-
+	parseMentionFunc := GetParseMentionFunc(state, federator)
 	processor := &Processor{
 		converter:        converter,
 		oauthServer:      oauthServer,
diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go
index d6a0c2457..1b6e8bd47 100644
--- a/internal/processing/status/boost.go
+++ b/internal/processing/status/boost.go
@@ -104,9 +104,18 @@ func (p *Processor) BoostCreate(
 		// We're permitted to do this, but since
 		// we matched due to presence in a followers
 		// or following collection, we should mark
-		// as pending approval and wait for an accept.
+		// as pending approval and wait until we can
+		// prove it's been Accepted by the target.
 		pendingApproval = true
 
+		if *target.Local {
+			// If the target is local we don't need
+			// to wait for an Accept from remote,
+			// we can just preapprove it and have
+			// the processor create the Accept.
+			boost.PreApproved = true
+		}
+
 	case policyResult.Permitted():
 		// We're permitted to do this
 		// based on another kind of match.
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 10e19ac43..11dece87d 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -221,9 +221,18 @@ func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Ac
 		// We're permitted to do this, but since
 		// we matched due to presence in a followers
 		// or following collection, we should mark
-		// as pending approval and wait for an accept.
+		// as pending approval and wait until we can
+		// prove it's been Accepted by the target.
 		pendingApproval = true
 
+		if *inReplyTo.Local {
+			// If the target is local we don't need
+			// to wait for an Accept from remote,
+			// we can just preapprove it and have
+			// the processor create the Accept.
+			status.PreApproved = true
+		}
+
 	case policyResult.Permitted():
 		// We're permitted to do this
 		// based on another kind of match.
diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go
index 0f5a72b7d..497c4d465 100644
--- a/internal/processing/status/fave.go
+++ b/internal/processing/status/fave.go
@@ -103,8 +103,13 @@ func (p *Processor) FaveCreate(
 		return nil, gtserror.NewErrorForbidden(err, errText)
 	}
 
-	// Derive pendingApproval status.
-	var pendingApproval bool
+	// Derive pendingApproval
+	// and preapproved status.
+	var (
+		pendingApproval bool
+		preApproved     bool
+	)
+
 	switch {
 	case policyResult.WithApproval():
 		// We're allowed to do
@@ -115,9 +120,18 @@ func (p *Processor) FaveCreate(
 		// We're permitted to do this, but since
 		// we matched due to presence in a followers
 		// or following collection, we should mark
-		// as pending approval and wait for an accept.
+		// as pending approval and wait until we can
+		// prove it's been Accepted by the target.
 		pendingApproval = true
 
+		if *status.Local {
+			// If the target is local we don't need
+			// to wait for an Accept from remote,
+			// we can just preapprove it and have
+			// the processor create the Accept.
+			preApproved = true
+		}
+
 	case policyResult.Permitted():
 		// We're permitted to do this
 		// based on another kind of match.
@@ -138,6 +152,7 @@ func (p *Processor) FaveCreate(
 		StatusID:        status.ID,
 		Status:          status,
 		URI:             uris.GenerateURIForLike(requester.Username, faveID),
+		PreApproved:     preApproved,
 		PendingApproval: &pendingApproval,
 	}
 
diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go
index 3538c9958..d71bb0a83 100644
--- a/internal/processing/workers/federate.go
+++ b/internal/processing/workers/federate.go
@@ -23,12 +23,14 @@ import (
 
 	"github.com/superseriousbusiness/activity/pub"
 	"github.com/superseriousbusiness/activity/streams"
+	"github.com/superseriousbusiness/activity/streams/vocab"
 	"github.com/superseriousbusiness/gotosocial/internal/ap"
 	"github.com/superseriousbusiness/gotosocial/internal/federation"
 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/state"
 	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 // federate wraps functions for federating
@@ -135,6 +137,12 @@ func (f *federate) DeleteAccount(ctx context.Context, account *gtsmodel.Account)
 	return nil
 }
 
+// CreateStatus sends the given status out to relevant
+// recipients with the Outbox of the status creator.
+//
+// If the status is pending approval, then it will be
+// sent **ONLY** to the inbox of the account it replies to,
+// ignoring shared inboxes.
 func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) error {
 	// Do nothing if the status
 	// shouldn't be federated.
@@ -153,18 +161,32 @@ func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) er
 		return gtserror.Newf("error populating status: %w", err)
 	}
 
-	// Parse the outbox URI of the status author.
-	outboxIRI, err := parseURI(status.Account.OutboxURI)
-	if err != nil {
-		return err
-	}
-
 	// Convert status to AS Statusable implementing type.
 	statusable, err := f.converter.StatusToAS(ctx, status)
 	if err != nil {
 		return gtserror.Newf("error converting status to Statusable: %w", err)
 	}
 
+	// If status is pending approval,
+	// it must be a reply. Deliver it
+	// **ONLY** to the account it replies
+	// to, on behalf of the replier.
+	if util.PtrOrValue(status.PendingApproval, false) {
+		return f.deliverToInboxOnly(
+			ctx,
+			status.Account,
+			status.InReplyToAccount,
+			// Status has to be wrapped in Create activity.
+			typeutils.WrapStatusableInCreate(statusable, false),
+		)
+	}
+
+	// Parse the outbox URI of the status author.
+	outboxIRI, err := parseURI(status.Account.OutboxURI)
+	if err != nil {
+		return err
+	}
+
 	// Send a Create activity with Statusable via the Actor's outbox.
 	create := typeutils.WrapStatusableInCreate(statusable, false)
 	if _, err := f.FederatingActor().Send(ctx, outboxIRI, create); err != nil {
@@ -672,6 +694,12 @@ func (f *federate) RejectFollow(ctx context.Context, follow *gtsmodel.Follow) er
 	return nil
 }
 
+// Like sends the given fave out to relevant
+// recipients with the Outbox of the status creator.
+//
+// If the fave is pending approval, then it will be
+// sent **ONLY** to the inbox of the account it faves,
+// ignoring shared inboxes.
 func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error {
 	// Populate model.
 	if err := f.state.DB.PopulateStatusFave(ctx, fave); err != nil {
@@ -684,18 +712,30 @@ func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error {
 		return nil
 	}
 
-	// Parse relevant URI(s).
-	outboxIRI, err := parseURI(fave.Account.OutboxURI)
-	if err != nil {
-		return err
-	}
-
 	// Create the ActivityStreams Like.
 	like, err := f.converter.FaveToAS(ctx, fave)
 	if err != nil {
 		return gtserror.Newf("error converting fave to AS Like: %w", err)
 	}
 
+	// If fave is pending approval,
+	// deliver it **ONLY** to the account
+	// it faves, on behalf of the faver.
+	if util.PtrOrValue(fave.PendingApproval, false) {
+		return f.deliverToInboxOnly(
+			ctx,
+			fave.Account,
+			fave.TargetAccount,
+			like,
+		)
+	}
+
+	// Parse relevant URI(s).
+	outboxIRI, err := parseURI(fave.Account.OutboxURI)
+	if err != nil {
+		return err
+	}
+
 	// Send the Like via the Actor's outbox.
 	if _, err := f.FederatingActor().Send(
 		ctx, outboxIRI, like,
@@ -709,6 +749,12 @@ func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error {
 	return nil
 }
 
+// Announce sends the given boost out to relevant
+// recipients with the Outbox of the status creator.
+//
+// If the boost is pending approval, then it will be
+// sent **ONLY** to the inbox of the account it boosts,
+// ignoring shared inboxes.
 func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error {
 	// Populate model.
 	if err := f.state.DB.PopulateStatus(ctx, boost); err != nil {
@@ -721,12 +767,6 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error {
 		return nil
 	}
 
-	// Parse relevant URI(s).
-	outboxIRI, err := parseURI(boost.Account.OutboxURI)
-	if err != nil {
-		return err
-	}
-
 	// Create the ActivityStreams Announce.
 	announce, err := f.converter.BoostToAS(
 		ctx,
@@ -738,6 +778,24 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error {
 		return gtserror.Newf("error converting boost to AS: %w", err)
 	}
 
+	// If announce is pending approval,
+	// deliver it **ONLY** to the account
+	// it boosts, on behalf of the booster.
+	if util.PtrOrValue(boost.PendingApproval, false) {
+		return f.deliverToInboxOnly(
+			ctx,
+			boost.Account,
+			boost.BoostOfAccount,
+			announce,
+		)
+	}
+
+	// Parse relevant URI(s).
+	outboxIRI, err := parseURI(boost.Account.OutboxURI)
+	if err != nil {
+		return err
+	}
+
 	// Send the Announce via the Actor's outbox.
 	if _, err := f.FederatingActor().Send(
 		ctx, outboxIRI, announce,
@@ -751,6 +809,57 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error {
 	return nil
 }
 
+// deliverToInboxOnly delivers the given Activity
+// *only* to the inbox of targetAcct, on behalf of
+// sendingAcct, regardless of the `to` and `cc` values
+// set on the activity. This should be used specifically
+// for sending "pending approval" activities.
+func (f *federate) deliverToInboxOnly(
+	ctx context.Context,
+	sendingAcct *gtsmodel.Account,
+	targetAcct *gtsmodel.Account,
+	t vocab.Type,
+) error {
+	if targetAcct.IsLocal() {
+		// If this is a local target,
+		// they've already received it.
+		return nil
+	}
+
+	toInbox, err := url.Parse(targetAcct.InboxURI)
+	if err != nil {
+		return gtserror.Newf(
+			"error parsing target inbox uri: %w",
+			err,
+		)
+	}
+
+	tsport, err := f.TransportController().NewTransportForUsername(
+		ctx,
+		sendingAcct.Username,
+	)
+	if err != nil {
+		return gtserror.Newf(
+			"error getting transport to deliver activity %T to target inbox %s: %w",
+			t, targetAcct.InboxURI, err,
+		)
+	}
+
+	m, err := ap.Serialize(t)
+	if err != nil {
+		return err
+	}
+
+	if err := tsport.Deliver(ctx, m, toInbox); err != nil {
+		return gtserror.Newf(
+			"error delivering activity %T to target inbox %s: %w",
+			t, targetAcct.InboxURI, err,
+		)
+	}
+
+	return nil
+}
+
 func (f *federate) UpdateAccount(ctx context.Context, account *gtsmodel.Account) error {
 	// Populate model.
 	if err := f.state.DB.PopulateAccount(ctx, account); err != nil {
@@ -1015,3 +1124,75 @@ func (f *federate) MoveAccount(ctx context.Context, account *gtsmodel.Account) e
 
 	return nil
 }
+
+func (f *federate) AcceptInteraction(
+	ctx context.Context,
+	approval *gtsmodel.InteractionApproval,
+) error {
+	// Populate model.
+	if err := f.state.DB.PopulateInteractionApproval(ctx, approval); err != nil {
+		return gtserror.Newf("error populating approval: %w", err)
+	}
+
+	// Bail if interacting account is ours:
+	// we've already accepted internally and
+	// shouldn't send an Accept to ourselves.
+	if approval.InteractingAccount.IsLocal() {
+		return nil
+	}
+
+	// Bail if account isn't ours:
+	// we can't Accept on another
+	// instance's behalf. (This
+	// should never happen but...)
+	if approval.Account.IsRemote() {
+		return nil
+	}
+
+	// Parse relevant URI(s).
+	outboxIRI, err := parseURI(approval.Account.OutboxURI)
+	if err != nil {
+		return err
+	}
+
+	acceptingAcctIRI, err := parseURI(approval.Account.URI)
+	if err != nil {
+		return err
+	}
+
+	interactingAcctURI, err := parseURI(approval.InteractingAccount.URI)
+	if err != nil {
+		return err
+	}
+
+	interactionURI, err := parseURI(approval.InteractionURI)
+	if err != nil {
+		return err
+	}
+
+	// Create a new Accept.
+	accept := streams.NewActivityStreamsAccept()
+
+	// Set interacted-with account
+	// as Actor of the Accept.
+	ap.AppendActorIRIs(accept, acceptingAcctIRI)
+
+	// Set the interacted-with object
+	// as Object of the Accept.
+	ap.AppendObjectIRIs(accept, interactionURI)
+
+	// Address the Accept To the interacting acct.
+	ap.AppendTo(accept, interactingAcctURI)
+
+	// Send the Accept via the Actor's outbox.
+	if _, err := f.FederatingActor().Send(
+		ctx, outboxIRI, accept,
+	); err != nil {
+		return gtserror.Newf(
+			"error sending activity %T for %v via outbox %s: %w",
+			accept, approval.InteractionType, outboxIRI, err,
+		)
+	}
+
+	return nil
+}
diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go
index d5d4265e1..7f1b5780c 100644
--- a/internal/processing/workers/fromclientapi.go
+++ b/internal/processing/workers/fromclientapi.go
@@ -135,6 +135,18 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro
 		// ACCEPT USER (ie., new user+account sign-up)
 		case ap.ObjectProfile:
 			return p.clientAPI.AcceptUser(ctx, cMsg)
+
+		// ACCEPT NOTE/STATUS (ie., accept a reply)
+		case ap.ObjectNote:
+			return p.clientAPI.AcceptReply(ctx, cMsg)
+
+		// ACCEPT LIKE
+		case ap.ActivityLike:
+			return p.clientAPI.AcceptLike(ctx, cMsg)
+
+		// ACCEPT BOOST
+		case ap.ActivityAnnounce:
+			return p.clientAPI.AcceptAnnounce(ctx, cMsg)
 		}
 
 	// REJECT SOMETHING
@@ -236,6 +248,61 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA
 		return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel)
 	}
 
+	// If pending approval is true then status must
+	// reply to a status (either one of ours or a
+	// remote) that requires approval for the reply.
+	pendingApproval := util.PtrOrValue(
+		status.PendingApproval,
+		false,
+	)
+
+	switch {
+	case pendingApproval && !status.PreApproved:
+		// If approval is required and status isn't
+		// preapproved, then send out the Create to
+		// only the replied-to account (if it's remote),
+		// and/or notify the account that's being
+		// interacted with (if it's local): they can
+		// approve or deny the interaction later.
+
+		// Notify *local* account of pending reply.
+		if err := p.surface.notifyPendingReply(ctx, status); err != nil {
+			log.Errorf(ctx, "error notifying pending reply: %v", err)
+		}
+
+		// Send Create to *remote* account inbox ONLY.
+		if err := p.federate.CreateStatus(ctx, status); err != nil {
+			log.Errorf(ctx, "error federating pending reply: %v", err)
+		}
+
+		// Return early.
+		return nil
+
+	case pendingApproval && status.PreApproved:
+		// If approval is required and status is
+		// preapproved, that means this is a reply
+		// to one of our statuses with permission
+		// that matched on a following/followers
+		// collection. Do the Accept immediately and
+		// then process everything else as normal,
+		// sending out the Create with the approval
+		// URI attached.
+
+		// Put approval in the database and
+		// update the status with approvedBy URI.
+		approval, err := p.utils.approveReply(ctx, status)
+		if err != nil {
+			return gtserror.Newf("error pre-approving reply: %w", err)
+		}
+
+		// Send out the approval as Accept.
+		if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
+			return gtserror.Newf("error federating pre-approval of reply: %w", err)
+		}
+
+		// Don't return, just continue as normal.
+	}
+
 	// Update stats for the actor account.
 	if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, status); err != nil {
 		log.Errorf(ctx, "error updating account stats: %v", err)
@@ -362,6 +429,61 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI
 		return gtserror.Newf("error populating status fave: %w", err)
 	}
 
+	// If pending approval is true then fave must
+	// target a status (either one of ours or a
+	// remote) that requires approval for the fave.
+	pendingApproval := util.PtrOrValue(
+		fave.PendingApproval,
+		false,
+	)
+
+	switch {
+	case pendingApproval && !fave.PreApproved:
+		// If approval is required and fave isn't
+		// preapproved, then send out the Like to
+		// only the faved account (if it's remote),
+		// and/or notify the account that's being
+		// interacted with (if it's local): they can
+		// approve or deny the interaction later.
+
+		// Notify *local* account of pending reply.
+		if err := p.surface.notifyPendingFave(ctx, fave); err != nil {
+			log.Errorf(ctx, "error notifying pending fave: %v", err)
+		}
+
+		// Send Like to *remote* account inbox ONLY.
+		if err := p.federate.Like(ctx, fave); err != nil {
+			log.Errorf(ctx, "error federating pending Like: %v", err)
+		}
+
+		// Return early.
+		return nil
+
+	case pendingApproval && fave.PreApproved:
+		// If approval is required and fave is
+		// preapproved, that means this is a fave
+		// of one of our statuses with permission
+		// that matched on a following/followers
+		// collection. Do the Accept immediately and
+		// then process everything else as normal,
+		// sending out the Like with the approval
+		// URI attached.
+
+		// Put approval in the database and
+		// update the fave with approvedBy URI.
+		approval, err := p.utils.approveFave(ctx, fave)
+		if err != nil {
+			return gtserror.Newf("error pre-approving fave: %w", err)
+		}
+
+		// Send out the approval as Accept.
+		if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
+			return gtserror.Newf("error federating pre-approval of fave: %w", err)
+		}
+
+		// Don't return, just continue as normal.
+	}
+
 	if err := p.surface.notifyFave(ctx, fave); err != nil {
 		log.Errorf(ctx, "error notifying fave: %v", err)
 	}
@@ -383,6 +505,61 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien
 		return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel)
 	}
 
+	// If pending approval is true then status must
+	// boost a status (either one of ours or a
+	// remote) that requires approval for the boost.
+	pendingApproval := util.PtrOrValue(
+		boost.PendingApproval,
+		false,
+	)
+
+	switch {
+	case pendingApproval && !boost.PreApproved:
+		// If approval is required and boost isn't
+		// preapproved, then send out the Announce to
+		// only the boosted account (if it's remote),
+		// and/or notify the account that's being
+		// interacted with (if it's local): they can
+		// approve or deny the interaction later.
+
+		// Notify *local* account of pending announce.
+		if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil {
+			log.Errorf(ctx, "error notifying pending boost: %v", err)
+		}
+
+		// Send Announce to *remote* account inbox ONLY.
+		if err := p.federate.Announce(ctx, boost); err != nil {
+			log.Errorf(ctx, "error federating pending Announce: %v", err)
+		}
+
+		// Return early.
+		return nil
+
+	case pendingApproval && boost.PreApproved:
+		// If approval is required and boost is
+		// preapproved, that means this is a boost
+		// of one of our statuses with permission
+		// that matched on a following/followers
+		// collection. Do the Accept immediately and
+		// then process everything else as normal,
+		// sending out the Create with the approval
+		// URI attached.
+
+		// Put approval in the database and
+		// update the boost with approvedBy URI.
+		approval, err := p.utils.approveAnnounce(ctx, boost)
+		if err != nil {
+			return gtserror.Newf("error pre-approving boost: %w", err)
+		}
+
+		// Send out the approval as Accept.
+		if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
+			return gtserror.Newf("error federating pre-approval of boost: %w", err)
+		}
+
+		// Don't return, just continue as normal.
+	}
+
 	// Update stats for the actor account.
 	if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, boost); err != nil {
 		log.Errorf(ctx, "error updating account stats: %v", err)
@@ -874,3 +1051,18 @@ func (p *clientAPI) RejectUser(ctx context.Context, cMsg *messages.FromClientAPI
 
 	return nil
 }
+
+func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI) error {
+	// TODO
+	return nil
+}
+
+func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAPI) error {
+	// TODO
+	return nil
+}
+
+func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error {
+	// TODO
+	return nil
+}
diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go
index ac4003f6a..63d1f0d16 100644
--- a/internal/processing/workers/fromfediapi.go
+++ b/internal/processing/workers/fromfediapi.go
@@ -122,11 +122,23 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF
 
 	// ACCEPT SOMETHING
 	case ap.ActivityAccept:
-		switch fMsg.APObjectType { //nolint:gocritic
+		switch fMsg.APObjectType {
 
-		// ACCEPT FOLLOW
+		// ACCEPT (pending) FOLLOW
 		case ap.ActivityFollow:
 			return p.fediAPI.AcceptFollow(ctx, fMsg)
+
+		// ACCEPT (pending) LIKE
+		case ap.ActivityLike:
+			return p.fediAPI.AcceptLike(ctx, fMsg)
+
+		// ACCEPT (pending) REPLY
+		case ap.ObjectNote:
+			return p.fediAPI.AcceptReply(ctx, fMsg)
+
+		// ACCEPT (pending) ANNOUNCE
+		case ap.ActivityAnnounce:
+			return p.fediAPI.AcceptAnnounce(ctx, fMsg)
 		}
 
 	// DELETE SOMETHING
@@ -216,6 +228,52 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
 		return nil
 	}
 
+	// If pending approval is true then
+	// status must reply to a LOCAL status
+	// that requires approval for the reply.
+	pendingApproval := util.PtrOrValue(
+		status.PendingApproval,
+		false,
+	)
+
+	switch {
+	case pendingApproval && !status.PreApproved:
+		// If approval is required and status isn't
+		// preapproved, then just notify the account
+		// that's being interacted with: they can
+		// approve or deny the interaction later.
+
+		// Notify *local* account of pending reply.
+		if err := p.surface.notifyPendingReply(ctx, status); err != nil {
+			log.Errorf(ctx, "error notifying pending reply: %v", err)
+		}
+
+		// Return early.
+		return nil
+
+	case pendingApproval && status.PreApproved:
+		// If approval is required and status is
+		// preapproved, that means this is a reply
+		// to one of our statuses with permission
+		// that matched on a following/followers
+		// collection. Do the Accept immediately and
+		// then process everything else as normal.
+
+		// Put approval in the database and
+		// update the status with approvedBy URI.
+		approval, err := p.utils.approveReply(ctx, status)
+		if err != nil {
+			return gtserror.Newf("error pre-approving reply: %w", err)
+		}
+
+		// Send out the approval as Accept.
+		if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
+			return gtserror.Newf("error federating pre-approval of reply: %w", err)
+		}
+
+		// Don't return, just continue as normal.
+	}
+
 	// Update stats for the remote account.
 	if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, status); err != nil {
 		log.Errorf(ctx, "error updating account stats: %v", err)
@@ -348,6 +406,52 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er
 		return gtserror.Newf("error populating status fave: %w", err)
 	}
 
+	// If pending approval is true then
+	// fave must target a LOCAL status
+	// that requires approval for the fave.
+	pendingApproval := util.PtrOrValue(
+		fave.PendingApproval,
+		false,
+	)
+
+	switch {
+	case pendingApproval && !fave.PreApproved:
+		// If approval is required and fave isn't
+		// preapproved, then just notify the account
+		// that's being interacted with: they can
+		// approve or deny the interaction later.
+
+		// Notify *local* account of pending fave.
+		if err := p.surface.notifyPendingFave(ctx, fave); err != nil {
+			log.Errorf(ctx, "error notifying pending fave: %v", err)
+		}
+
+		// Return early.
+		return nil
+
+	case pendingApproval && fave.PreApproved:
+		// If approval is required and fave is
+		// preapproved, that means this is a fave
+		// of one of our statuses with permission
+		// that matched on a following/followers
+		// collection. Do the Accept immediately and
+		// then process everything else as normal.
+
+		// Put approval in the database and
+		// update the fave with approvedBy URI.
+		approval, err := p.utils.approveFave(ctx, fave)
+		if err != nil {
+			return gtserror.Newf("error pre-approving fave: %w", err)
+		}
+
+		// Send out the approval as Accept.
+		if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
+			return gtserror.Newf("error federating pre-approval of fave: %w", err)
+		}
+
+		// Don't return, just continue as normal.
+	}
+
 	if err := p.surface.notifyFave(ctx, fave); err != nil {
 		log.Errorf(ctx, "error notifying fave: %v", err)
 	}
@@ -365,8 +469,9 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
 		return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel)
 	}
 
-	// Dereference status that this boosts, note
-	// that this will handle storing the boost in
+	// Dereference into a boost wrapper status.
+	//
+	// Note: this will handle storing the boost in
 	// the db, and dereferencing the target status
 	// ancestors / descendants where appropriate.
 	var err error
@@ -376,8 +481,10 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
 		fMsg.Receiving.Username,
 	)
 	if err != nil {
-		if gtserror.IsUnretrievable(err) {
-			// Boosted status domain blocked, nothing to do.
+		if gtserror.IsUnretrievable(err) ||
+			gtserror.NotPermitted(err) {
+			// Boosted status domain blocked, or
+			// otherwise not permitted, nothing to do.
 			log.Debugf(ctx, "skipping announce: %v", err)
 			return nil
 		}
@@ -386,6 +493,52 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
 		return gtserror.Newf("error dereferencing announce: %w", err)
 	}
 
+	// If pending approval is true then
+	// boost must target a LOCAL status
+	// that requires approval for the boost.
+	pendingApproval := util.PtrOrValue(
+		boost.PendingApproval,
+		false,
+	)
+
+	switch {
+	case pendingApproval && !boost.PreApproved:
+		// If approval is required and boost isn't
+		// preapproved, then just notify the account
+		// that's being interacted with: they can
+		// approve or deny the interaction later.
+
+		// Notify *local* account of pending announce.
+		if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil {
+			log.Errorf(ctx, "error notifying pending boost: %v", err)
+		}
+
+		// Return early.
+		return nil
+
+	case pendingApproval && boost.PreApproved:
+		// If approval is required and status is
+		// preapproved, that means this is a boost
+		// of one of our statuses with permission
+		// that matched on a following/followers
+		// collection. Do the Accept immediately and
+		// then process everything else as normal.
+
+		// Put approval in the database and
+		// update the boost with approvedBy URI.
+		approval, err := p.utils.approveAnnounce(ctx, boost)
+		if err != nil {
+			return gtserror.Newf("error pre-approving boost: %w", err)
+		}
+
+		// Send out the approval as Accept.
+		if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
+			return gtserror.Newf("error federating pre-approval of boost: %w", err)
+		}
+
+		// Don't return, just continue as normal.
+	}
+
 	// Update stats for the remote account.
 	if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, boost); err != nil {
 		log.Errorf(ctx, "error updating account stats: %v", err)
@@ -549,6 +702,68 @@ func (p *fediAPI) AcceptFollow(ctx context.Context, fMsg *messages.FromFediAPI)
 	return nil
 }
 
+func (p *fediAPI) AcceptLike(ctx context.Context, fMsg *messages.FromFediAPI) error {
+	// TODO: Add something here if we ever implement sending out Likes to
+	// followers more broadly and not just the owner of the Liked status.
+	return nil
+}
+
+func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) error {
+	status, ok := fMsg.GTSModel.(*gtsmodel.Status)
+	if !ok {
+		return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel)
+	}
+
+	// Update stats for the actor account.
+	if err := p.utils.incrementStatusesCount(ctx, status.Account, status); err != nil {
+		log.Errorf(ctx, "error updating account stats: %v", err)
+	}
+
+	// Timeline and notify the status.
+	if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil {
+		log.Errorf(ctx, "error timelining and notifying status: %v", err)
+	}
+
+	// Interaction counts changed on the replied-to status;
+	// uncache the prepared version from all timelines.
+	p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
+
+	// Send out the reply again, fully this time.
+	if err := p.federate.CreateStatus(ctx, status); err != nil {
+		log.Errorf(ctx, "error federating announce: %v", err)
+	}
+
+	return nil
+}
+
+func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI) error {
+	boost, ok := fMsg.GTSModel.(*gtsmodel.Status)
+	if !ok {
+		return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel)
+	}
+
+	// Update stats for the actor account.
+	if err := p.utils.incrementStatusesCount(ctx, boost.Account, boost); err != nil {
+		log.Errorf(ctx, "error updating account stats: %v", err)
+	}
+
+	// Timeline and notify the boost wrapper status.
+	if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil {
+		log.Errorf(ctx, "error timelining and notifying status: %v", err)
+	}
+
+	// Interaction counts changed on the boosted status;
+	// uncache the prepared version from all timelines.
+	p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
+
+	// Send out the boost again, fully this time.
+	if err := p.federate.Announce(ctx, boost); err != nil {
+		log.Errorf(ctx, "error federating announce: %v", err)
+	}
+
+	return nil
+}
+
 func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) error {
 	// Cast the existing Status model attached to msg.
 	existing, ok := fMsg.GTSModel.(*gtsmodel.Status)
diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go
index edeb4b57e..872ccca65 100644
--- a/internal/processing/workers/surfacenotify.go
+++ b/internal/processing/workers/surfacenotify.go
@@ -32,6 +32,62 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
+// notifyPendingReply notifies the account replied-to
+// by the given status that they have a new reply,
+// and that approval is pending.
+func (s *Surface) notifyPendingReply(
+	ctx context.Context,
+	status *gtsmodel.Status,
+) error {
+	// Beforehand, ensure the passed status is fully populated.
+	if err := s.State.DB.PopulateStatus(ctx, status); err != nil {
+		return gtserror.Newf("error populating status %s: %w", status.ID, err)
+	}
+
+	if status.InReplyToAccount.IsRemote() {
+		// Don't notify
+		// remote accounts.
+		return nil
+	}
+
+	if status.AccountID == status.InReplyToAccountID {
+		// Don't notify
+		// self-replies.
+		return nil
+	}
+
+	// Ensure thread not muted
+	// by replied-to account.
+	muted, err := s.State.DB.IsThreadMutedByAccount(
+		ctx,
+		status.ThreadID,
+		status.InReplyToAccountID,
+	)
+	if err != nil {
+		return gtserror.Newf("error checking status thread mute %s: %w", status.ThreadID, err)
+	}
+
+	if muted {
+		// The replied-to account
+		// has muted the thread.
+		// Don't pester them.
+		return nil
+	}
+
+	// notify mentioned
+	// by status author.
+	if err := s.Notify(ctx,
+		gtsmodel.NotificationPendingReply,
+		status.InReplyToAccount,
+		status.Account,
+		status.ID,
+	); err != nil {
+		return gtserror.Newf("error notifying replied-to account %s: %w", status.InReplyToAccountID, err)
+	}
+
+	return nil
+}
+
 // notifyMentions iterates through mentions on the
 // given status, and notifies each mentioned account
 // that they have a new mention.
@@ -181,20 +237,82 @@ func (s *Surface) notifyFave(
 	ctx context.Context,
 	fave *gtsmodel.StatusFave,
 ) error {
+	notifyable, err := s.notifyableFave(ctx, fave)
+	if err != nil {
+		return err
+	}
+
+	if !notifyable {
+		// Nothing to do.
+		return nil
+	}
+
+	// notify status author
+	// of fave by account.
+	if err := s.Notify(ctx,
+		gtsmodel.NotificationFave,
+		fave.TargetAccount,
+		fave.Account,
+		fave.StatusID,
+	); err != nil {
+		return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err)
+	}
+
+	return nil
+}
+
+// notifyPendingFave notifies the target of the
+// given fave that their status has been faved
+// and that approval is required.
+func (s *Surface) notifyPendingFave(
+	ctx context.Context,
+	fave *gtsmodel.StatusFave,
+) error {
+	notifyable, err := s.notifyableFave(ctx, fave)
+	if err != nil {
+		return err
+	}
+
+	if !notifyable {
+		// Nothing to do.
+		return nil
+	}
+
+	// notify status author
+	// of fave by account.
+	if err := s.Notify(ctx,
+		gtsmodel.NotificationPendingFave,
+		fave.TargetAccount,
+		fave.Account,
+		fave.StatusID,
+	); err != nil {
+		return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err)
+	}
+
+	return nil
+}
+
+// notifyableFave checks that the given
+// fave should be notified, taking account
+// of localness of receiving account, and mutes.
+func (s *Surface) notifyableFave(
+	ctx context.Context,
+	fave *gtsmodel.StatusFave,
+) (bool, error) {
 	if fave.TargetAccountID == fave.AccountID {
 		// Self-fave, nothing to do.
-		return nil
+		return false, nil
 	}
 
 	// Beforehand, ensure the passed status fave is fully populated.
 	if err := s.State.DB.PopulateStatusFave(ctx, fave); err != nil {
-		return gtserror.Newf("error populating fave %s: %w", fave.ID, err)
+		return false, gtserror.Newf("error populating fave %s: %w", fave.ID, err)
 	}
 
 	if fave.TargetAccount.IsRemote() {
 		// no need to notify
 		// remote accounts.
-		return nil
+		return false, nil
 	}
 
 	// Ensure favee hasn't
@@ -205,54 +323,105 @@ func (s *Surface) notifyFave(
 		fave.TargetAccountID,
 	)
 	if err != nil {
-		return gtserror.Newf("error checking status thread mute %s: %w", fave.StatusID, err)
+		return false, gtserror.Newf("error checking status thread mute %s: %w", fave.StatusID, err)
 	}
 
 	if muted {
 		// Favee doesn't want
 		// notifs for this thread.
+		return false, nil
+	}
+
+	return true, nil
+}
+
+// notifyAnnounce notifies the status boost target
+// account that their status has been boosted.
+func (s *Surface) notifyAnnounce(
+	ctx context.Context,
+	boost *gtsmodel.Status,
+) error {
+	notifyable, err := s.notifyableAnnounce(ctx, boost)
+	if err != nil {
+		return err
+	}
+
+	if !notifyable {
+		// Nothing to do.
 		return nil
 	}
 
 	// notify status author
-	// of fave by account.
+	// of boost by account.
 	if err := s.Notify(ctx,
-		gtsmodel.NotificationFave,
-		fave.TargetAccount,
-		fave.Account,
-		fave.StatusID,
+		gtsmodel.NotificationReblog,
+		boost.BoostOfAccount,
+		boost.Account,
+		boost.ID,
 	); err != nil {
-		return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err)
+		return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err)
 	}
 
 	return nil
 }
 
-// notifyAnnounce notifies the status boost target
-// account that their status has been boosted.
-func (s *Surface) notifyAnnounce(
+// notifyPendingAnnounce notifies the status boost
+// target account that their status has been boosted,
+// and that the boost requires approval.
+func (s *Surface) notifyPendingAnnounce(
 	ctx context.Context,
-	status *gtsmodel.Status,
+	boost *gtsmodel.Status,
 ) error {
+	notifyable, err := s.notifyableAnnounce(ctx, boost)
+	if err != nil {
+		return err
+	}
+
+	if !notifyable {
+		// Nothing to do.
+		return nil
+	}
+
+	// notify status author
+	// of boost by account.
+	if err := s.Notify(ctx,
+		gtsmodel.NotificationPendingReblog,
+		boost.BoostOfAccount,
+		boost.Account,
+		boost.ID,
+	); err != nil {
+		return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err)
+	}
+
+	return nil
+}
+
+// notifyableAnnounce checks that the given
+// announce should be notified, taking account
+// of localness of receiving account, and mutes.
+func (s *Surface) notifyableAnnounce(
+	ctx context.Context,
+	status *gtsmodel.Status,
+) (bool, error) {
 	if status.BoostOfID == "" {
 		// Not a boost, nothing to do.
-		return nil
+		return false, nil
 	}
 
 	if status.BoostOfAccountID == status.AccountID {
 		// Self-boost, nothing to do.
-		return nil
+		return false, nil
 	}
 
 	// Beforehand, ensure the passed status is fully populated.
 	if err := s.State.DB.PopulateStatus(ctx, status); err != nil {
-		return gtserror.Newf("error populating status %s: %w", status.ID, err)
+		return false, gtserror.Newf("error populating status %s: %w", status.ID, err)
 	}
 
 	if status.BoostOfAccount.IsRemote() {
 		// no need to notify
 		// remote accounts.
-		return nil
+		return false, nil
 	}
 
 	// Ensure boostee hasn't
@@ -264,27 +433,16 @@ func (s *Surface) notifyAnnounce(
 	)
 
 	if err != nil {
-		return gtserror.Newf("error checking status thread mute %s: %w", status.BoostOfID, err)
+		return false, gtserror.Newf("error checking status thread mute %s: %w", status.BoostOfID, err)
 	}
 
 	if muted {
 		// Boostee doesn't want
 		// notifs for this thread.
-		return nil
+		return false, nil
 	}
 
-	// notify status author
-	// of boost by account.
-	if err := s.Notify(ctx,
-		gtsmodel.NotificationReblog,
-		status.BoostOfAccount,
-		status.Account,
-		status.ID,
-	); err != nil {
-		return gtserror.Newf("error notifying status author %s: %w", status.BoostOfAccountID, err)
-	}
-
-	return nil
+	return true, nil
 }
 
 func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) error {
diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go
index 915370976..994242d37 100644
--- a/internal/processing/workers/util.go
+++ b/internal/processing/workers/util.go
@@ -26,10 +26,13 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/id"
 	"github.com/superseriousbusiness/gotosocial/internal/log"
 	"github.com/superseriousbusiness/gotosocial/internal/processing/account"
 	"github.com/superseriousbusiness/gotosocial/internal/processing/media"
 	"github.com/superseriousbusiness/gotosocial/internal/state"
+	"github.com/superseriousbusiness/gotosocial/internal/uris"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 // util provides util functions used by both
@@ -498,3 +501,129 @@ func (u *utils) decrementFollowRequestsCount(
 
 	return nil
 }
+
+// approveFave stores + returns an
+// interactionApproval for a fave.
+func (u *utils) approveFave(
+	ctx context.Context,
+	fave *gtsmodel.StatusFave,
+) (*gtsmodel.InteractionApproval, error) {
+	id := id.NewULID()
+
+	approval := >smodel.InteractionApproval{
+		ID:                   id,
+		AccountID:            fave.TargetAccountID,
+		Account:              fave.TargetAccount,
+		InteractingAccountID: fave.AccountID,
+		InteractingAccount:   fave.Account,
+		InteractionURI:       fave.URI,
+		InteractionType:      gtsmodel.InteractionLike,
+		URI:                  uris.GenerateURIForAccept(fave.TargetAccount.Username, id),
+	}
+
+	if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil {
+		err := gtserror.Newf("db error inserting interaction approval: %w", err)
+		return nil, err
+	}
+
+	// Mark the fave itself as now approved.
+	fave.PendingApproval = util.Ptr(false)
+	fave.PreApproved = false
+	fave.ApprovedByURI = approval.URI
+
+	if err := u.state.DB.UpdateStatusFave(
+		ctx,
+		fave,
+		"pending_approval",
+		"approved_by_uri",
+	); err != nil {
+		err := gtserror.Newf("db error updating status fave: %w", err)
+		return nil, err
+	}
+
+	return approval, nil
+}
+
+// approveReply stores + returns an
+// interactionApproval for a reply.
+func (u *utils) approveReply(
+	ctx context.Context,
+	status *gtsmodel.Status,
+) (*gtsmodel.InteractionApproval, error) {
+	id := id.NewULID()
+
+	approval := >smodel.InteractionApproval{
+		ID:                   id,
+		AccountID:            status.InReplyToAccountID,
+		Account:              status.InReplyToAccount,
+		InteractingAccountID: status.AccountID,
+		InteractingAccount:   status.Account,
+		InteractionURI:       status.URI,
+		InteractionType:      gtsmodel.InteractionReply,
+		URI:                  uris.GenerateURIForAccept(status.InReplyToAccount.Username, id),
+	}
+
+	if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil {
+		err := gtserror.Newf("db error inserting interaction approval: %w", err)
+		return nil, err
+	}
+
+	// Mark the status itself as now approved.
+	status.PendingApproval = util.Ptr(false)
+	status.PreApproved = false
+	status.ApprovedByURI = approval.URI
+
+	if err := u.state.DB.UpdateStatus(
+		ctx,
+		status,
+		"pending_approval",
+		"approved_by_uri",
+	); err != nil {
+		err := gtserror.Newf("db error updating status: %w", err)
+		return nil, err
+	}
+
+	return approval, nil
+}
+
+// approveAnnounce stores + returns an
+// interactionApproval for an announce.
+func (u *utils) approveAnnounce(
+	ctx context.Context,
+	boost *gtsmodel.Status,
+) (*gtsmodel.InteractionApproval, error) {
+	id := id.NewULID()
+
+	approval := >smodel.InteractionApproval{
+		ID:                   id,
+		AccountID:            boost.BoostOfAccountID,
+		Account:              boost.BoostOfAccount,
+		InteractingAccountID: boost.AccountID,
+		InteractingAccount:   boost.Account,
+		InteractionURI:       boost.URI,
+		InteractionType:      gtsmodel.InteractionReply,
+		URI:                  uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id),
+	}
+
+	if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil {
+		err := gtserror.Newf("db error inserting interaction approval: %w", err)
+		return nil, err
+	}
+
+	// Mark the status itself as now approved.
+	boost.PendingApproval = util.Ptr(false)
+	boost.PreApproved = false
+	boost.ApprovedByURI = approval.URI
+
+	if err := u.state.DB.UpdateStatus(
+		ctx,
+		boost,
+		"pending_approval",
+		"approved_by_uri",
+	); err != nil {
+		err := gtserror.Newf("db error updating boost wrapper status: %w", err)
+		return nil, err
+	}
+
+	return approval, nil
+}
diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go
index aca502345..799557657 100644
--- a/internal/regexes/regexes.go
+++ b/internal/regexes/regexes.go
@@ -38,6 +38,7 @@ const (
 	follow    = "follow"
 	blocks    = "blocks"
 	reports   = "reports"
+	accepts   = "accepts"
 
 	schemes                  = `(http|https)://`                                         // Allowed URI protocols for parsing links in text.
 	alphaNumeric             = `\p{L}\p{M}*|\p{N}`                                       // A single number or script character in any language, including chars with accents.
@@ -71,6 +72,7 @@ const (
 	followPath        = userPathPrefix + `/` + follow + `/(` + ulid + `)$`
 	likePath          = userPathPrefix + `/` + liked + `/(` + ulid + `)$`
 	statusesPath      = userPathPrefix + `/` + statuses + `/(` + ulid + `)$`
+	acceptsPath       = userPathPrefix + `/` + accepts + `/(` + ulid + `)$`
 	blockPath         = userPathPrefix + `/` + blocks + `/(` + ulid + `)$`
 	reportPath        = `^/?` + reports + `/(` + ulid + `)$`
 	filePath          = `^/?(` + ulid + `)/([a-z]+)/([a-z]+)/(` + ulid + `)\.([a-z0-9]+)$`
@@ -158,6 +160,10 @@ var (
 	// from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R
 	ReportPath = regexp.MustCompile(reportPath)
 
+	// ReportPath parses a path that validates and captures the username part and the ulid part
+	// from eg /users/example_username/accepts/01GP3AWY4CRDVRNZKW0TEAMB5R
+	AcceptsPath = regexp.MustCompile(acceptsPath)
+
 	// FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]
 	// eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg
 	// It captures the account id, media type, media size, file name, and file extension, eg
diff --git a/internal/transport/controller.go b/internal/transport/controller.go
index 519298d8e..3a529a8f3 100644
--- a/internal/transport/controller.go
+++ b/internal/transport/controller.go
@@ -204,6 +204,38 @@ func (c *controller) dereferenceLocalUser(ctx context.Context, iri *url.URL) (*h
 	return rsp, nil
 }
 
+// dereferenceLocalAccept is a shortcut to dereference an accept created
+// by an account on this instance, without making any external api/http calls.
+//
+// It is passed to new transports, and should only be invoked when the iri.Host == this host.
+func (c *controller) dereferenceLocalAccept(ctx context.Context, iri *url.URL) (*http.Response, error) {
+	accept, err := c.fedDB.GetAccept(ctx, iri)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		return nil, err
+	}
+
+	if accept == nil {
+		// Return a generic 404 not found response.
+		rsp := craftResponse(iri, http.StatusNotFound)
+		return rsp, nil
+	}
+
+	i, err := ap.Serialize(accept)
+	if err != nil {
+		return nil, err
+	}
+
+	b, err := json.Marshal(i)
+	if err != nil {
+		return nil, err
+	}
+
+	// Return a response with AS data as body.
+	rsp := craftResponse(iri, http.StatusOK)
+	rsp.Body = io.NopCloser(bytes.NewReader(b))
+	return rsp, nil
+}
+
 func craftResponse(url *url.URL, code int) *http.Response {
 	rsp := new(http.Response)
 	rsp.Request = new(http.Request)
diff --git a/internal/transport/dereference.go b/internal/transport/dereference.go
index 952791f70..8cc1f2103 100644
--- a/internal/transport/dereference.go
+++ b/internal/transport/dereference.go
@@ -29,17 +29,26 @@ import (
 )
 
 func (t *transport) Dereference(ctx context.Context, iri *url.URL) (*http.Response, error) {
-	// if the request is to us, we can shortcut for certain URIs rather than going through
-	// the normal request flow, thereby saving time and energy
+	// If the request is to us, we can shortcut for
+	// certain URIs rather than going through the normal
+	// request flow, thereby saving time and energy.
 	if iri.Host == config.GetHost() {
-		if uris.IsFollowersPath(iri) {
-			// the request is for followers of one of our accounts, which we can shortcut
+		switch {
+
+		case uris.IsFollowersPath(iri):
+			// The request is for followers of one of
+			// our accounts, which we can shortcut.
 			return t.controller.dereferenceLocalFollowers(ctx, iri)
-		}
 
-		if uris.IsUserPath(iri) {
-			// the request is for one of our accounts, which we can shortcut
+		case uris.IsUserPath(iri):
+			// The request is for one of our
+			// accounts, which we can shortcut.
 			return t.controller.dereferenceLocalUser(ctx, iri)
+
+		case uris.IsAcceptsPath(iri):
+			// The request is for an Accept on
+			// our instance, which we can shortcut.
+			return t.controller.dereferenceLocalAccept(ctx, iri)
 		}
 	}
 
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
index cb3e320d9..2946c8d09 100644
--- a/internal/typeutils/astointernal.go
+++ b/internal/typeutils/astointernal.go
@@ -393,13 +393,23 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab
 		return nil, gtserror.SetMalformed(err)
 	}
 
-	// Advanced visibility toggles for this status.
-	//
-	// TODO: a lot of work to be done here -- a new type
-	// needs to be created for this in go-fed/activity.
-	// Until this is implemented, assume all true.
+	// Status was sent to us or dereffed
+	// by us so it must be federated.
 	status.Federated = util.Ptr(true)
 
+	// Derive interaction policy for this status.
+	status.InteractionPolicy = ap.ExtractInteractionPolicy(
+		statusable,
+		status.Account,
+	)
+
+	// Set approvedByURI if present,
+	// for later dereferencing.
+	approvedByURI := ap.GetApprovedBy(statusable)
+	if approvedByURI != nil {
+		status.ApprovedByURI = approvedByURI.String()
+	}
+
 	// status.Sensitive
 	sensitive := ap.ExtractSensitive(statusable)
 	status.Sensitive = &sensitive
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
index 567493673..31b256b6c 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -36,6 +36,7 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/log"
 	"github.com/superseriousbusiness/gotosocial/internal/uris"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
 )
 
 // AccountToAS converts a gts model account into an activity streams person, suitable for federation
@@ -672,6 +673,38 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Stat
 	sensitiveProp.AppendXMLSchemaBoolean(*s.Sensitive)
 	status.SetActivityStreamsSensitive(sensitiveProp)
 
+	// interactionPolicy
+	var p *gtsmodel.InteractionPolicy
+	if s.InteractionPolicy != nil {
+		// Use InteractionPolicy
+		// set on the status.
+		p = s.InteractionPolicy
+	} else {
+		// Fall back to default policy
+		// for the status's visibility.
+		p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility)
+	}
+	policy, err := c.InteractionPolicyToASInteractionPolicy(ctx, p, s)
+	if err != nil {
+		return nil, fmt.Errorf("error creating interactionPolicy: %w", err)
+	}
+
+	policyProp := streams.NewGoToSocialInteractionPolicyProperty()
+	policyProp.AppendGoToSocialInteractionPolicy(policy)
+	status.SetGoToSocialInteractionPolicy(policyProp)
+
+	// Parse + set approvedBy.
+	if s.ApprovedByURI != "" {
+		approvedBy, err := url.Parse(s.ApprovedByURI)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing approvedBy: %w", err)
+		}
+
+		approvedByProp := streams.NewGoToSocialApprovedByProperty()
+		approvedByProp.Set(approvedBy)
+		status.SetGoToSocialApprovedBy(approvedByProp)
+	}
+
 	return status, nil
 }
 
@@ -1169,6 +1202,18 @@ func (c *Converter) FaveToAS(ctx context.Context, f *gtsmodel.StatusFave) (vocab
 	toProp.AppendIRI(toIRI)
 	like.SetActivityStreamsTo(toProp)
 
+	// Parse + set approvedBy.
+	if f.ApprovedByURI != "" {
+		approvedBy, err := url.Parse(f.ApprovedByURI)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing approvedBy: %w", err)
+		}
+
+		approvedByProp := streams.NewGoToSocialApprovedByProperty()
+		approvedByProp.Set(approvedBy)
+		like.SetGoToSocialApprovedBy(approvedByProp)
+	}
+
 	return like, nil
 }
 
@@ -1247,6 +1292,18 @@ func (c *Converter) BoostToAS(ctx context.Context, boostWrapperStatus *gtsmodel.
 
 	announce.SetActivityStreamsCc(ccProp)
 
+	// Parse + set approvedBy.
+	if boostWrapperStatus.ApprovedByURI != "" {
+		approvedBy, err := url.Parse(boostWrapperStatus.ApprovedByURI)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing approvedBy: %w", err)
+		}
+
+		approvedByProp := streams.NewGoToSocialApprovedByProperty()
+		approvedByProp.Set(approvedBy)
+		announce.SetGoToSocialApprovedBy(approvedByProp)
+	}
+
 	return announce, nil
 }
 
@@ -1724,3 +1781,227 @@ func (c *Converter) PollVoteToASCreate(
 
 	return create, nil
 }
+
+// populateValuesForProp appends the given PolicyValues
+// to the given property, for the given status.
+func populateValuesForProp[T ap.WithIRI](
+	prop ap.Property[T],
+	status *gtsmodel.Status,
+	urns gtsmodel.PolicyValues,
+) error {
+	iriStrs := make([]string, 0)
+
+	for _, urn := range urns {
+		switch urn {
+
+		case gtsmodel.PolicyValueAuthor:
+			iriStrs = append(iriStrs, status.Account.URI)
+
+		case gtsmodel.PolicyValueMentioned:
+			for _, m := range status.Mentions {
+				iriStrs = append(iriStrs, m.TargetAccount.URI)
+			}
+
+		case gtsmodel.PolicyValueFollowing:
+			iriStrs = append(iriStrs, status.Account.FollowingURI)
+
+		case gtsmodel.PolicyValueFollowers:
+			iriStrs = append(iriStrs, status.Account.FollowersURI)
+
+		case gtsmodel.PolicyValuePublic:
+			iriStrs = append(iriStrs, pub.PublicActivityPubIRI)
+
+		default:
+			iriStrs = append(iriStrs, string(urn))
+		}
+	}
+
+	// Deduplicate the iri strings to
+	// make sure we're not parsing + adding
+	// the same string multiple times.
+	iriStrs = util.Deduplicate(iriStrs)
+
+	// Append them to the property.
+	for _, iriStr := range iriStrs {
+		iri, err := url.Parse(iriStr)
+		if err != nil {
+			return err
+		}
+
+		prop.AppendIRI(iri)
+	}
+
+	return nil
+}
+
+// InteractionPolicyToASInteractionPolicy returns a
+// GoToSocial interaction policy suitable for federation.
+func (c *Converter) InteractionPolicyToASInteractionPolicy(
+	ctx context.Context,
+	interactionPolicy *gtsmodel.InteractionPolicy,
+	status *gtsmodel.Status,
+) (vocab.GoToSocialInteractionPolicy, error) {
+	policy := streams.NewGoToSocialInteractionPolicy()
+
+	/*
+		CAN LIKE
+	*/
+
+	// Build canLike
+	canLike := streams.NewGoToSocialCanLike()
+
+	// Build canLike.always
+	canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty()
+	if err := populateValuesForProp(
+		canLikeAlwaysProp,
+		status,
+		interactionPolicy.CanLike.Always,
+	); err != nil {
+		return nil, gtserror.Newf("error setting canLike.always: %w", err)
+	}
+
+	// Set canLike.always
+	canLike.SetGoToSocialAlways(canLikeAlwaysProp)
+
+	// Build canLike.approvalRequired
+	canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty()
+	if err := populateValuesForProp(
+		canLikeApprovalRequiredProp,
+		status,
+		interactionPolicy.CanLike.WithApproval,
+	); err != nil {
+		return nil, gtserror.Newf("error setting canLike.approvalRequired: %w", err)
+	}
+
+	// Set canLike.approvalRequired.
+	canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp)
+
+	// Set canLike on the policy.
+	canLikeProp := streams.NewGoToSocialCanLikeProperty()
+	canLikeProp.AppendGoToSocialCanLike(canLike)
+	policy.SetGoToSocialCanLike(canLikeProp)
+
+	/*
+		CAN REPLY
+	*/
+
+	// Build canReply
+	canReply := streams.NewGoToSocialCanReply()
+
+	// Build canReply.always
+	canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty()
+	if err := populateValuesForProp(
+		canReplyAlwaysProp,
+		status,
+		interactionPolicy.CanReply.Always,
+	); err != nil {
+		return nil, gtserror.Newf("error setting canReply.always: %w", err)
+	}
+
+	// Set canReply.always
+	canReply.SetGoToSocialAlways(canReplyAlwaysProp)
+
+	// Build canReply.approvalRequired
+	canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty()
+	if err := populateValuesForProp(
+		canReplyApprovalRequiredProp,
+		status,
+		interactionPolicy.CanReply.WithApproval,
+	); err != nil {
+		return nil, gtserror.Newf("error setting canReply.approvalRequired: %w", err)
+	}
+
+	// Set canReply.approvalRequired.
+	canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp)
+
+	// Set canReply on the policy.
+	canReplyProp := streams.NewGoToSocialCanReplyProperty()
+	canReplyProp.AppendGoToSocialCanReply(canReply)
+	policy.SetGoToSocialCanReply(canReplyProp)
+
+	/*
+		CAN ANNOUNCE
+	*/
+
+	// Build canAnnounce
+	canAnnounce := streams.NewGoToSocialCanAnnounce()
+
+	// Build canAnnounce.always
+	canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty()
+	if err := populateValuesForProp(
+		canAnnounceAlwaysProp,
+		status,
+		interactionPolicy.CanAnnounce.Always,
+	); err != nil {
+		return nil, gtserror.Newf("error setting canAnnounce.always: %w", err)
+	}
+
+	// Set canAnnounce.always
+	canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp)
+
+	// Build canAnnounce.approvalRequired
+	canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty()
+	if err := populateValuesForProp(
+		canAnnounceApprovalRequiredProp,
+		status,
+		interactionPolicy.CanAnnounce.WithApproval,
+	); err != nil {
+		return nil, gtserror.Newf("error setting canAnnounce.approvalRequired: %w", err)
+	}
+
+	// Set canAnnounce.approvalRequired.
+	canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp)
+
+	// Set canAnnounce on the policy.
+	canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty()
+	canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce)
+	policy.SetGoToSocialCanAnnounce(canAnnounceProp)
+
+	return policy, nil
+}
+
+// InteractionApprovalToASAccept converts a *gtsmodel.InteractionApproval
+// to an ActivityStreams Accept, addressed to the interacting account.
+func (c *Converter) InteractionApprovalToASAccept(
+	ctx context.Context,
+	approval *gtsmodel.InteractionApproval,
+) (vocab.ActivityStreamsAccept, error) {
+	accept := streams.NewActivityStreamsAccept()
+
+	acceptID, err := url.Parse(approval.URI)
+	if err != nil {
+		return nil, gtserror.Newf("invalid accept uri: %w", err)
+	}
+
+	actorIRI, err := url.Parse(approval.Account.URI)
+	if err != nil {
+		return nil, gtserror.Newf("invalid account uri: %w", err)
+	}
+
+	objectIRI, err := url.Parse(approval.InteractionURI)
+	if err != nil {
+		return nil, gtserror.Newf("invalid target uri: %w", err)
+	}
+
+	toIRI, err := url.Parse(approval.InteractingAccount.URI)
+	if err != nil {
+		return nil, gtserror.Newf("invalid interacting account uri: %w", err)
+	}
+
+	// Set id to the URI of
+	// interactionApproval.
+	ap.SetJSONLDId(accept, acceptID)
+
+	// Actor is the account that
+	// owns the approval / accept.
+	ap.AppendActorIRIs(accept, actorIRI)
+
+	// Object is the interaction URI.
+	ap.AppendObjectIRIs(accept, objectIRI)
+
+	// Address to the owner
+	// of interaction URI.
+	ap.AppendTo(accept, toIRI)
+
+	return accept, nil
+}
diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go
index ca8143436..905dccfad 100644
--- a/internal/typeutils/internaltoas_test.go
+++ b/internal/typeutils/internaltoas_test.go
@@ -21,8 +21,6 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
-	"fmt"
-	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/suite"
@@ -46,14 +44,15 @@ func (suite *InternalToASTestSuite) TestAccountToAS() {
 	ser, err := ap.Serialize(asPerson)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// trim off everything up to 'discoverable';
-	// this is necessary because the order of multiple 'context' entries is not determinate
-	trimmed := strings.Split(string(bytes), "\"discoverable\"")[1]
-
-	suite.Equal(`: true,
+	suite.Equal(`{
+  "discoverable": true,
   "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured",
   "followers": "http://localhost:8080/users/the_mighty_zork/followers",
   "following": "http://localhost:8080/users/the_mighty_zork/following",
@@ -82,7 +81,7 @@ func (suite *InternalToASTestSuite) TestAccountToAS() {
   "tag": [],
   "type": "Person",
   "url": "http://localhost:8080/@the_mighty_zork"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestAccountToASWithFields() {
@@ -95,16 +94,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() {
 	ser, err := ap.Serialize(asPerson)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// trim off everything up to 'attachment';
-	// this is necessary because the order of multiple 'context' entries is not determinate
-	trimmed := strings.Split(string(bytes), "\"attachment\"")[1]
-
-	fmt.Printf("\n\n\n%s\n\n\n", string(bytes))
-
-	suite.Equal(`: [
+	suite.Equal(`{
+  "attachment": [
     {
       "name": "should you follow me?",
       "type": "PropertyValue",
@@ -135,7 +133,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() {
   "tag": [],
   "type": "Person",
   "url": "http://localhost:8080/@1happyturtle"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() {
@@ -161,14 +159,15 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() {
 	ser, err := ap.Serialize(asPerson)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// trim off everything up to 'alsoKnownAs';
-	// this is necessary because the order of multiple 'context' entries is not determinate
-	trimmed := strings.Split(string(bytes), "\"alsoKnownAs\"")[1]
-
-	suite.Equal(`: [
+	suite.Equal(`{
+  "alsoKnownAs": [
     "http://localhost:8080/users/1happyturtle"
   ],
   "discoverable": true,
@@ -201,7 +200,7 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() {
   "tag": [],
   "type": "Person",
   "url": "http://localhost:8080/@the_mighty_zork"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestAccountToASWithOneField() {
@@ -215,15 +214,16 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() {
 	ser, err := ap.Serialize(asPerson)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// trim off everything up to 'attachment';
-	// this is necessary because the order of multiple 'context' entries is not determinate
-	trimmed := strings.Split(string(bytes), "\"attachment\"")[1]
-
 	// Despite only one field being set, attachments should still be a slice/array.
-	suite.Equal(`: [
+	suite.Equal(`{
+  "attachment": [
     {
       "name": "should you follow me?",
       "type": "PropertyValue",
@@ -249,7 +249,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() {
   "tag": [],
   "type": "Person",
   "url": "http://localhost:8080/@1happyturtle"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() {
@@ -263,14 +263,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() {
 	ser, err := ap.Serialize(asPerson)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// trim off everything up to 'discoverable';
-	// this is necessary because the order of multiple 'context' entries is not determinate
-	trimmed := strings.Split(string(bytes), "\"discoverable\"")[1]
-
-	suite.Equal(`: true,
+	suite.Equal(`{
+  "discoverable": true,
   "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured",
   "followers": "http://localhost:8080/users/the_mighty_zork/followers",
   "following": "http://localhost:8080/users/the_mighty_zork/following",
@@ -309,7 +310,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() {
   },
   "type": "Person",
   "url": "http://localhost:8080/@the_mighty_zork"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() {
@@ -324,14 +325,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() {
 	ser, err := ap.Serialize(asPerson)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// trim off everything up to 'discoverable';
-	// this is necessary because the order of multiple 'context' entries is not determinate
-	trimmed := strings.Split(string(bytes), "\"discoverable\"")[1]
-
-	suite.Equal(`: true,
+	suite.Equal(`{
+  "discoverable": true,
   "endpoints": {
     "sharedInbox": "http://localhost:8080/sharedInbox"
   },
@@ -363,7 +365,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() {
   "tag": [],
   "type": "Person",
   "url": "http://localhost:8080/@the_mighty_zork"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestStatusToAS() {
@@ -376,11 +378,14 @@ func (suite *InternalToASTestSuite) TestStatusToAS() {
 	ser, err := ap.Serialize(asStatus)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
 	suite.Equal(`{
-  "@context": "https://www.w3.org/ns/activitystreams",
   "attachment": [],
   "attributedTo": "http://localhost:8080/users/the_mighty_zork",
   "cc": "http://localhost:8080/users/the_mighty_zork/followers",
@@ -389,6 +394,26 @@ func (suite *InternalToASTestSuite) TestStatusToAS() {
     "en": "hello everyone!"
   },
   "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+  "interactionPolicy": {
+    "canAnnounce": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canLike": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canReply": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    }
+  },
   "published": "2021-10-20T12:40:37+02:00",
   "replies": {
     "first": {
@@ -420,14 +445,15 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() {
 	ser, err := ap.Serialize(asStatus)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// we can't be sure in what order the two context entries --
-	// http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams --
-	// will appear, so trim them out of the string for consistency
-	trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1]
-	suite.Equal(` [
+	suite.Equal(`{
+  "attachment": [
     {
       "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
       "mediaType": "image/jpeg",
@@ -443,6 +469,26 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() {
     "en": "hello world! #welcome ! first post on the instance :rainbow: !"
   },
   "id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
+  "interactionPolicy": {
+    "canAnnounce": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canLike": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canReply": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    }
+  },
   "published": "2021-10-20T11:36:45Z",
   "replies": {
     "first": {
@@ -477,7 +523,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() {
   "to": "https://www.w3.org/ns/activitystreams#Public",
   "type": "Note",
   "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() {
@@ -492,14 +538,15 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() {
 	ser, err := ap.Serialize(asStatus)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
-	// we can't be sure in what order the two context entries --
-	// http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams --
-	// will appear, so trim them out of the string for consistency
-	trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1]
-	suite.Equal(` [
+	suite.Equal(`{
+  "attachment": [
     {
       "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
       "mediaType": "image/jpeg",
@@ -515,6 +562,26 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() {
     "en": "hello world! #welcome ! first post on the instance :rainbow: !"
   },
   "id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
+  "interactionPolicy": {
+    "canAnnounce": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canLike": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canReply": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    }
+  },
   "published": "2021-10-20T11:36:45Z",
   "replies": {
     "first": {
@@ -549,7 +616,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() {
   "to": "https://www.w3.org/ns/activitystreams#Public",
   "type": "Note",
   "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"
-}`, trimmed)
+}`, string(bytes))
 }
 
 func (suite *InternalToASTestSuite) TestStatusToASWithMentions() {
@@ -565,11 +632,14 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() {
 	ser, err := ap.Serialize(asStatus)
 	suite.NoError(err)
 
+	// Drop "@context" property as
+	// the ordering is non-determinate.
+	delete(ser, "@context")
+
 	bytes, err := json.MarshalIndent(ser, "", "  ")
 	suite.NoError(err)
 
 	suite.Equal(`{
-  "@context": "https://www.w3.org/ns/activitystreams",
   "attachment": [],
   "attributedTo": "http://localhost:8080/users/admin",
   "cc": [
@@ -582,6 +652,26 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() {
   },
   "id": "http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0",
   "inReplyTo": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+  "interactionPolicy": {
+    "canAnnounce": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canLike": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    },
+    "canReply": {
+      "always": [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "approvalRequired": []
+    }
+  },
   "published": "2021-11-20T13:32:16Z",
   "replies": {
     "first": {
@@ -967,6 +1057,51 @@ func (suite *InternalToASTestSuite) TestPollVoteToASCreate() {
 }`, string(bytes))
 }
 
+func (suite *InternalToASTestSuite) TestInteractionApprovalToASAccept() {
+	acceptingAccount := suite.testAccounts["local_account_1"]
+	interactingAccount := suite.testAccounts["remote_account_1"]
+
+	interactionApproval := >smodel.InteractionApproval{
+		ID:                   "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
+		CreatedAt:            testrig.TimeMustParse("2022-06-09T13:12:00Z"),
+		UpdatedAt:            testrig.TimeMustParse("2022-06-09T13:12:00Z"),
+		AccountID:            acceptingAccount.ID,
+		Account:              acceptingAccount,
+		InteractingAccountID: interactingAccount.ID,
+		InteractingAccount:   interactingAccount,
+		InteractionURI:       "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
+		InteractionType:      gtsmodel.InteractionAnnounce,
+		URI:                  "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
+	}
+
+	accept, err := suite.typeconverter.InteractionApprovalToASAccept(
+		context.Background(),
+		interactionApproval,
+	)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	i, err := ap.Serialize(accept)
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	b, err := json.MarshalIndent(i, "", "  ")
+	if err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Equal(`{
+  "@context": "https://www.w3.org/ns/activitystreams",
+  "actor": "http://localhost:8080/users/the_mighty_zork",
+  "id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
+  "object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
+  "to": "http://fossbros-anonymous.io/users/foss_satan",
+  "type": "Accept"
+}`, string(b))
+}
+
 func TestInternalToASTestSuite(t *testing.T) {
 	suite.Run(t, new(InternalToASTestSuite))
 }
diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go
index 453073ed6..833b18bac 100644
--- a/internal/typeutils/wrap_test.go
+++ b/internal/typeutils/wrap_test.go
@@ -72,11 +72,14 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() {
 	createI, err := ap.Serialize(create)
 	suite.NoError(err)
 
+	// Chop off @context since
+	// ordering is non-determinate.
+	delete(createI, "@context")
+
 	bytes, err := json.MarshalIndent(createI, "", "  ")
 	suite.NoError(err)
 
 	suite.Equal(`{
-  "@context": "https://www.w3.org/ns/activitystreams",
   "actor": "http://localhost:8080/users/the_mighty_zork",
   "cc": "http://localhost:8080/users/the_mighty_zork/followers",
   "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity#Create",
@@ -89,6 +92,26 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() {
       "en": "hello everyone!"
     },
     "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+    "interactionPolicy": {
+      "canAnnounce": {
+        "always": [
+          "https://www.w3.org/ns/activitystreams#Public"
+        ],
+        "approvalRequired": []
+      },
+      "canLike": {
+        "always": [
+          "https://www.w3.org/ns/activitystreams#Public"
+        ],
+        "approvalRequired": []
+      },
+      "canReply": {
+        "always": [
+          "https://www.w3.org/ns/activitystreams#Public"
+        ],
+        "approvalRequired": []
+      }
+    },
     "published": "2021-10-20T12:40:37+02:00",
     "replies": {
       "first": {
diff --git a/internal/uris/uri.go b/internal/uris/uri.go
index 335461d84..159508176 100644
--- a/internal/uris/uri.go
+++ b/internal/uris/uri.go
@@ -46,6 +46,7 @@ const (
 	FileserverPath   = "fileserver"    // FileserverPath is a path component for serving attachments + media
 	EmojiPath        = "emoji"         // EmojiPath represents the activitypub emoji location
 	TagsPath         = "tags"          // TagsPath represents the activitypub tags location
+	AcceptsPath      = "accepts"       // AcceptsPath represents the activitypub accepts location
 )
 
 // UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc.
@@ -136,6 +137,14 @@ func GenerateURIForEmailConfirm(token string) string {
 	return fmt.Sprintf("%s://%s/%s?token=%s", protocol, host, ConfirmEmailPath, token)
 }
 
+// GenerateURIForAccept returns the AP URI for a new accept activity -- something like:
+// https://example.org/users/whatever_user/accepts/01F7XTH1QGBAPMGF49WJZ91XGC
+func GenerateURIForAccept(username string, thisAcceptID string) string {
+	protocol := config.GetProtocol()
+	host := config.GetHost()
+	return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, AcceptsPath, thisAcceptID)
+}
+
 // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host.
 func GenerateURIsForAccount(username string) *UserURIs {
 	protocol := config.GetProtocol()
@@ -317,6 +326,11 @@ func IsReportPath(id *url.URL) bool {
 	return regexes.ReportPath.MatchString(id.Path)
 }
 
+// IsAcceptsPath returns true if the given URL path corresponds to eg /users/example_username/accepts/SOME_ULID_OF_AN_ACCEPT
+func IsAcceptsPath(id *url.URL) bool {
+	return regexes.AcceptsPath.MatchString(id.Path)
+}
+
 // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS
 func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) {
 	matches := regexes.StatusesPath.FindStringSubmatch(id.Path)
-- 
cgit v1.2.3