- Always implement all required adapter callbacks - Missing callbacks will cause runtime errors
- Never trust remote data - Always validate and sanitize incoming activities
- Respect federation boundaries - Check
federate_actor?before any federation operation - Handle failures gracefully - Federation is unreliable by nature
- Cache aggressively but invalidate properly - Performance depends on good caching
- Sign all requests - HTTP signatures are mandatory for security
Always implement the full ActivityPub.Federator.Adapter behaviour. Missing any required callback will cause federation to fail.
defmodule MyApp.ActivityPubAdapter do
@behaviour ActivityPub.Federator.Adapter
# REQUIRED - All callbacks must be implemented
@impl true
def base_url, do: "https://myapp.example.com"
@impl true
def get_actor_by_id(id), do: # implementation
@impl true
def get_actor_by_username(username), do: # implementation
@impl true
def get_actor_by_ap_id(ap_id), do: # implementation
# ... all other required callbacks
end
Always return actors in the correct format:
# GOOD - Returns proper Actor struct
def get_actor_by_username(username) do
case MyApp.Users.get_by_username(username) do
nil -> {:error, :not_found}
user -> {:ok, user_to_actor(user)}
end
end
# BAD - Returns raw user struct
def get_actor_by_username(username) do
{:ok, MyApp.Users.get_by_username(username)}
endAlways validate actor format:
# GOOD - Proper Actor struct with all required fields
%ActivityPub.Actor{
id: user.id, # Internal ID
data: %{
"id" => "https://myapp.com/users/#{username}", # AP ID
"type" => "Person",
"preferredUsername" => username,
"inbox" => "https://myapp.com/users/#{username}/inbox",
"outbox" => "https://myapp.com/users/#{username}/outbox",
"followers" => "https://myapp.com/users/#{username}/followers",
"following" => "https://myapp.com/users/#{username}/following"
},
local: true,
keys: pem_keys, # Required for local actors
ap_id: "https://myapp.com/users/#{username}",
username: username,
pointer_id: user.id
}
# BAD - Missing required AP endpoints
%ActivityPub.Actor{
id: user.id,
data: %{"id" => "https://myapp.com/users/#{username}"},
local: true
}Always validate incoming activities in your adapter:
# GOOD - Validate before processing
def handle_activity(%{data: %{"type" => "Create", "object" => object}} = activity) do
with :ok <- validate_object(object),
:ok <- check_spam(object),
{:ok, local_object} <- create_from_ap(object) do
{:ok, local_object}
else
{:error, reason} -> {:error, reason}
end
end
# BAD - No validation
def handle_activity(%{data: %{"type" => "Create", "object" => object}}) do
create_from_ap(object)
endNever process activities from blocked actors:
# GOOD - Check blocks first
def handle_activity(activity) do
actor_id = activity.data["actor"]
if blocked?(actor_id) do
{:error, :blocked}
else
process_activity(activity)
end
endAlways implement federate_actor? to control federation boundaries:
# GOOD - Check both directions and blocks
def federate_actor?(actor, direction, by_actor) do
case direction do
:in ->
# Check if we accept activities from this actor
not blocked?(actor) and not instance_blocked?(actor)
:out ->
# Check if we send activities to this actor
actor.local and not actor.private and not blocked?(by_actor)
_ ->
# Both directions
not blocked?(actor) and not blocked?(by_actor)
end
end
# BAD - No boundary checking
def federate_actor?(_actor, _direction, _by_actor) do
true
endAlways convert your objects to proper ActivityStreams format:
# GOOD - Complete AP object
def maybe_publish_object(post_id, _manually_fetching?) do
post = MyApp.Posts.get!(post_id)
{:ok, %ActivityPub.Object{
data: %{
"id" => "https://myapp.com/posts/#{post.id}",
"type" => "Note",
"content" => post.content,
"attributedTo" => "https://myapp.com/users/#{post.author.username}",
"published" => DateTime.to_iso8601(post.inserted_at),
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => ["https://myapp.com/users/#{post.author.username}/followers"]
},
local: true,
public: true
}}
end
# BAD - Incomplete object
def maybe_publish_object(post_id, _) do
post = MyApp.Posts.get!(post_id)
{:ok, %{content: post.content}}
endAlways include proper addressing for activities:
# GOOD - Complete addressing
ActivityPub.create(%{
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [actor.data["followers"]],
actor: actor,
context: context_id,
object: %{
"type" => "Note",
"content" => "Hello, Fediverse!",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [actor.data["followers"]]
},
local: true
})
# BAD - Missing addressing
ActivityPub.create(%{
actor: actor,
object: %{"type" => "Note", "content" => "Hello!"},
local: true
})Never create activities for remote actors:
# WRONG - Creating activity for remote actor
remote_actor = ActivityPub.Actor.get_cached!(ap_id: "https://remote.com/users/bob")
ActivityPub.create(%{actor: remote_actor, ...}) # This will fail!
# CORRECT - Only create for local actors
local_actor = Adapter.get_actor_by_username("alice")
ActivityPub.create(%{actor: local_actor, ...})Always check if follow is allowed before creating:
# GOOD - Check boundaries first
def follow_user(follower, followed) do
if Adapter.federate_actor?(followed, :in, follower) do
ActivityPub.follow(%{
actor: follower,
object: followed,
local: true
})
else
{:error, :not_allowed}
end
end
# BAD - No permission check
def follow_user(follower, followed) do
ActivityPub.follow(%{actor: follower, object: followed, local: true})
endPrefer cached operations to avoid unnecessary network requests:
# GOOD - Try cache first
case ActivityPub.Actor.get_cached(ap_id: ap_id) do
{:ok, actor} -> {:ok, actor}
_ -> ActivityPub.Actor.get_cached_or_fetch(ap_id: ap_id)
end
# WASTEFUL - Always fetches
ActivityPub.Actor.get_cached_or_fetch(ap_id: ap_id)Always handle fetch failures:
# GOOD - Handle errors
case ActivityPub.Actor.get_cached_or_fetch(ap_id: ap_id) do
{:ok, actor} -> process_actor(actor)
{:error, :unreachable} -> handle_unreachable_instance()
{:error, reason} -> log_error(reason)
end
# BAD - Assumes success
{:ok, actor} = ActivityPub.Actor.get_cached_or_fetch(ap_id: ap_id)Never expose private keys:
# GOOD - Only include keys for local actors
def actor_json(actor) do
data = %{
"id" => actor.ap_id,
"publicKey" => %{
"id" => "#{actor.ap_id}#main-key",
"owner" => actor.ap_id,
"publicKeyPem" => get_public_key(actor) # Only public key
}
}
end
# BAD - Exposing private keys
def actor_json(actor) do
%{
"publicKey" => actor.keys # Never expose raw keys!
}
endAlways verify signatures on incoming activities:
# GOOD - Signature verification is mandatory
# This is handled automatically by the library, but ensure your routes use:
pipeline :activity_pub do
plug ActivityPub.Web.Plugs.FetchHTTPSignaturePlug
plug ActivityPub.Web.Plugs.EnsureHTTPSignaturePlug
end
# BAD - Bypassing signature verification
# Never accept unsigned ActivityPub requests!Never accept activities where actor doesn't match signature:
# The library handles this, but in your adapter:
# GOOD - Verify actor matches
def handle_activity(%{data: %{"actor" => actor}} = activity) do
if actor == activity.actor.ap_id do
process_activity(activity)
else
{:error, :actor_mismatch}
end
endAlways configure MRF policies for safety:
# GOOD - Basic safety configuration
config :activity_pub, :instance,
rewrite_policy: [
ActivityPub.MRF.SimplePolicy,
MyApp.CustomMRFPolicy
]
config :activity_pub, :mrf_simple,
reject: ["known-bad.example.com"],
media_removal: ["nsfw.example.com"],
report_removal: ["spam.example.com"]
# BAD - No MRF policies
config :activity_pub, :instance,
rewrite_policy: []Always handle unreachable instances gracefully:
# GOOD - Check reachability
def fetch_remote_user(ap_id) do
case ActivityPub.Actor.get_cached_or_fetch(ap_id: ap_id) do
{:ok, actor} ->
{:ok, actor}
{:error, :unreachable} ->
# Instance is down, use cached data if available
ActivityPub.Actor.get_cached(ap_id: ap_id)
error ->
error
end
end
# BAD - No error handling for unreachable instances
def fetch_remote_user(ap_id) do
ActivityPub.Actor.get_cached_or_fetch!(ap_id: ap_id)
endNever use string concatenation for public URI:
# WRONG - Typos will break federation
to: ["https://www.w3.org/ns/activitystreams#public"] # lowercase!
# CORRECT - Use the constant
to: ["https://www.w3.org/ns/activitystreams#Public"]
# BETTER - Use a helper
def public_uri, do: "https://www.w3.org/ns/activitystreams#Public"Always use full URLs for object IDs:
# WRONG - Relative IDs break federation
%{
"id" => "/posts/123",
"type" => "Note"
}
# CORRECT - Full URL
%{
"id" => "https://myapp.com/posts/123",
"type" => "Note"
}Always preserve context for replies:
# GOOD - Preserve thread context
def create_reply(parent, content) do
context = parent.data["context"] || parent.data["conversation"] || parent.data["id"]
ActivityPub.create(%{
object: %{
"type" => "Note",
"content" => content,
"inReplyTo" => parent.data["id"],
"context" => context # Important!
}
})
end
# BAD - Lost threading
def create_reply(parent, content) do
ActivityPub.create(%{
object: %{
"type" => "Note",
"content" => content,
"inReplyTo" => parent.data["id"]
# Missing context!
}
})
endAlways configure the minimum required settings:
# GOOD - Complete required configuration
config :activity_pub, :adapter, MyApp.ActivityPubAdapter
config :activity_pub, :repo, MyApp.Repo
config :activity_pub, :instance,
hostname: "myapp.example.com",
federating: true
# BAD - Missing required configuration
config :activity_pub, :adapter, MyApp.ActivityPubAdapter
# Missing repo and instance config will cause runtime errors!Never use localhost or example.com in production:
# WRONG - Invalid hostnames
config :activity_pub, :instance,
hostname: "localhost" # Will break federation!
# CORRECT - Valid public hostname
config :activity_pub, :instance,
hostname: "myapp.example.com"Always sign object fetches for better security:
# GOOD - Signed fetches
config :activity_pub, :sign_object_fetches, true
# RISKY - Unsigned fetches
config :activity_pub, :sign_object_fetches, falseAlways set reasonable federation limits:
# GOOD - Protect against recursion attacks
config :activity_pub, :instance,
federation_incoming_max_recursion: 10,
federation_incoming_max_items: 5
# BAD - No limits (DoS risk)
config :activity_pub, :instance,
federation_incoming_max_recursion: 1000,
federation_incoming_max_items: 1000Always set a descriptive user agent:
# GOOD - Identifies your instance
config :activity_pub, :http,
user_agent: "MyApp/1.0 (+https://myapp.com)",
send_user_agent: true
# BAD - Generic or missing user agent
config :activity_pub, :http,
send_user_agent: falseUse proxy configuration when behind a proxy:
# GOOD - Proxy aware
config :activity_pub, :http,
proxy_url: "http://proxy.internal:8080"
# BAD - Ignoring proxy requirements
# Will fail to connect if behind mandatory proxyAlways validate and handle errors in your adapter's handle_activity/1:
# GOOD - Complete validation and error handling
def handle_activity(%{data: %{"type" => "Create", "object" => object}} = activity) do
with :ok <- validate_create_activity(activity),
{:ok, local_object} <- create_from_ap(object),
{:ok, _} <- notify_users(local_object) do
{:ok, local_object}
else
{:error, :invalid_object} -> {:error, "Invalid object format"}
{:error, reason} -> {:error, reason}
end
end
# BAD - No validation or error handling
def handle_activity(%{data: %{"type" => "Create", "object" => object}}) do
local_object = create_from_ap!(object)
notify_users!(local_object)
{:ok, local_object}
endAlways handle all activity types you support:
# GOOD - Handle supported types, reject unknown
def handle_activity(%{data: %{"type" => type}} = activity) do
case type do
"Create" -> handle_create(activity)
"Update" -> handle_update(activity)
"Delete" -> handle_delete(activity)
"Follow" -> handle_follow(activity)
"Like" -> handle_like(activity)
"Announce" -> handle_announce(activity)
_ -> {:error, "Unsupported activity type: #{type}"}
end
end
# BAD - Silent failures for unknown types
def handle_activity(activity) do
# Only handles some types, ignores others
handle_create(activity)
endAlways federate after successful local creation:
# GOOD - Create locally first, then federate
def create_post(author, attrs) do
with {:ok, post} <- Posts.create(author, attrs),
{:ok, actor} <- get_actor_for_user(author),
{:ok, activity} <- ActivityPub.create(%{
actor: actor,
to: ["https://www.w3.org/ns/activitystreams#Public"],
object: post_to_ap_object(post),
local: true
}) do
{:ok, post}
else
{:error, reason} ->
# Local creation failed, don't federate
{:error, reason}
end
end
# BAD - Federation before local persistence
def create_post(author, attrs) do
{:ok, activity} = ActivityPub.create(%{...}) # Federates first!
Posts.create(author, attrs) # Might fail after federation
endAlways implement granular federation controls:
# GOOD - Direction-aware controls
def federate_actor?(actor, direction, by_actor) do
case direction do
:in ->
# Incoming: Check blocks and instance policies
not actor_blocked?(actor) and
not instance_blocked?(actor) and
accepting_activities?()
:out ->
# Outgoing: Check privacy settings
actor.local and
not actor.private and
federating_enabled?(by_actor)
end
end
# BAD - No direction awareness
def federate_actor?(_actor, _direction, _by_actor) do
true # Federates everything!
endAlways create a complete mock adapter for tests:
# GOOD - Complete mock implementation
defmodule MyApp.MockAdapter do
@behaviour ActivityPub.Federator.Adapter
def base_url, do: "https://test.example.com"
def get_actor_by_id(id), do: {:ok, mock_actor(id)}
def get_actor_by_username(username), do: {:ok, mock_actor(username)}
def get_actor_by_ap_id(ap_id), do: {:ok, mock_actor(ap_id)}
def handle_activity(activity), do: {:ok, activity}
def maybe_publish_object(id, _), do: {:ok, mock_object(id)}
# ... implement ALL required callbacks
end
# BAD - Partial implementation
defmodule MyApp.BadMockAdapter do
@behaviour ActivityPub.Federator.Adapter
def base_url, do: "https://test.example.com"
# Missing required callbacks!
endNever use production adapter in tests:
# WRONG - Tests will hit real federation
config :activity_pub, :adapter, MyApp.ProductionAdapter
# CORRECT - Use mock for isolation
config :activity_pub, :adapter, MyApp.MockAdapterAlways mock external HTTP requests in tests:
# GOOD - Predictable test responses
setup do
Tesla.Mock.mock(fn
%{url: "https://remote.example/actor"} ->
%Tesla.Env{status: 200, body: valid_actor_json()}
%{url: "https://remote.example/inbox"} ->
%Tesla.Env{status: 202, body: ""}
_ ->
%Tesla.Env{status: 404, body: "Not Found"}
end)
:ok
end
# BAD - No mocking, tests make real requests
test "fetch remote actor" do
# This will make actual HTTP requests!
{:ok, actor} = ActivityPub.Actor.get_cached_or_fetch(ap_id: "https://real.site/user")
endAlways use valid ActivityStreams format in tests:
# GOOD - Valid AS2 data
def valid_actor_json do
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.example/actor",
"type" => "Person",
"inbox" => "https://remote.example/actor/inbox",
"outbox" => "https://remote.example/actor/outbox",
"preferredUsername" => "testuser"
}
end
# BAD - Invalid/incomplete data
def bad_actor_json do
%{"name" => "Test User"} # Missing required fields!
endAlways use caching for remote actors and objects:
# GOOD - Use cached operations
ActivityPub.Actor.get_cached(ap_id: ap_id) ||
ActivityPub.Actor.get_cached_or_fetch(ap_id: ap_id)
# BAD - Always fetching
ActivityPub.Actor.get_or_fetch(ap_id: ap_id, force: true)Never cache local actors longer than remote actors:
# Configuration should reflect this
config :activity_pub, :cache,
remote_actor_ttl: :timer.hours(24),
local_actor_ttl: :timer.hours(1) # Shorter for localAlways process federation in background jobs:
# GOOD - Queue for background processing
def publish_activity(activity) do
Oban.insert(FederationWorker.new(%{activity_id: activity.id}))
end
# BAD - Synchronous federation
def publish_activity(activity) do
Enum.each(recipients, fn inbox ->
HTTPClient.post(inbox, activity) # Blocks!
end)
endAlways batch deliveries to the same instance:
# GOOD - Group by instance
def deliver_to_inboxes(activity, inboxes) do
inboxes
|> Enum.group_by(&URI.parse(&1).host)
|> Enum.map(fn {_host, inbox_list} ->
# Deliver to shared inbox if available
deliver_to_instance(activity, inbox_list)
end)
end
# BAD - Individual delivery to each inbox
def deliver_to_inboxes(activity, inboxes) do
Enum.each(inboxes, &deliver(activity, &1))
endAlways enable debug logging when troubleshooting federation:
# GOOD - Verbose logging for debugging
config :activity_pub, :debug, true
config :logger, :console, level: :debug
# Also log specific modules
config :logger, :console,
metadata: [:module, :actor_id, :activity_id]Never leave debug logging on in production:
# Production config
config :activity_pub, :debug, false
config :logger, level: :infoAlways check job queues when federation seems stuck:
# GOOD - Comprehensive queue check
def inspect_federation_queues do
Oban.Job
|> where([j], j.queue in ["federation", "federator_outgoing", "federator_incoming"])
|> where([j], j.state in ["available", "scheduled", "executing", "retryable"])
|> Repo.all()
|> Enum.group_by(& {&1.queue, &1.state})
|> Enum.map(fn {{queue, state}, jobs} ->
%{
queue: queue,
state: state,
count: length(jobs),
oldest: List.first(jobs)
}
end)
end
# BAD - Incomplete queue check
Oban.Job |> Repo.all() # Too much data, not filteredAlways trace activities through the full pipeline:
# GOOD - Complete activity trace
def trace_activity(activity_id) do
with {:ok, activity} <- ActivityPub.Object.get_by_id(activity_id),
deliveries <- get_delivery_records(activity),
jobs <- get_related_jobs(activity) do
%{
activity: activity,
deliveries: deliveries,
jobs: jobs,
errors: get_delivery_errors(activity)
}
end
endAlways add ActivityPub routes to your router:
# GOOD - ActivityPub routes included
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use ActivityPub.Web.Router # Required!
# Your other routes...
end
# BAD - Missing AP routes
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# Federation endpoints won't work!
endAlways sign fetches when configured:
# GOOD - Honor signature configuration
if ActivityPub.Config.get(:sign_object_fetches) do
fetch_with_signature(url, actor)
else
fetch_without_signature(url)
end
# BAD - Never signing fetches
HTTPClient.get(url) # Some instances will reject!Always generate keys before first federation:
# GOOD - Ensure keys exist
def ensure_actor_keys(actor) do
if actor.keys do
{:ok, actor}
else
{:ok, keys} = ActivityPub.Safety.Keys.generate_rsa_pem()
update_actor(actor, %{keys: keys})
end
end
# BAD - Publishing without keys
ActivityPub.create(%{actor: keyless_actor, ...}) # Will fail!Always ensure pointer IDs are unique:
# GOOD - Type-specific IDs
def generate_pointer_id(type, local_id) do
"#{type}:#{local_id}" # e.g., "actor:123", "object:456"
end
# BAD - Reusing IDs across types
def generate_pointer_id(_type, local_id) do
local_id # Collision risk!
endAlways register custom activity types in configuration:
# GOOD - Explicitly declare supported types
config :activity_pub, :instance,
supported_activity_types: [
# Standard types
"Create", "Update", "Delete", "Follow", "Like", "Announce",
# Custom types
"Question", "Answer", "Event"
]
# BAD - Undeclared custom types
# Using custom types without configuration
ActivityPub.create(%{type: "CustomType", ...}) # Not registered!Always validate in MRF policies, never modify without reason:
# GOOD - Clear policy with validation
defmodule MyApp.SpamFilterMRF do
@behaviour ActivityPub.MRF
@impl true
def filter(%{data: %{"content" => content}} = object, local?) do
if spam?(content) do
{:reject, "Content identified as spam"}
else
{:ok, object}
end
end
def filter(object, _local?), do: {:ok, object}
defp spam?(content) do
# Actual spam detection logic
String.contains?(content, ~w[spam viagra])
end
end
# BAD - Modifying without clear reason
defmodule MyApp.BadMRF do
@behaviour ActivityPub.MRF
@impl true
def filter(object, _local?) do
# Arbitrarily modifying content!
modified = put_in(object, ["data", "content"], "MODIFIED")
{:ok, modified}
end
endAlways add MRF policies to configuration:
# GOOD - Policy registered
config :activity_pub, :instance,
rewrite_policy: [
ActivityPub.MRF.SimplePolicy,
MyApp.SpamFilterMRF,
MyApp.CustomMRF
]
# BAD - Policy not in config
# MRF policy exists but isn't configured to runOnly transform when necessary for compatibility:
# GOOD - Transform for specific compatibility
def transform_outgoing(data, "mastodon.social", _actor_id) do
# Mastodon-specific transformation
data
|> Map.put("@context", expanded_context())
|> ensure_attachment_format(:mastodon)
end
def transform_outgoing(data, _host, _actor_id), do: data
# BAD - Unnecessary transformation
def transform_outgoing(data, _host, _actor_id) do
# Modifying all outgoing data unnecessarily
Map.put(data, "custom_field", "value")
end- Always validate incoming data in your adapter
- Never trust remote content without sanitization
- Always check actor matches activity author
- Always handle errors gracefully - federation is unreliable
- Never assume remote instances are available
- Always implement timeouts and retries
- Never federate private content
- Always respect user privacy settings
- Always check boundaries before federation
- Always rate limit incoming requests
- Always monitor queue depths
- Always cache but respect TTLs
- Always test with real implementations
- Never assume all instances behave identically
- Always handle both compact and expanded JSON-LD
- Always handle Delete activities
- Always create Tombstone objects
- Never hard-delete federated content immediately
- Always support Move activities
- Always update follower lists
- Never lose follower relationships