Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/activitypub-inbound-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dwk/activitypub": minor
---

Handle inbound `Move` (Mastodon account migration). When an account this actor follows announces a move, the actor follows the target and sends the old account an `Undo(Follow)`, but only after the target's `alsoKnownAs` is verified to list the old account. The target fetch runs from the alarm, off the inbound request path. New log events: `activitypub.move.followed` and `activitypub.move.rejected`.
2 changes: 1 addition & 1 deletion packages/activitypub/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Native ActivityPub actor rooted at the user's domain. Per-actor Durable Object
manages inbox/outbox/followers/following collections with SQLite. Handles
inbound `POST /inbox` with HTTP signature verification (RFC 9421 + draft-cavage),
activity deduplication, and activity-type handlers (Follow, Undo, Create, Update,
Like, Announce, Delete). Outbound delivery retries via DO alarms with backoff.
Like, Announce, Delete, Move). Outbound delivery retries via DO alarms with backoff.
Serves actor documents, collection pages, and NodeInfo for fediverse discovery.
Also contributes a read-only `@dwk/mcp` tool (`createActivitypubMcpTools` →
`activitypub_list_inbox`) over a new internal-only DO route — the public
Expand Down
4 changes: 3 additions & 1 deletion packages/activitypub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ This is the second `@dwk` package to ship a Durable Object.
`OrderedCollection`s; `inbox` is write-only to peers.
- **Server-to-server (inbound)** `POST /inbox`: HTTP-signature verification at
the edge, dedup by activity `id`, and handling of `Follow` / `Undo` /
`Accept` / `Create` / `Update` / `Like` / `Announce` / `Delete`.
`Accept` / `Create` / `Update` / `Like` / `Announce` / `Delete` / `Move`.
A `Move` from an account this actor follows switches the follow to the
new account once that account lists the old one in `alsoKnownAs`.
- **Server-to-server (outbound)**: auto-`Accept` of follows and signed fan-out
delivery to follower inboxes, with retry/backoff driven by **DO alarms**.
- **NodeInfo** — the `/.well-known/nodeinfo` discovery document and a
Expand Down
194 changes: 194 additions & 0 deletions packages/activitypub/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2140,3 +2140,197 @@ describe("FEP-8b32 integrity proofs", () => {
});
});
});

// ---------------------------------------------------------------------------
// Inbound Move (Mastodon account migration): follow the target, unfollow the
// mover — only when the target lists the mover in alsoKnownAs.
// ---------------------------------------------------------------------------

describe("inbound Move", () => {
const NEW = "https://new.example/users/alice";

/** Drive Move(s) into a fresh actor that follows REMOTE (when `follows`). */
async function runMove(opts: {
follows?: boolean;
alsoFollowsTarget?: boolean;
aliases?: string[];
move?: Record<string, unknown>;
/** Move activity ids to send (each a distinct activity). */
moveIds?: string[];
/** Runs after the Move(s) land and before the alarm pass. */
beforeAlarm?: (sql: SqlStorage) => void;
}): Promise<{
following: { actor: string; state: string }[];
posts: { url: string; body: Record<string, unknown> }[];
queuedMoves: number;
targetFetches: number;
}> {
const username = `bob-${crypto.randomUUID().slice(0, 8)}`;
const iris = deriveIris(BASE, username);
const stub = testEnv.ACTOR.get(testEnv.ACTOR.idFromName(iris.id));
let out!: Awaited<ReturnType<typeof runMove>>;

await runInDurableObject(stub, async (instance, state) => {
const original = globalThis.fetch;
const posts: { url: string; body: Record<string, unknown> }[] = [];
let targetFetches = 0;
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const href = typeof url === "string" ? url : url.toString();
if (init?.method === "POST") {
posts.push({
url: href,
body: JSON.parse(
new TextDecoder().decode(init.body as ArrayBufferView),
) as Record<string, unknown>,
});
return new Response(null, { status: 202 });
}
if (href === NEW) {
targetFetches++;
return new Response(
JSON.stringify({
id: NEW,
type: "Person",
inbox: `${NEW}/inbox`,
alsoKnownAs: opts.aliases ?? [REMOTE],
}),
{ headers: { "content-type": "application/activity+json" } },
);
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
const headers = {
[INTERNAL_HEADERS.config]: forwardedHeader(username),
};
const follow = (actor: string) =>
state.storage.sql.exec(
`INSERT INTO following (actor, state, added_at, inbox)
VALUES (?, 'accepted', 0, ?)`,
actor,
`${actor}/inbox`,
);

try {
// A first request lets the DO create its schema.
await instance.fetch(new Request(`${iris.id}/__deliver`, { headers }));
if (opts.follows ?? true) follow(REMOTE);
if (opts.alsoFollowsTarget) follow(NEW);
for (const id of opts.moveIds ?? [`${REMOTE}#moves/1`]) {
await instance.fetch(
new Request(iris.inbox, {
method: "POST",
headers: {
...headers,
"content-type": "application/activity+json",
[INTERNAL_HEADERS.signedActor]: REMOTE,
},
body: JSON.stringify({
id,
type: "Move",
actor: REMOTE,
object: REMOTE,
target: NEW,
...opts.move,
}),
}),
);
}
const queuedMoves = state.storage.sql
.exec<{
n: number;
}>("SELECT COUNT(*) AS n FROM pending_accept WHERE kind = 'move'")
.one().n;
opts.beforeAlarm?.(state.storage.sql);
await instance.fetch(new Request(`${iris.id}/__deliver`, { headers }));
out = {
following: state.storage.sql
.exec<{ actor: string; state: string }>(
"SELECT actor, state FROM following ORDER BY actor",
)
.toArray(),
posts,
queuedMoves,
targetFetches,
};
} finally {
globalThis.fetch = original;
}
});
return out;
}

it("follows a verified target and unfollows the mover", async () => {
const { following, posts } = await runMove({});

expect(following).toEqual([{ actor: NEW, state: "pending" }]);
const follow = posts.find((p) => p.url === `${NEW}/inbox`);
expect(follow?.body).toMatchObject({ type: "Follow", object: NEW });
const undo = posts.find((p) => p.url === `${REMOTE}/inbox`);
expect(undo?.body).toMatchObject({
type: "Undo",
object: { type: "Follow", object: REMOTE },
});
});

it("ignores a target that does not list the mover in alsoKnownAs", async () => {
const { following, posts } = await runMove({
aliases: ["https://elsewhere.example/users/mallory"],
});
expect(following).toEqual([{ actor: REMOTE, state: "accepted" }]);
expect(posts).toEqual([]);
});

it("ignores a Move from an account this actor does not follow", async () => {
const { following, posts } = await runMove({ follows: false });
expect(following).toEqual([]);
expect(posts).toEqual([]);
});

it("queues at most one move per mover, however many Moves arrive", async () => {
const { following, queuedMoves, targetFetches } = await runMove({
moveIds: [`${REMOTE}#moves/1`, `${REMOTE}#moves/2`],
});
expect(queuedMoves).toBe(1);
expect(targetFetches).toBe(1);
expect(following).toEqual([{ actor: NEW, state: "pending" }]);
});

it("cancels a queued move when the owner unfollows the mover first", async () => {
const { following, posts, targetFetches } = await runMove({
beforeAlarm: (sql) => {
sql.exec("DELETE FROM following WHERE actor = ?", REMOTE);
},
});
expect(targetFetches).toBe(0);
expect(following).toEqual([]);
expect(posts).toEqual([]);
});

it("only unfollows the mover when the target is already followed", async () => {
const { following, posts } = await runMove({ alsoFollowsTarget: true });
expect(following).toEqual([{ actor: NEW, state: "accepted" }]);
expect(posts.map((p) => p.body.type)).toEqual(["Undo"]);
expect(posts[0]?.url).toBe(`${REMOTE}/inbox`);
});

it.each([
["the mover itself", REMOTE],
["a private-network host", "https://127.0.0.1/users/alice"],
["a non-https IRI", "http://new.example/users/alice"],
])("ignores a Move whose target is %s", async (_label, target) => {
const { following, posts, queuedMoves } = await runMove({
move: { target },
});
expect(queuedMoves).toBe(0);
expect(following).toEqual([{ actor: REMOTE, state: "accepted" }]);
expect(posts).toEqual([]);
});

it("ignores a Move whose object is not the moving actor", async () => {
const { following, posts } = await runMove({
move: { object: "https://remote.example/users/carol" },
});
expect(following).toEqual([{ actor: REMOTE, state: "accepted" }]);
expect(posts).toEqual([]);
});
});
14 changes: 14 additions & 0 deletions packages/activitypub/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ export const ActivityPubLogEvent = {
* HTTP signature). Logged once per distinct key. Field: `errorName`.
*/
IntegrityKeyInvalid: "activitypub.integrity.key_invalid",
/**
* An account this actor followed moved (Mastodon `Move`) and the target
* proved the alias: this actor now follows the target and unfollowed the
* mover. Fields: `fromHost`, `toHost`, `alreadyFollowing`.
*/
MoveFollowed: "activitypub.move.followed",
/**
* A `Move` from a followed account was not acted on because the target's
* `alsoKnownAs` does not list the mover. Fields: `fromHost`, `toHost`,
* `reason` (currently always `alias_missing`; a target that cannot be
* fetched is retried and surfaces as `DeliveryFailed` with
* `stage: "resolve"` instead).
*/
MoveRejected: "activitypub.move.rejected",
/**
* The default key resolver could not resolve an inbound signer's key —
* surfaces *why*, since `SignatureRejected`'s `key_unresolved` reason alone
Expand Down
Loading
Loading