[ Content & feeds ]
Federate a site with ActivityPub
Publish an actor, answer WebFinger, verify an inbox that handles follows and undos, deliver signed posts to followers from jobs, and test it all in memory.
Last updated 2026-10-08
ActivityPub is the protocol Mastodon, Misskey, GoToSocial, Pixelfed and the rest of the fediverse speak. A site that federates has an actor, an account someone on any of those servers can search for and follow. Every post the site publishes lands in their timeline, and every reply, like and boost they send comes back to the site's inbox.
This guide makes a Remix v3 app one such actor. It serves the actor document and WebFinger,
verifies each activity at the inbox, processes follows and undos, delivers signed posts to every
follower from background jobs, and serves each post as HTML to a browser and as an
ActivityStreams Article to a server.
@sdxc/activitypub holds the protocol: one Federation per actor, the
vocabulary, and in-memory stores for tests. Its signatures come from
@sdxc/http-signatures, @sdxc/well-known answers
WebFinger, @sdxc/jobs runs every step off the request, and
@sdxc/cache holds the remote documents the federation fetches.
npm add remix @sdxc/activitypub @sdxc/jobs @sdxc/cache @sdxc/well-known @sdxc/http \
@sdxc/result
The package keeps no state of its own. Followers, the activities already processed and the
posts you serve live in your database, behind four small interfaces it calls, so the examples
import a few modules from your own ~/app/repositories/* and ~/app/models/*.
Generate the actor's keys
Every activity the site sends is signed with the actor's RSA key, and every server that receives
one fetches the actor document to read the public half. ActorKeys.generate makes the pair
Mastodon expects. Run it once, from a script, and store the private key as a secret:
import { ActorKeys } from "@sdxc/activitypub";
import { isFailure } from "@sdxc/result";
let pair = await ActorKeys.generate();
if (isFailure(pair)) process.exitCode = 1;
else process.stdout.write(pair.data.privateKeyPem);
bun scripts/actor-keys.ts | npx wrangler secret put ACTIVITYPUB_PRIVATE_KEY
The private key is the only secret: ActorKeys.import derives the public half from it. Keep the
key stable, because followers verify against the one they cached, and a new pair means
announcing it to every one of them.
Import it lazily through a KeyProvider, so the work happens inside the first request or job
that signs, and once per isolate after that:
import type { KeyProvider } from "@sdxc/activitypub";
import type { Result } from "@sdxc/result";
import { ActorKeys } from "@sdxc/activitypub";
import { isFailure, success } from "@sdxc/result";
import { env } from "cloudflare:workers";
import { ACTOR_ID } from "~/app/config/activitypub";
class SiteKeys implements KeyProvider {
#keys: Promise<Result<ActorKeys, Error>> | null = null;
async keysOf(actor: string): Promise<Result<ActorKeys | null, Error>> {
if (actor !== ACTOR_ID) return success(null);
let privateKeyPem = env.ACTIVITYPUB_PRIVATE_KEY;
this.#keys ??= ActorKeys.import({ actor: ACTOR_ID, privateKeyPem });
let keys = await this.#keys;
if (isFailure(keys)) this.#keys = null;
return keys;
}
}
export const SITE_KEYS = new SiteKeys();
A failed import is forgotten, so the next call tries again. The key must be PKCS#8
(BEGIN PRIVATE KEY), which is what generate writes.
Describe the actor
The actor's URLs are ordinary routes in your app. Name them once, so the routes and the actor document cannot drift apart:
export const ACTOR_ID = "https://example.com/actor";
export const INBOX_ID = "https://example.com/actor/inbox";
export const OUTBOX_ID = "https://example.com/actor/outbox";
export const FOLLOWERS_ID = "https://example.com/actor/followers";
export const FOLLOWING_ID = "https://example.com/actor/following";
export const HANDLE = "acct:hello@example.com";
export const USER_AGENT = "example.com/1.0 (+https://example.com)";
import { get, post, route } from "remix/routes";
export default route({
posts: { show: get("/posts/:slug") },
activityPub: {
actor: get("/actor"),
inbox: post("/actor/inbox"),
outbox: get("/actor/outbox"),
followers: get("/actor/followers"),
following: get("/actor/following"),
},
});
parseActor reads a document into the full ActivityPub.Actor shape, filling every member you
leave out with null, [] or false, so the actor is written as the handful of members that
matter:
import type { ActivityPub, ActivityPubParseError } from "@sdxc/activitypub";
import type { Result } from "@sdxc/result";
import { parseActor } from "@sdxc/activitypub";
import {
ACTOR_ID,
FOLLOWERS_ID,
FOLLOWING_ID,
INBOX_ID,
OUTBOX_ID,
} from "~/app/config/activitypub";
export function siteActor(): Result<ActivityPub.Actor, ActivityPubParseError> {
return parseActor({
id: ACTOR_ID,
type: "Person",
preferredUsername: "hello",
name: "Example",
summary: "<p>Notes and articles from example.com.</p>",
url: "https://example.com/",
inbox: INBOX_ID,
outbox: OUTBOX_ID,
followers: FOLLOWERS_ID,
following: FOLLOWING_ID,
endpoints: { sharedInbox: INBOX_ID },
});
}
preferredUsername is the hello of hello@example.com, the handle people search for.
publicKey stays out: the federation fills it from the keys wherever it serves the actor.
endpoints.sharedInbox lets a server holding many of your followers deliver one copy for all of
them, and pointing it at the inbox keeps one URL to verify.
Build the federation
One Federation wires the actor, its keys, your stores, the cache and the queue together. It
holds nothing between calls, so build it wherever a database and a job enqueuer are at hand: in
a request with ctx.db and ctx.jobs, in a job with its database and the dispatcher.
import type { ActivityPubParseError } from "@sdxc/activitypub";
import type { JobEnqueuer } from "@sdxc/jobs";
import type { Result } from "@sdxc/result";
import type { Database } from "remix/data-table";
import { Federation } from "@sdxc/activitypub";
import { WorkerKVCache } from "@sdxc/cache/worker-kv";
import { isFailure, success } from "@sdxc/result";
import { env } from "cloudflare:workers";
import { USER_AGENT } from "~/app/config/activitypub";
import jobs from "~/app/jobs";
import { saveResponse, withdrawResponse } from "~/app/models/responses";
import { Followers } from "~/app/repositories/followers";
import { SeenActivities } from "~/app/repositories/seen-activities";
import { SITE_KEYS } from "~/app/services/actor-keys";
import { FederatedPosts } from "~/app/services/federated-posts";
import { siteActor } from "~/app/services/site-actor";
interface FederationServices {
db: Database;
jobs: JobEnqueuer;
}
export function createFederation(
services: FederationServices,
): Result<Federation, ActivityPubParseError> {
let actor = siteActor();
if (isFailure(actor)) return actor;
let { db } = services;
let federation = new Federation({
actor: actor.data,
keys: SITE_KEYS,
stores: {
followers: new Followers(db),
seen: new SeenActivities(env.CACHE),
objects: new FederatedPosts(db),
},
cache: new WorkerKVCache(env.CACHE),
userAgent: USER_AGENT,
queue: {
enqueue: (message) =>
services.jobs.enqueue(jobs.federation.process, message),
enqueueMany: (messages) =>
services.jobs.enqueueMany(jobs.federation.process, messages),
},
});
return success(
federation
.on(["Create", "Update", "Like", "Announce"], (inbound) =>
saveResponse(db, inbound.summary()),
)
.on("Undo", (inbound) => withdrawResponse(db, inbound.object?.id ?? null))
.on("Delete", (inbound) =>
withdrawResponse(
db,
inbound.deleted.kind === "object" ? inbound.deleted.id : null,
),
),
);
}
The stores are yours, each a small class over your own tables:
FollowersimplementsFollowerStore:put,get,removeandremoveInboxkeyed by(actor, id), andlist,countandinboxesover accepted followers only.SeenActivitiesimplementsSeenActivities:claim(id, ttl)answerstruethe first time an activity id is claimed, which spares a redelivered activity a second pass.FederatedPostsimplementsLocalObjects:find(id)answers the post you serve underid, which is how the inbox tells a reply to one of your posts from noise. It is built further down.
The cache holds remote actors and objects, the signature scheme each server accepted, and the
servers that keep failing. Any @sdxc/cache Cache works; a failing one costs a refetch, never
a wrong answer. userAgent goes out on every request, since some servers refuse requests
without one.
Pass blocked: (host) => … to refuse a server: a blocked host can neither deliver to the
inbox, follow the actor, nor receive its posts. authorizedFetch: true requires a signed GET
for the collections and for every document respond serves, which is the secure mode some
servers run; the actor document always answers unsigned, since that is where a server reads the
key it verifies with.
Answer the actor's URLs
federation.fetch answers every URL the actor document names: the actor itself with
publicKey filled in, the inbox, the outbox, and the followers and following collections, each
with an ETag, 304 revalidation and a five-minute public cache. Every route maps to the same
action:
import type { JobEnqueuer } from "@sdxc/jobs";
import type { Database } from "remix/data-table";
import { notFound, serviceUnavailable } from "@sdxc/http/response/json";
import { isFailure } from "@sdxc/result";
import { createController } from "remix/router";
import { createFederation } from "~/app/services/federation";
import routes from "~/routes/web";
interface FederateContext {
request: Request;
db: Database;
jobs: JobEnqueuer;
}
async function federate(ctx: FederateContext): Promise<Response> {
let federation = createFederation(ctx);
if (isFailure(federation)) return serviceUnavailable({ error: "not federating" });
let response = await federation.data.fetch(ctx.request);
return response ?? notFound({ error: "not found" });
}
export default createController(routes.activityPub, {
actions: {
actor: federate,
inbox: federate,
outbox: federate,
followers: federate,
following: federate,
},
});
Map it with router.map(routes.activityPub, activityPub). The followers collection reads
count and list from your store. The outbox is empty until you pass outbox: (cursor) => …,
which answers a page of your Create activities as { items, next, totalItems }.
What the inbox does inside the request
A POST to the inbox is verified before it is queued, and the response is the verdict: 202
once the activity is on the queue, or the status of the check it failed. The checks run in
order and stop at the first failure, so a malformed or blocked delivery costs no fetch:
The content type is
application/activity+jsonorapplication/ld+json(415), and the body fits 100 KB (413).The body is an activity with an
id, atypeand anactor(400), from a host you have not blocked (403).The request is signed, with RFC 9421 or draft-cavage, over a digest that matches the body, within an hour of now (
401).The signature verifies with the sender's key, fetched from its actor document and refetched once when it fails, so a rotated key still lands (
401).The key belongs to the activity's actor, or the activity, fetched fresh from its own origin, is the same activity by the same actor. That second path is how a reply Mastodon forwards to a thread's participants is accepted.
Verifying inside the request is what lets a sender learn from a 401 and retry at once with
the other signature scheme. A Delete from an account whose key now answers 410 gets an empty
202, since a deleted account's last messages can no longer be verified.
Verify before anything else reads the request: the body is a stream. A server delivering an
activity has no session and no Origin header, so if your app runs Remix's
cop(), exempt the inbox
path from it: the signature is the stronger claim.
Answer WebFinger
Someone on Mastodon types @hello@example.com. Their server asks
https://example.com/.well-known/webfinger?resource=acct:hello@example.com for a self link
typed application/activity+json, then fetches the actor it points at. actorLink writes that
link, and the wellKnown middleware from @sdxc/well-known answers the path:
import { actorLink } from "@sdxc/activitypub";
import { isFailure } from "@sdxc/result";
import { serve, wellKnown } from "@sdxc/well-known/middleware";
import { readQuery, select, webFinger } from "@sdxc/well-known/webfinger";
import { ACTOR_ID, HANDLE } from "~/app/config/activitypub";
export const webFingerMiddleware = wellKnown({
webfinger: serve(webFinger, (ctx) => {
let query = readQuery(ctx.url);
if (isFailure(query) || query.data.resource !== HANDLE) return null;
let jrd = {
subject: HANDLE,
aliases: [ACTOR_ID],
properties: {},
links: [actorLink(ACTOR_ID)],
};
return select(jrd, query.data.rels);
}),
});
Add it to the router's middleware. A resource you do not host returns null, which falls
through to your router's 404. Mastodon also checks the reverse direction:
preferredUsername@<actor host> must resolve back to the same actor, which holds here because
the handle and the actor share example.com.
The same lookup runs the other way through federation.lookup("@someone@mastodon.social"),
which resolves a remote handle to its actor, confirming it against the actor's own host, for a
form where someone types a handle to block or to mention.
Process the queue
Every step the federation queues (an inbox activity, the fan-out of a post, one delivery) is
one message shape, Federation.MESSAGE, a schema from remix/data-schema. Declare one job for
it, beside the job that publishes a post:
import { Federation } from "@sdxc/activitypub";
import { job, jobs } from "@sdxc/jobs";
import * as s from "remix/data-schema";
export default jobs({
federation: {
process: job({ input: Federation.MESSAGE }),
publish: job({ input: s.object({ postId: s.string() }) }),
},
});
The handler hands each message back to the federation and maps its answer onto the job's verbs:
import { createJobHandler } from "@sdxc/jobs";
import { isFailure } from "@sdxc/result";
import jobs from "~/app/jobs";
import { dispatcher } from "~/app/jobs/dispatcher";
import { createFederation } from "~/app/services/federation";
export default createJobHandler(jobs.federation.process, async (ctx) => {
let federation = createFederation({ db: ctx.database, jobs: dispatcher });
if (isFailure(federation)) return ctx.exit(federation.error.message);
let processed = await federation.data.process(ctx.input, {
attempts: ctx.attempts,
});
if (isFailure(processed)) {
let { delay, retryable } = processed.error;
if (retryable) return ctx.retry({ delay, cause: processed.error });
return ctx.ack(processed.error.message);
}
ctx.log.set({ activitypub: { step: processed.data.kind } });
});
ctx.attempts picks the step of the retry schedule, and delay is in milliseconds, which
ctx.retry takes as it is. The job runs inside the dispatcher, so it enqueues through
dispatcher rather than ctx.jobs; Background jobs and
cron builds both, and the database() job
middleware that publishes ctx.database.
Handle follows, undos and replies
An inbox message is processed in two parts: the protocol's work, which the package does, then your handler for the activity's type. By the time a handler runs, the sender's actor has been fetched from its own server, and anything the activity embeds from another origin has been fetched fresh rather than trusted.
Follow. The package stores the follower and delivers an Accept that embeds the Follow,
which is how the remote server learns the follow went through. A repeated Follow means the
sender never saw the first Accept, so it is sent again. Chain a Follow handler onto the
federation to decide instead:
federation.on("Follow", (inbound) =>
inbound.actor.type === "Service" ? "pending" : null,
);
"accept", "reject" and "pending" decide; null keeps the default, which is "accept"
unless the actor sets manuallyApprovesFollowers: true. A pending follower is stored with
state: "pending" and left out of the collection and of deliveries. Approving one later is
yours: mark it accepted in your store and publish an Accept addressed to the follower, whose
object is the Follow rebuilt from the stored followId. A rejected follow, and any follow
from a blocked server, gets a Reject.
Undo. An Undo of a Follow removes the follower when the follow it names is the one
stored. Every Undo reaches your handler once the undone activity is shown to be the sender's,
with that activity as inbound.object: the withdrawResponse call above removes the like or
boost it undid, and finds nothing to remove for a follow.
Create, Update, Like, Announce. A post must be attributed to its sender, live on the
sender's server, and reply to, quote or mention something of yours; a like or boost must name a
post your LocalObjects serves. Anything else is acknowledged and dropped before your handler
runs. inbound.summary() then answers what the activity says about your content:
interface Summary {
kind: "reply" | "like" | "repost" | "mention";
id: string;
url: string;
target: string;
author: {
id: string;
handle: string;
name: string | null;
url: string;
photo: string | null;
};
content: { html: string; text: string } | null;
published: Date | null;
}
target is your post or actor, and content.html is already sanitized against the remote
post's own URL. It is the shape a verified Webmention carries, so one table and one moderation
queue can hold both; Join the IndieWeb builds that side.
Store responses as pending and show them once you approve them.
Delete. A Delete of a post is believed once a fresh fetch answers 404, 410 or a
Tombstone, or still names the sender as its author; a Delete of an account removes its
follow. Its handler gets inbound.deleted,
{ kind: "object" | "actor", id }, and inbound.actor is null when the account is already
gone.
A handler answers nothing or a Result. A failure, or an exception, retries the whole message,
so write every handler to tolerate seeing the same activity twice; the upserts in
saveResponse and the deletes in withdrawResponse already do.
Publish posts as articles
A post goes out as an ActivityStreams object wrapped in an activity: a Note for a short post,
an Article for a long one with a title. The same object is what a server gets when it asks
for the post's URL, and what LocalObjects.find answers for it:
import type { ActivityPub, LocalObjects } from "@sdxc/activitypub";
import type { Result } from "@sdxc/result";
import type { Database } from "remix/data-table";
import { parseObject, PUBLIC, stringify, tombstone } from "@sdxc/activitypub";
import { isFailure, success, wrap } from "@sdxc/result";
import type { Post } from "~/app/models/posts";
import { ACTOR_ID, FOLLOWERS_ID } from "~/app/config/activitypub";
import { findPostByUrl } from "~/app/models/posts";
export function articleOf(post: Post): ActivityPub.Draft<ActivityPub.Object> {
return {
id: post.url,
type: "Article",
attributedTo: [ACTOR_ID],
to: [PUBLIC],
cc: [FOLLOWERS_ID],
name: post.title,
summary: post.excerpt,
content: post.html,
url: post.url,
published: post.publishedAt,
updated: post.updatedAt,
};
}
export function activityOf(
post: Post,
now: Date,
): ActivityPub.Draft<ActivityPub.Activity> | null {
let addressing = { actor: ACTOR_ID, to: [PUBLIC], cc: [FOLLOWERS_ID] };
if (post.deletedAt !== null) {
if (post.federatedAt === null) return null;
let object = tombstone({ id: post.url, formerType: "Article", deleted: now });
return { id: `${post.url}#delete`, type: "Delete", object, ...addressing };
}
let object = articleOf(post);
if (post.federatedAt === null) {
return { id: `${post.url}#create`, type: "Create", object, ...addressing };
}
let id = `${post.url}#update-${now.getTime()}`;
return { id, type: "Update", object, ...addressing };
}
export class FederatedPosts implements LocalObjects {
readonly #db: Database;
constructor(db: Database) {
this.#db = db;
}
async find(id: string): Promise<Result<ActivityPub.Object | null, Error>> {
let post = await wrap(() => findPostByUrl(this.#db, id));
if (isFailure(post)) return post;
if (post.data === null || post.data.deletedAt !== null) return success(null);
return parseObject(JSON.parse(stringify(articleOf(post.data))));
}
}
Post and findPostByUrl are your own model. ActivityPub.Draft is the shape you author:
id and type are required and every other member may be left out. stringify writes the
JSON-LD document with its @context, dates as ISO 8601, and nothing for an absent member;
reading it back through parseObject gives the full shape find answers.
Address PUBLIC in to for a public post, and the followers collection in cc to reach your
followers. Each activity needs its own id, so each Update carries the time of the change.
content is the post's HTML; receiving servers sanitize it to the elements they render.
Deliver from a job
Enqueue jobs.federation.publish with the post's id whenever a post is created, edited or
deleted. The job works out which activity that is and hands it to publish:
import { createJobHandler } from "@sdxc/jobs";
import { isFailure } from "@sdxc/result";
import jobs from "~/app/jobs";
import { dispatcher } from "~/app/jobs/dispatcher";
import { findPost, markFederated } from "~/app/models/posts";
import { activityOf } from "~/app/services/federated-posts";
import { createFederation } from "~/app/services/federation";
export default createJobHandler(jobs.federation.publish, async (ctx) => {
let post = await findPost(ctx.database, ctx.input.postId);
if (post === null) return ctx.ack("The post no longer exists");
let activity = activityOf(post, new Date());
if (activity === null) return ctx.ack("The post never federated");
let federation = createFederation({ db: ctx.database, jobs: dispatcher });
if (isFailure(federation)) return ctx.exit(federation.error.message);
let published = await federation.data.publish(activity);
if (isFailure(published)) {
if (published.error.retryable) return ctx.retry({ cause: published.error });
return ctx.exit(published.error.message);
}
await markFederated(ctx.database, post.id);
});
publish serializes the activity and queues one fanOut message carrying it whole, so every
inbox receives the same bytes even if the post changes again before delivery ends. An activity
over 120 KB fails too-large, since no queue message could carry it; trim content to a
summary and a link for a post that long. Marking the post federated only after the fan-out is
queued means a failed publish retries as a Create.
The fan-out runs as its own message. It reads your followers' inboxes from the store a page at a
time and queues one deliver message per distinct inbox, preferring each server's shared inbox,
so a server with four hundred of your followers gets one request. Hosts you block are skipped,
and so are servers that have failed every delivery for seven days.
Each delivery is signed when it is sent, so Date is fresh on every retry. The first request
to a server is signed with RFC 9421; a 400, 401 or 403 is retried at once with
draft-cavage, the scheme Mastodon before 4.5 requires, and the scheme that landed is remembered
for that server for thirty days. An inbox that answers 410 is gone, so every follower reached
through it is removed.
A server that is down or rate-limiting fails the delivery as retryable, with a delay from
DELIVERY_BACKOFF: five minutes, thirty minutes, two hours, six hours, then twelve, each with
±20% jitter, or longer when the inbox sent a Retry-After. That is about 21 hours over five
retries, so give the queue at least six attempts. A queue that retries more takes its own
schedule as backoff: createBackoff(…) from @sdxc/backoff;
Retry on a growing delay covers the
options.
Serve each post both ways
A server that sees your post's URL, because someone pasted it into Mastodon's search or a reply
links to it, asks that URL for application/activity+json. federation.respond answers it,
and answers null for a browser, so the page renders as usual. Call it first in the post
page's handler:
import type { JobEnqueuer } from "@sdxc/jobs";
import type { Database } from "remix/data-table";
import { tombstone } from "@sdxc/activitypub";
import { isFailure } from "@sdxc/result";
import type { Post } from "~/app/models/posts";
import { articleOf } from "~/app/services/federated-posts";
import { createFederation } from "~/app/services/federation";
interface ActivityContext {
request: Request;
db: Database;
jobs: JobEnqueuer;
}
export async function activityResponse(ctx: ActivityContext, post: Post) {
let federation = createFederation(ctx);
if (isFailure(federation)) return null;
let document =
post.deletedAt === null
? articleOf(post)
: tombstone({
id: post.url,
formerType: "Article",
deleted: post.deletedAt,
});
return await federation.data.respond(ctx.request, document);
}
let activity = await activityResponse(ctx, post);
if (activity) return activity;
The response carries Vary: Accept, an ETag and 304 revalidation. A Tombstone answers
410, which is how a server refetching a deleted post learns to remove it. Link the
representation from the page's <head> too, as
<link rel="alternate" type="application/activity+json" href={post.url} />, for a client that
reads the page first.
Test it in memory
@sdxc/activitypub/testing has an in-memory implementation of every store, and conformance
suites that hold your own stores to the contracts the package relies on. Register a suite for
each store you wrote:
import { followerStoreConformance } from "@sdxc/activitypub/testing";
import { Followers } from "~/app/repositories/followers";
import { openTestDatabase } from "~/test/database";
followerStoreConformance({
name: "Followers",
create: async () => new Followers(await openTestDatabase()),
});
create builds a fresh store for every test, so give it an empty database each time.
seenActivitiesConformance takes the same two options plus an advance(ms) that moves the
store's clock, so its expiry is tested without waiting. localObjectsConformance takes the
served ids your create serves, and keyProviderConformance the hosted actors.
The federation itself runs on the memory stores and a MemoryCache, with a queue that
collects what it is handed:
import { ACTIVITY_JSON, ActorKeys, Federation, parseActor } from "@sdxc/activitypub";
import {
MemoryFollowerStore,
MemoryLocalObjects,
MemorySeenActivities,
} from "@sdxc/activitypub/testing";
import { MemoryCache } from "@sdxc/cache/memory";
import { isSuccess, unwrap } from "@sdxc/result";
import { expect, test } from "vitest";
import { ACTOR_ID, INBOX_ID } from "~/app/config/activitypub";
import { siteActor } from "~/app/services/site-actor";
async function testFederation(queued: Federation.Message[]) {
let { privateKeyPem } = unwrap(await ActorKeys.generate());
let keys = unwrap(await ActorKeys.import({ actor: ACTOR_ID, privateKeyPem }));
return new Federation({
actor: unwrap(siteActor()),
keys,
stores: {
followers: new MemoryFollowerStore(),
seen: new MemorySeenActivities(),
objects: new MemoryLocalObjects(),
},
cache: new MemoryCache(),
userAgent: "test",
queue: { enqueue: async (message) => void queued.push(message) },
});
}
test("the actor document carries the key followers verify with", async () => {
let federation = await testFederation([]);
let response = await federation.fetch(new Request(ACTOR_ID));
let actor = parseActor(await response?.json());
expect(isSuccess(actor) && actor.data.publicKey?.id).toBe(`${ACTOR_ID}#main-key`);
});
test("an unsigned activity is refused before it is queued", async () => {
let queued: Federation.Message[] = [];
let federation = await testFederation(queued);
let response = await federation.fetch(
new Request(INBOX_ID, {
method: "POST",
headers: { "content-type": ACTIVITY_JSON },
body: JSON.stringify({
id: "https://mastodon.social/users/alice#follows/1",
type: "Follow",
actor: "https://mastodon.social/users/alice",
object: ACTOR_ID,
}),
}),
);
expect(response?.status).toBe(401);
expect(queued).toEqual([]);
});
Neither test leaves the process: the actor is served from memory, and an unsigned request is
refused before anything is fetched. A test of a signed delivery serves the remote actor and
its inbox with MSW and signs the request with
@sdxc/http-signatures. Every outbound request also resolves its host
over DNS-over-HTTPS first, so answer https://cloudflare-dns.com/dns-query with a public address
as well; Fetch URLs a stranger chose shows
that handler. MemorySeenActivities takes a now clock, so a test moves time to check a
claim expires.
Where to go next
Sign and verify HTTP requests — the signatures and digests every delivery carries, on their own.
Join the IndieWeb — Webmention and Micropub, the other way independent sites talk to each other.
Background jobs and cron — the dispatcher, the queue and the verbs every handler here uses.
@sdxc/activitypub— every error code, the delivery table and the quirks of each fediverse server the package absorbs.