From 96fe64c1564bb8c40151b0c99805df04c0de5db6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:54:50 +0000 Subject: [PATCH 1/2] feat(activitypub): follow a followed account's Mastodon Move When an account this actor follows sends a Move, queue a `move` row; the alarm fetches the target and, only if its alsoKnownAs lists the mover, follows the target and sends the mover an Undo(Follow). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LRGjmHE99WESN5HBERxSj --- .changeset/activitypub-inbound-move.md | 5 + packages/activitypub/CLAUDE.md | 2 +- packages/activitypub/README.md | 4 +- packages/activitypub/src/index.test.ts | 134 ++++++++++++++++++++ packages/activitypub/src/log.ts | 12 ++ packages/activitypub/src/object.ts | 168 ++++++++++++++++++++++++- spec/packages/activitypub.md | 17 ++- 7 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 .changeset/activitypub-inbound-move.md diff --git a/.changeset/activitypub-inbound-move.md b/.changeset/activitypub-inbound-move.md new file mode 100644 index 0000000..16d617b --- /dev/null +++ b/.changeset/activitypub-inbound-move.md @@ -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`. diff --git a/packages/activitypub/CLAUDE.md b/packages/activitypub/CLAUDE.md index f4cfba9..5ac8ed2 100644 --- a/packages/activitypub/CLAUDE.md +++ b/packages/activitypub/CLAUDE.md @@ -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 diff --git a/packages/activitypub/README.md b/packages/activitypub/README.md index e65d0dd..61b9288 100644 --- a/packages/activitypub/README.md +++ b/packages/activitypub/README.md @@ -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 diff --git a/packages/activitypub/src/index.test.ts b/packages/activitypub/src/index.test.ts index 39fe7f3..c078f81 100644 --- a/packages/activitypub/src/index.test.ts +++ b/packages/activitypub/src/index.test.ts @@ -2140,3 +2140,137 @@ 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 one Move into a fresh actor that follows REMOTE (when `follows`). */ + async function runMove(opts: { + follows?: boolean; + aliases?: string[]; + move?: Record; + }): Promise<{ + following: { actor: string; state: string }[]; + posts: { url: string; body: Record }[]; + }> { + 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>; + + await runInDurableObject(stub, async (instance, state) => { + const original = globalThis.fetch; + const posts: { url: string; body: Record }[] = []; + 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, + }); + return new Response(null, { status: 202 }); + } + if (href === NEW) { + 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), + }; + + try { + // A first request lets the DO create its schema. + await instance.fetch(new Request(`${iris.id}/__deliver`, { headers })); + if (opts.follows ?? true) { + state.storage.sql.exec( + `INSERT INTO following (actor, state, added_at, inbox) + VALUES (?, 'accepted', 0, ?)`, + REMOTE, + `${REMOTE}/inbox`, + ); + } + await instance.fetch( + new Request(iris.inbox, { + method: "POST", + headers: { + ...headers, + "content-type": "application/activity+json", + [INTERNAL_HEADERS.signedActor]: REMOTE, + }, + body: JSON.stringify({ + id: `${REMOTE}#moves/1`, + type: "Move", + actor: REMOTE, + object: REMOTE, + target: NEW, + ...opts.move, + }), + }), + ); + 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, + }; + } 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("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([]); + }); +}); diff --git a/packages/activitypub/src/log.ts b/packages/activitypub/src/log.ts index 2ef8d87..9d69b95 100644 --- a/packages/activitypub/src/log.ts +++ b/packages/activitypub/src/log.ts @@ -64,6 +64,18 @@ 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`. + */ + 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 diff --git a/packages/activitypub/src/object.ts b/packages/activitypub/src/object.ts index 070e4d6..393a4b1 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -15,6 +15,7 @@ import { DurableObject } from "cloudflare:workers"; import { AS2_CONTENT_TYPE, + AS2_NS, PUBLIC_AUDIENCE, actorIri, buildCollection, @@ -818,6 +819,11 @@ export class ActivityPubObject extends DurableObject { // own Announce — unwrap and store the inner activity too (§2.2). await this.#maybeUnwrapAnnounce(activity, config); break; + case "Move": + // An account we follow migrated (Mastodon `Move`): follow the target + // once it proves the alias, from the alarm (see #onMove). + await this.#onMove(activity, config); + break; case "Flag": // A report (#489). Stored like Like/Dislike/Announce, but // deliberately WITHOUT #maybeForward — a report must never fan out @@ -1594,6 +1600,133 @@ export class ActivityPubObject extends DurableObject { return json(201, activity as JsonValue, { location: id }); } + /** + * Handle an inbound `Move` (Mastodon account migration): when the mover is + * an actor this actor follows, queue a `move` row whose alarm pass fetches + * the target and, only if the target lists the mover in `alsoKnownAs`, + * follows the target and unfollows the mover (#onMoveVerified). The fetch + * stays off the inbound POST's critical path, like `pending_accept`. + * + * The front door already bound the activity's `actor` to the HTTP signer, + * so requiring `object` to equal `actor` means only the account itself can + * announce its move. Anything else — a `Move` of some other object, one we + * don't follow, an unsafe or self target — is accepted and ignored. + */ + async #onMove( + activity: ActivityObject, + config: ForwardedConfig, + ): Promise { + const mover = actorIri(activity.actor); + const target = objectId(activity.target as JsonValue); + if ( + !mover || + objectId(activity.object as JsonValue) !== mover || + !target || + target === mover || + !isSafeTarget(target) + ) { + return; + } + const followed = + this.#sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM following WHERE actor = ?`, + mover, + ) + .one().n > 0; + const queued = + this.#sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM pending_accept WHERE kind = 'move' AND event = ?`, + mover, + ) + .one().n > 0; + if (!followed || queued) return; + // Built now, while the forwarded config (our actor IRI) is in hand: the + // alarm that sends them can wake on a cold isolate without it. + const me = config.iris.id; + const follow = { + "@context": AS2_NS, + id: `${me}#follows/${crypto.randomUUID()}`, + type: "Follow", + actor: me, + object: target, + }; + const undo = { + "@context": AS2_NS, + id: `${me}#undos/${crypto.randomUUID()}`, + type: "Undo", + actor: me, + object: { type: "Follow", actor: me, object: mover }, + }; + this.#sql.exec( + `INSERT INTO pending_accept (kind, actor, event, json, attempts, next_at) + VALUES ('move', ?, ?, ?, 0, ?)`, + target, + mover, + JSON.stringify({ follow, undo }), + Date.now(), + ); + await this.#armAlarm(); + } + + /** + * Apply a verified `Move` from `mover` to `target`: follow `target` (unless + * already following it), drop the `mover` following row, and send `mover` an + * `Undo(Follow)`. Mastodon matches that `Undo` on actor + object, so the + * original `Follow` id is not needed. + */ + #onMoveVerified( + mover: string, + target: string, + resolved: { + inbox: string; + sharedInbox: string | null; + actorType: string | null; + }, + json: string, + ): void { + let bodies: { follow: unknown; undo: unknown }; + try { + bodies = JSON.parse(json) as { follow: unknown; undo: unknown }; + } catch { + return; + } + const alreadyFollowing = + this.#sql + .exec<{ n: number }>( + `SELECT COUNT(*) AS n FROM following WHERE actor = ?`, + target, + ) + .one().n > 0; + if (!alreadyFollowing) { + this.#sql.exec( + `INSERT INTO following (actor, state, added_at, actor_type, inbox, shared_inbox) + VALUES (?, 'pending', ?, ?, ?, ?)`, + target, + Date.now(), + resolved.actorType ?? "Unknown", + resolved.inbox, + resolved.sharedInbox, + ); + this.#enqueueDelivery(resolved.inbox, JSON.stringify(bodies.follow)); + } + const old = this.#sql + .exec<{ inbox: string | null }>( + `DELETE FROM following WHERE actor = ? RETURNING inbox`, + mover, + ) + .toArray()[0]; + const undo = JSON.stringify(bodies.undo); + if (old?.inbox) this.#enqueueDelivery(old.inbox, undo); + else this.#enqueuePendingDelivery(mover, undo); + this.#logDelivery(ActivityPubLogEvent.MoveFollowed, { + fromHost: hostFromUrl(mover) ?? "unknown", + toHost: hostFromUrl(target) ?? "unknown", + alreadyFollowing, + }); + } + /** * Route an owner-published relationship activity (`Follow`, `Undo(Follow)`) * to its single target: upsert/delete the `following` row (state `pending` @@ -3861,6 +3994,18 @@ export class ActivityPubObject extends DurableObject { .one().n > 0 ); } + if (row.kind === "move") { + // Only while we still follow the mover (an owner unfollow in between + // cancels the migration). + return ( + row.event !== null && + this.#sql + .exec<{ + n: number; + }>(`SELECT COUNT(*) AS n FROM following WHERE actor = ?`, row.event) + .one().n > 0 + ); + } if (!row.event) return false; return ( this.#sql @@ -3941,6 +4086,21 @@ export class ActivityPubObject extends DurableObject { }); continue; } + if (row.kind === "move") { + // Mastodon's own check: the target must list the mover as an alias, + // or anyone could "move" an account onto an arbitrary actor. + if (row.event && resolved.alsoKnownAs.includes(row.event)) { + this.#onMoveVerified(row.event, row.actor, resolved, row.json); + } else { + this.#logDelivery(ActivityPubLogEvent.MoveRejected, { + fromHost: hostFromUrl(row.event ?? "") ?? "unknown", + toHost: hostFromUrl(row.actor) ?? "unknown", + reason: "alias_missing", + }); + } + this.#sql.exec(`DELETE FROM pending_accept WHERE seq = ?`, row.seq); + continue; + } if (row.kind === "follow") { // Keep the shared inbox separately so future fan-out can batch // deliveries per instance (spec/fediverse-interop.md storage deltas). @@ -4074,6 +4234,8 @@ export class ActivityPubObject extends DurableObject { /** The actor's own `inbox`, for blind deliveries that must not batch. */ personalInbox: string | null; actorType: string | null; + /** The actor's `alsoKnownAs` IRIs (Move target validation). */ + alsoKnownAs: readonly string[]; } | null> { try { assertPublicHttpsTarget(actor); @@ -4115,9 +4277,13 @@ export class ActivityPubObject extends DurableObject { const personal = typeof record.inbox === "string" ? record.inbox : null; const inbox = sharedInbox ?? personal; const actorType = typeof record.type === "string" ? record.type : null; + const aliases = record.alsoKnownAs; + const alsoKnownAs = (Array.isArray(aliases) ? aliases : [aliases]).filter( + (alias): alias is string => typeof alias === "string", + ); return inbox === null ? null - : { inbox, sharedInbox, personalInbox: personal, actorType }; + : { inbox, sharedInbox, personalInbox: personal, actorType, alsoKnownAs }; } /** diff --git a/spec/packages/activitypub.md b/spec/packages/activitypub.md index 28b8746..2875cb0 100644 --- a/spec/packages/activitypub.md +++ b/spec/packages/activitypub.md @@ -91,7 +91,7 @@ large media; bodies **MUST** stream and **MUST NOT** be buffered in the DO. - Inbound `POST /inbox`: verify the HTTP signature, dedup by activity `id`, and handle `Follow` / `Undo` / `Create` / `Update` / `Like` / `Announce` / - `Delete`. A **temporary** signature-verification failure — the signer's key + `Delete` / `Move`. A **temporary** signature-verification failure — the signer's key could not be resolved (e.g. their server was briefly unreachable, or an anti-scraper challenge layer the signer's host runs blocks an unidentified client — see `userAgent` below) — answers **`503` + `Retry-After`** so the @@ -103,6 +103,21 @@ large media; bodies **MUST** stream and **MUST NOT** be buffered in the DO. `key_unresolved` alone doesn't distinguish a network error from a challenge/redirect loop (or, on Cloudflare, a same-zone key fetch looped by the zone's Flexible SSL/TLS mode — see the package README). +- Inbound **`Move`** (Mastodon account migration, the follower side). A + `Move` is acted on only when its `actor` (already bound to the HTTP signer) + equals its `object`, its `target` is a different, SSRF-safe IRI, and this + actor has a `following` row for the mover; otherwise it is accepted and + ignored. Acting on it queues a `pending_accept` row of kind `move` (target + in `actor`, mover in `event`, the prebuilt `Follow` and `Undo(Follow)` in + `json`), so the target fetch runs from the alarm, not on the inbound POST. + The alarm resolves the target's actor document and requires its + `alsoKnownAs` to contain the mover, as Mastodon does. If it does, this actor + follows the target (a `pending` `following` row plus a `Follow`, skipped if + it already follows the target), deletes the mover's `following` row, and + sends the mover an `Undo(Follow)`. If it doesn't, nothing changes + (`activitypub.move.rejected`). An owner unfollow of the mover before the + alarm runs cancels the queued move. At most one `move` row is queued per + mover. The `Move` itself is not stored in the inbox. - Outbound delivery: fan out activities to follower inboxes with retry/backoff via **DO alarms** (and a Queue where composed), signing each request. Every outbound fetch (key resolution, delivery, discovery) carries a configurable From 9698fe9b964ba54bd2cbd3280a0722bc33e17ddb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 00:01:40 +0000 Subject: [PATCH 2/2] fix(activitypub): address inbound Move review feedback Don't mark an existing follow of the migration target `Unknown` when the target can't be resolved; document MoveRejected's single reason; and add DO tests for move dedupe, owner-unfollow cancellation, an already-followed target, and self/private/non-https targets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LRGjmHE99WESN5HBERxSj --- packages/activitypub/src/index.test.ts | 110 +++++++++++++++++++------ packages/activitypub/src/log.ts | 4 +- packages/activitypub/src/object.ts | 6 +- 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/packages/activitypub/src/index.test.ts b/packages/activitypub/src/index.test.ts index c078f81..2791590 100644 --- a/packages/activitypub/src/index.test.ts +++ b/packages/activitypub/src/index.test.ts @@ -2149,14 +2149,21 @@ describe("FEP-8b32 integrity proofs", () => { describe("inbound Move", () => { const NEW = "https://new.example/users/alice"; - /** Drive one Move into a fresh actor that follows REMOTE (when `follows`). */ + /** Drive Move(s) into a fresh actor that follows REMOTE (when `follows`). */ async function runMove(opts: { follows?: boolean; + alsoFollowsTarget?: boolean; aliases?: string[]; move?: Record; + /** 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 }[]; + queuedMoves: number; + targetFetches: number; }> { const username = `bob-${crypto.randomUUID().slice(0, 8)}`; const iris = deriveIris(BASE, username); @@ -2166,6 +2173,7 @@ describe("inbound Move", () => { await runInDurableObject(stub, async (instance, state) => { const original = globalThis.fetch; const posts: { url: string; body: Record }[] = []; + let targetFetches = 0; globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { const href = typeof url === "string" ? url : url.toString(); if (init?.method === "POST") { @@ -2178,6 +2186,7 @@ describe("inbound Move", () => { return new Response(null, { status: 202 }); } if (href === NEW) { + targetFetches++; return new Response( JSON.stringify({ id: NEW, @@ -2193,36 +2202,45 @@ describe("inbound Move", () => { 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) { - state.storage.sql.exec( - `INSERT INTO following (actor, state, added_at, inbox) - VALUES (?, 'accepted', 0, ?)`, - REMOTE, - `${REMOTE}/inbox`, + 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, + }), + }), ); } - await instance.fetch( - new Request(iris.inbox, { - method: "POST", - headers: { - ...headers, - "content-type": "application/activity+json", - [INTERNAL_HEADERS.signedActor]: REMOTE, - }, - body: JSON.stringify({ - id: `${REMOTE}#moves/1`, - 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 @@ -2231,6 +2249,8 @@ describe("inbound Move", () => { ) .toArray(), posts, + queuedMoves, + targetFetches, }; } finally { globalThis.fetch = original; @@ -2266,6 +2286,46 @@ describe("inbound Move", () => { 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" }, diff --git a/packages/activitypub/src/log.ts b/packages/activitypub/src/log.ts index 9d69b95..bb1e82c 100644 --- a/packages/activitypub/src/log.ts +++ b/packages/activitypub/src/log.ts @@ -73,7 +73,9 @@ export const ActivityPubLogEvent = { /** * A `Move` from a followed account was not acted on because the target's * `alsoKnownAs` does not list the mover. Fields: `fromHost`, `toHost`, - * `reason`. + * `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", /** diff --git a/packages/activitypub/src/object.ts b/packages/activitypub/src/object.ts index 393a4b1..85a16ea 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -4062,9 +4062,11 @@ export class ActivityPubObject extends DurableObject { // A profile resolution that is about to hit the attempts ceiling // marks the following row 'Unknown' so the backfill never re-queues // it (and it never qualifies as a Group). + // A `move` row's actor is the migration target, not a follow being + // typed: an unreachable target must not mark an existing follow of it. if ( - row.attempts + 1 >= - this.#deliveryPolicy("deliveryMaxAttempts", 8) + row.kind !== "move" && + row.attempts + 1 >= this.#deliveryPolicy("deliveryMaxAttempts", 8) ) { this.#sql.exec( `UPDATE following SET actor_type = 'Unknown'