From 054a564a5d629fe8f3f30c8d641f787e4fdf27ea Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 6 Aug 2026 03:39:37 +0900 Subject: [PATCH 01/10] Clarify relay follower state tests Make protocol expectations explicit before consolidating shared relay behavior. Verify that Mastodon immediately accepts approved followers and that LitePub Undo removes pending and accepted followers. Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/litepub.test.ts | 98 ++++++++++++++--------------- packages/relay/src/mastodon.test.ts | 5 +- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 899bf6d8b..a8955e1e9 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -583,64 +583,64 @@ describe("LitePubRelay", () => { strictEqual(followerData.state, "accepted"); }); - test("handles Undo Follow activity", async () => { - const kv = new MemoryKvStore(); + for (const state of ["pending", "accepted"] as const) { + test(`handles Undo Follow activity for ${state} follower`, async () => { + const kv = new MemoryKvStore(); - // Pre-populate with an accepted follower - const followerId = "https://remote.example.com/users/alice"; - const follower = new Person({ - id: new URL(followerId), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); + const followerId = "https://remote.example.com/users/alice"; + const follower = new Person({ + id: new URL(followerId), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); - await kv.set( - ["follower", followerId], - { actor: await follower.toJsonLd(), state: "accepted" }, - ); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state }, + ); - const relay = createRelay("litepub", { - kv, - origin: "https://relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - subscriptionHandler: () => Promise.resolve(true), - }); + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); - const originalFollow = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), - actor: new URL(followerId), - object: new URL("https://relay.example.com/users/relay"), - }); + const originalFollow = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: new URL(followerId), + object: new URL("https://relay.example.com/users/relay"), + }); - const undoActivity = new Undo({ - id: new URL("https://remote.example.com/activities/undo/1"), - actor: new URL(followerId), - object: originalFollow, - }); + const undoActivity = new Undo({ + id: new URL("https://remote.example.com/activities/undo/1"), + actor: new URL(followerId), + object: originalFollow, + }); - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - }, - body: JSON.stringify( - await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id, - ); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); - await relay.fetch(request); + await relay.fetch(request); - // Verify follower was removed - const followerData = await kv.get(["follower", followerId]); - strictEqual(followerData, undefined); - }); + const followerData = await kv.get(["follower", followerId]); + strictEqual(followerData, undefined); + }); + } test("handles Create activity with Announce forwarding", async () => { const kv = new MemoryKvStore(); diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index d5a73b503..3737c851a 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -389,12 +389,13 @@ describe("MastodonRelay", () => { strictEqual(handlerCalled, true); ok(handlerActor); - // Verify follower was stored + // Verify follower was immediately accepted const followerData = await kv.get([ "follower", "https://remote.example.com/users/alice", ]); - ok(followerData); + ok(isRelayFollowerData(followerData)); + strictEqual(followerData.state, "accepted"); }); test("handles Follow activity with subscription rejection", async () => { From 10b6e87b9f8f0d2aadbfbe8becff69b09a431c5d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 6 Aug 2026 20:01:45 +0900 Subject: [PATCH 02/10] Add Mastodon Announce forwarding coverage Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/mastodon.test.ts | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 3737c851a..3c1d27b0a 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -2,6 +2,7 @@ import { MemoryKvStore, signRequest } from "@fedify/fedify"; import { createRelay, type RelayOptions } from "@fedify/relay"; import { + Announce, Create, Delete, Follow, @@ -689,6 +690,45 @@ describe("MastodonRelay", () => { ok(response.status === 200 || response.status === 202); }); + test("handles Announce activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); + + const announceActivity = new Announce({ + id: new URL("https://remote.example.com/activities/announce/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/notes/1"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await announceActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted + ok(response.status === 200 || response.status === 202); + }); + test("ignores Follow activity without required fields", async () => { const kv = new MemoryKvStore(); From 85495a0e8f64a3aab08e39ad018e9e1c1696515f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 6 Aug 2026 20:05:15 +0900 Subject: [PATCH 03/10] Consolidate relay protocol handling Move shared follow, undo, listener registration, and forwarding behavior into BaseRelay so protocol-specific differences remain explicit in the Mastodon and LitePub implementations. https://github.com/fedify-dev/fedify/issues/905 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/base.ts | 130 ++++++++++++++++++++++-- packages/relay/src/litepub.ts | 178 ++++++++++++--------------------- packages/relay/src/mastodon.ts | 78 ++------------- packages/relay/src/types.ts | 11 +- 4 files changed, 204 insertions(+), 193 deletions(-) diff --git a/packages/relay/src/base.ts b/packages/relay/src/base.ts index d7a18e075..8161b0ddc 100644 --- a/packages/relay/src/base.ts +++ b/packages/relay/src/base.ts @@ -1,13 +1,40 @@ -import type { Context, Federation, FederationBuilder } from "@fedify/fedify"; -import { isActor, Object as APObject } from "@fedify/vocab"; +import type { + Context, + Federation, + FederationBuilder, + InboxContext, + InboxListenerSetters, +} from "@fedify/fedify"; +import { + type Actor, + Announce, + Create, + Delete, + Follow, + isActor, + Move, + Object as APObject, + Undo, + Update, +} from "@fedify/vocab"; +import type { Logger } from "@logtape/logtape"; +import { + handleUndoFollow, + sendFollowResponse, + validateFollowActivity, +} from "./follow.ts"; import { isRelayFollowerData, type Relay, RELAY_SERVER_ACTOR, type RelayFollower, + type RelayFollowerState, type RelayOptions, } from "./types.ts"; +/** @internal */ +export type RelayableActivity = Create | Delete | Move | Update | Announce; + /** * Abstract base class for relay implementations. * Provides common infrastructure for both Mastodon and LitePub relays. @@ -19,6 +46,9 @@ export abstract class BaseRelay implements Relay { protected options: RelayOptions; protected federation?: Federation; + protected abstract readonly initialFollowerState: RelayFollowerState; + protected abstract readonly logger: Logger; + constructor( options: RelayOptions, relayBuilder: FederationBuilder, @@ -126,11 +156,97 @@ export abstract class BaseRelay implements Relay { return await this.parseFollowerData(actorId, followerData); } - /** - * Set up inbox listeners for handling ActivityPub activities. - * Each relay type implements this method with protocol-specific logic. - */ - protected abstract setupInboxListeners(): void; + protected shouldSkipFollow( + _ctx: InboxContext, + _follower: Actor, + ): Promise { + return Promise.resolve(false); + } + + protected afterFollowApproved( + _ctx: InboxContext, + _follower: Actor, + ): Promise { + return Promise.resolve(); + } + + protected abstract deliverActivity( + ctx: InboxContext, + activity: RelayableActivity, + excludeBaseUris: URL[], + ): Promise; + + async #handleFollow( + ctx: InboxContext, + follow: Follow, + ): Promise { + const follower = await validateFollowActivity(ctx, follow); + if (follower?.id == null || await this.shouldSkipFollow(ctx, follower)) { + return; + } + + const approved = await this.options.subscriptionHandler(ctx, follower); + if (approved) { + await ctx.data.kv.set( + ["follower", follower.id.href], + { + actor: await follower.toJsonLd(), + state: this.initialFollowerState, + }, + ); + } + + await sendFollowResponse(ctx, follow, follower, approved); + if (approved) await this.afterFollowApproved(ctx, follower); + } + + async #relayActivity( + ctx: InboxContext, + activity: RelayableActivity, + ): Promise { + const sender = await activity.getActor(ctx); + const excludeBaseUris = sender?.id == null ? [] : [new URL(sender.id)]; + await this.deliverActivity(ctx, activity, excludeBaseUris); + } + + protected setupInboxListeners(): InboxListenerSetters { + if (this.federation == null) { + throw new Error("Federation must be initialized before inbox listeners"); + } + + const listeners = this.federation.setInboxListeners( + "/users/{identifier}/inbox", + "/inbox", + ); + listeners + .on(Follow, async (ctx, follow) => await this.#handleFollow(ctx, follow)) + .on( + Undo, + async (ctx, undo) => await handleUndoFollow(ctx, undo, this.logger), + ) + .on( + Create, + async (ctx, create) => await this.#relayActivity(ctx, create), + ) + .on( + Delete, + async (ctx, deleteActivity) => + await this.#relayActivity(ctx, deleteActivity), + ) + .on( + Move, + async (ctx, move) => await this.#relayActivity(ctx, move), + ) + .on( + Update, + async (ctx, update) => await this.#relayActivity(ctx, update), + ) + .on( + Announce, + async (ctx, announce) => await this.#relayActivity(ctx, announce), + ); + return listeners; + } async #getFederation(): Promise> { if (this.federation == null) { diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 8114e0f50..653d7c08b 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -1,23 +1,14 @@ -import type { InboxContext } from "@fedify/fedify"; +import type { InboxContext, InboxListenerSetters } from "@fedify/fedify"; import { Accept, + type Actor, Announce, - Create, - Delete, Follow, isActor, - Move, PUBLIC_COLLECTION, - Undo, - Update, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { BaseRelay } from "./base.ts"; -import { - handleUndoFollow, - sendFollowResponse, - validateFollowActivity, -} from "./follow.ts"; +import { BaseRelay, type RelayableActivity } from "./base.ts"; import { RELAY_SERVER_ACTOR, type RelayFollowerData, @@ -34,13 +25,43 @@ const logger = getLogger(["fedify", "relay", "litepub"]); * @since 2.0.0 */ export class LitePubRelay extends BaseRelay { - async #announceToFollowers( + protected readonly initialFollowerState = "pending"; + protected readonly logger = logger; + + protected override async shouldSkipFollow( + ctx: InboxContext, + follower: Actor, + ): Promise { + if (follower.id == null) return true; + const existingFollow = await ctx.data.kv.get([ + "follower", + follower.id.href, + ]); + return existingFollow?.state === "pending"; + } + + protected override async afterFollowApproved( ctx: InboxContext, - activity: Create | Delete | Move | Update | Announce, + follower: Actor, ): Promise { - const sender = await activity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + if (follower.id == null) return; + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Follow({ + actor: relayActorUri, + object: follower.id, + to: follower.id, + }), + ); + } + protected async deliverActivity( + ctx: InboxContext, + activity: RelayableActivity, + excludeBaseUris: URL[], + ): Promise { const announce = new Announce({ id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), actor: ctx.getActorUri(RELAY_SERVER_ACTOR), @@ -60,105 +81,36 @@ export class LitePubRelay extends BaseRelay { ); } - protected setupInboxListeners(): void { - if (this.federation != null) { - this.federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - const follower = await validateFollowActivity(ctx, follow); - if (!follower || !follower.id) return; - - // Litepub-specific: check if already in pending state - const existingFollow = await ctx.data.kv.get([ - "follower", - follower.id.href, - ]); - if (existingFollow?.state === "pending") return; - - const approved = await this.options.subscriptionHandler( - ctx, - follower, - ); + protected override setupInboxListeners(): InboxListenerSetters { + return super.setupInboxListeners().on(Accept, async (ctx, accept) => { + // Validate follow activity from accept activity + const follow = await accept.getObject({ + crossOrigin: "trust", + ...ctx, + }); + if (!(follow instanceof Follow)) return; + const relayActorId = follow.actorId; + if (relayActorId == null) return; - if (approved) { - // Litepub-specific: save with "pending" state - await ctx.data.kv.set( - ["follower", follower.id.href], - { actor: await follower.toJsonLd(), state: "pending" }, - ); + // Validate follower actor - accept activity sender + const followerActor = await accept.getActor(); + if (!isActor(followerActor) || !followerActor.id) return; + const parsed = ctx.parseUri(relayActorId); + if (parsed == null || parsed.type !== "actor") return; - await sendFollowResponse(ctx, follow, follower, approved); + // Get follower from kv store + const followerData = await ctx.data.kv.get([ + "follower", + followerActor.id.href, + ]); + if (followerData == null) return; - // Litepub-specific: send reciprocal follow - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Follow({ - actor: relayActorUri, - object: follower.id, - to: follower.id, - }), - ); - } else { - await sendFollowResponse(ctx, follow, follower, approved); - } - }) - .on(Accept, async (ctx, accept) => { - // Validate follow activity from accept activity - const follow = await accept.getObject({ - crossOrigin: "trust", - ...ctx, - }); - if (!(follow instanceof Follow)) return; - const relayActorId = follow.actorId; - if (relayActorId == null) return; - - // Validate follower actor - accept activity sender - const followerActor = await accept.getActor(); - if (!isActor(followerActor) || !followerActor.id) return; - const parsed = ctx.parseUri(relayActorId); - if (parsed == null || parsed.type !== "actor") return; - - // Get follower from kv store - const followerData = await ctx.data.kv.get([ - "follower", - followerActor.id.href, - ]); - if (followerData == null) return; - - // Update follower state to accepted - const updatedFollowerData = { ...followerData, state: "accepted" }; - await ctx.data.kv.set( - ["follower", followerActor.id.href], - updatedFollowerData, - ); - }) - .on( - Undo, - async (ctx, undo) => await handleUndoFollow(ctx, undo, logger), - ) - .on( - Create, - async (ctx, create) => await this.#announceToFollowers(ctx, create), - ) - .on( - Update, - async (ctx, update) => await this.#announceToFollowers(ctx, update), - ) - .on( - Move, - async (ctx, move) => await this.#announceToFollowers(ctx, move), - ) - .on( - Delete, - async (ctx, deleteActivity) => - await this.#announceToFollowers(ctx, deleteActivity), - ) - .on( - Announce, - async (ctx, announce) => - await this.#announceToFollowers(ctx, announce), - ); - } + // Update follower state to accepted + const updatedFollowerData = { ...followerData, state: "accepted" }; + await ctx.data.kv.set( + ["follower", followerActor.id.href], + updatedFollowerData, + ); + }); } } diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index a1136be6a..fc5b99000 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -1,20 +1,6 @@ import type { InboxContext } from "@fedify/fedify"; -import { - Announce, - Create, - Delete, - Follow, - Move, - Undo, - Update, -} from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { BaseRelay } from "./base.ts"; -import { - handleUndoFollow, - sendFollowResponse, - validateFollowActivity, -} from "./follow.ts"; +import { BaseRelay, type RelayableActivity } from "./base.ts"; import { RELAY_SERVER_ACTOR, type RelayOptions } from "./types.ts"; const logger = getLogger(["fedify", "relay", "mastodon"]); @@ -27,13 +13,14 @@ const logger = getLogger(["fedify", "relay", "mastodon"]); * @since 2.0.0 */ export class MastodonRelay extends BaseRelay { - async #forwardToFollowers( + protected readonly initialFollowerState = "accepted"; + protected readonly logger = logger; + + protected async deliverActivity( ctx: InboxContext, - activity: Create | Delete | Move | Update | Announce, + _activity: RelayableActivity, + excludeBaseUris: URL[], ): Promise { - const sender = await activity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - await ctx.forwardActivity( { identifier: RELAY_SERVER_ACTOR }, "followers", @@ -44,55 +31,4 @@ export class MastodonRelay extends BaseRelay { }, ); } - - protected setupInboxListeners(): void { - if (this.federation != null) { - this.federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - const follower = await validateFollowActivity(ctx, follow); - if (!follower || !follower.id) return; - - const approved = await this.options.subscriptionHandler( - ctx, - follower, - ); - - if (approved) { - // Mastodon-specific: immediately add to followers list with accepted state - await ctx.data.kv.set( - ["follower", follower.id.href], - { actor: await follower.toJsonLd(), state: "accepted" }, - ); - } - - await sendFollowResponse(ctx, follow, follower, approved); - }) - .on( - Undo, - async (ctx, undo) => await handleUndoFollow(ctx, undo, logger), - ) - .on( - Create, - async (ctx, create) => await this.#forwardToFollowers(ctx, create), - ) - .on( - Delete, - async (ctx, deleteActivity) => - await this.#forwardToFollowers(ctx, deleteActivity), - ) - .on( - Move, - async (ctx, move) => await this.#forwardToFollowers(ctx, move), - ) - .on( - Update, - async (ctx, update) => await this.#forwardToFollowers(ctx, update), - ) - .on( - Announce, - async (ctx, announce) => - await this.#forwardToFollowers(ctx, announce), - ); - } - } } diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index fd906bed7..da1671dcb 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -12,6 +12,13 @@ export const RELAY_SERVER_ACTOR = "relay"; */ export type RelayType = "mastodon" | "litepub"; +/** + * A follower's subscription state. + * + * @internal + */ +export type RelayFollowerState = "pending" | "accepted"; + /** * Handler for subscription requests (Follow/Undo activities). */ @@ -79,7 +86,7 @@ export interface RelayFollowerData { /** The actor's JSON-LD representation (serialized for storage). */ readonly actor: unknown; /** The follower's state. */ - readonly state: "pending" | "accepted"; + readonly state: RelayFollowerState; } /** @@ -94,7 +101,7 @@ export interface RelayFollower { /** The validated Actor object. */ readonly actor: Actor; /** The follower's state. */ - readonly state: "pending" | "accepted"; + readonly state: RelayFollowerState; } /** From 0e99920e18041c3d717ef85e586cbc312f58015c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 12 Aug 2026 15:04:08 +0900 Subject: [PATCH 04/10] Avoid dereferencing relay senders Use the activity actor URI directly when excluding the originating server. This avoids an unnecessary remote actor fetch and preserves the exclusion when the actor document is unavailable. https://github.com/fedify-dev/fedify/pull/984#discussion_r3733707201 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/base.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/base.ts b/packages/relay/src/base.ts index 8161b0ddc..15ec97a91 100644 --- a/packages/relay/src/base.ts +++ b/packages/relay/src/base.ts @@ -204,8 +204,8 @@ export abstract class BaseRelay implements Relay { ctx: InboxContext, activity: RelayableActivity, ): Promise { - const sender = await activity.getActor(ctx); - const excludeBaseUris = sender?.id == null ? [] : [new URL(sender.id)]; + const senderId = activity.actorId; + const excludeBaseUris = senderId == null ? [] : [senderId]; await this.deliverActivity(ctx, activity, excludeBaseUris); } From 7126f77f0209ef7b0a47a39720fcf42dff721c21 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 12 Aug 2026 15:09:55 +0900 Subject: [PATCH 05/10] Use relay context for Accept actors Resolve LitePub Accept senders through the inbox context so configured document loaders are used for URI-only actors. https://github.com/fedify-dev/fedify/pull/984#discussion_r3733707228 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/litepub.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 653d7c08b..194c74e73 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -93,7 +93,7 @@ export class LitePubRelay extends BaseRelay { if (relayActorId == null) return; // Validate follower actor - accept activity sender - const followerActor = await accept.getActor(); + const followerActor = await accept.getActor(ctx); if (!isActor(followerActor) || !followerActor.id) return; const parsed = ctx.parseUri(relayActorId); if (parsed == null || parsed.type !== "actor") return; From b4fac19c0487164f7bda84236e081d27e8a0eea1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 12 Aug 2026 15:24:31 +0900 Subject: [PATCH 06/10] Validate accepted follower records Validate stored LitePub follower data before changing its state so malformed records are not rewritten as accepted followers. Keep the accepted update checked against the internal storage type. https://github.com/fedify-dev/fedify/pull/984#discussion_r3733707237 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/litepub.test.ts | 51 +++++++++++++++++++++++++++++- packages/relay/src/litepub.ts | 8 +++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index a8955e1e9..dd37e9da5 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -18,7 +18,7 @@ import { getDocumentLoader, type RemoteDocument, } from "@fedify/vocab-runtime"; -import { ok, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, strictEqual } from "node:assert"; import test, { describe } from "node:test"; import { isRelayFollowerData } from "./types.ts"; @@ -583,6 +583,55 @@ describe("LitePubRelay", () => { strictEqual(followerData.state, "accepted"); }); + test("ignores Accept activity for invalid follower data", async () => { + const kv = new MemoryKvStore(); + const followerId = "https://remote.example.com/users/alice"; + const invalidFollowerData = { state: "pending" }; + + await kv.set(["follower", followerId], invalidFollowerData); + + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); + + const relayFollow = new Follow({ + id: new URL("https://relay.example.com/activities/follow/1"), + actor: new URL("https://relay.example.com/users/relay"), + object: new URL(followerId), + }); + const acceptActivity = new Accept({ + id: new URL("https://remote.example.com/activities/accept/1"), + actor: new URL(followerId), + object: relayFollow, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + deepStrictEqual( + await kv.get(["follower", followerId]), + invalidFollowerData, + ); + }); + for (const state of ["pending", "accepted"] as const) { test(`handles Undo Follow activity for ${state} follower`, async () => { const kv = new MemoryKvStore(); diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 194c74e73..b91f2b982 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -10,6 +10,7 @@ import { import { getLogger } from "@logtape/logtape"; import { BaseRelay, type RelayableActivity } from "./base.ts"; import { + isRelayFollowerData, RELAY_SERVER_ACTOR, type RelayFollowerData, type RelayOptions, @@ -103,10 +104,13 @@ export class LitePubRelay extends BaseRelay { "follower", followerActor.id.href, ]); - if (followerData == null) return; + if (!isRelayFollowerData(followerData)) return; // Update follower state to accepted - const updatedFollowerData = { ...followerData, state: "accepted" }; + const updatedFollowerData: RelayFollowerData = { + ...followerData, + state: "accepted", + }; await ctx.data.kv.set( ["follower", followerActor.id.href], updatedFollowerData, From a80825c00b5426ba5dd6c7da421ccc2de92d25eb Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 12 Aug 2026 15:39:58 +0900 Subject: [PATCH 07/10] Keep repeated LitePub Follows idempotent Skip LitePub Follow processing whenever a follower record already exists so accepted followers cannot regress to pending or restart the reciprocal Follow handshake. https://github.com/fedify-dev/fedify/pull/984#discussion_r3733707207 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/litepub.test.ts | 100 +++++++++++++++-------------- packages/relay/src/litepub.ts | 4 +- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index dd37e9da5..0aed8cbdf 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -466,60 +466,64 @@ describe("LitePubRelay", () => { strictEqual(followerData, undefined); }); - test("ignores duplicate Follow activity from pending follower", async () => { - const kv = new MemoryKvStore(); - let handlerCallCount = 0; - - const relay = createRelay("litepub", { - kv, - origin: "https://relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - subscriptionHandler: async (_ctx, _actor) => { - handlerCallCount++; - return await Promise.resolve(true); - }, - }); - - const follower = new Person({ - id: new URL("https://remote.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), - }); + for (const state of ["pending", "accepted"] as const) { + test(`ignores duplicate Follow activity from ${state} follower`, async () => { + const kv = new MemoryKvStore(); + let handlerCallCount = 0; - // Pre-populate with pending follower - await kv.set( - ["follower", "https://remote.example.com/users/alice"], - { actor: await follower.toJsonLd(), state: "pending" }, - ); + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + handlerCallCount++; + return await Promise.resolve(true); + }, + }); - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), - actor: follower.id, - object: new URL("https://relay.example.com/users/relay"), - }); + const follower = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + await kv.set( + ["follower", "https://remote.example.com/users/alice"], + { actor: await follower.toJsonLd(), state }, + ); - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://relay.example.com/users/relay"), + }); - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id, - ); + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); - await relay.fetch(request); + await relay.fetch(request); - // Verify handler was NOT called (duplicate follow ignored) - strictEqual(handlerCallCount, 0); - }); + strictEqual(handlerCallCount, 0); + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(isRelayFollowerData(followerData)); + strictEqual(followerData.state, state); + }); + } test("handles Accept activity completing reciprocal follow", async () => { const kv = new MemoryKvStore(); diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index b91f2b982..7cf457a75 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -34,11 +34,11 @@ export class LitePubRelay extends BaseRelay { follower: Actor, ): Promise { if (follower.id == null) return true; - const existingFollow = await ctx.data.kv.get([ + const existingFollow = await ctx.data.kv.get([ "follower", follower.id.href, ]); - return existingFollow?.state === "pending"; + return existingFollow != null; } protected override async afterFollowApproved( From 86a5d03875a4b85e068d34d929957f6c42d1c46c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 12 Aug 2026 16:15:48 +0900 Subject: [PATCH 08/10] Verify Mastodon Announce delivery Give the forwarding test an accepted follower and inspect its outbound inbox request. Sign the fixture so Mastodon's unsigned-activity guard permits forwarding, then verify that the original payload is preserved exactly. https://github.com/fedify-dev/fedify/pull/984#discussion_r3733707244 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/mastodon.test.ts | 59 +++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 3c1d27b0a..df36d631a 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -1,5 +1,5 @@ // deno-lint-ignore-file no-explicit-any -import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { MemoryKvStore, signJsonLd, signRequest } from "@fedify/fedify"; import { createRelay, type RelayOptions } from "@fedify/relay"; import { Announce, @@ -17,7 +17,7 @@ import { getDocumentLoader, type RemoteDocument, } from "@fedify/vocab-runtime"; -import { ok, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, strictEqual } from "node:assert"; import test, { describe } from "node:test"; import { isRelayFollowerData } from "./types.ts"; @@ -692,6 +692,15 @@ describe("MastodonRelay", () => { test("handles Announce activity forwarding", async () => { const kv = new MemoryKvStore(); + const follower = new Person({ + id: new URL("https://follower.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://follower.example.com/users/bob/inbox"), + }); + await kv.set( + ["follower", follower.id!.href], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); const relay = createRelay("mastodon", { kv, @@ -706,15 +715,19 @@ describe("MastodonRelay", () => { actor: new URL("https://remote.example.com/users/alice"), object: new URL("https://remote.example.com/notes/1"), }); + const signedAnnounce = await signJsonLd( + await announceActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + rsaKeyPair.privateKey, + rsaPublicKey.id, + { contextLoader: mockDocumentLoader }, + ); let request = new Request("https://relay.example.com/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json", }, - body: JSON.stringify( - await announceActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), + body: JSON.stringify(signedAnnounce), }); request = await signRequest( @@ -723,10 +736,40 @@ describe("MastodonRelay", () => { rsaPublicKey.id, ); - const response = await relay.fetch(request); + const originalFetch = globalThis.fetch; + let deliveryMethod: string | undefined; + let deliveredActivity: unknown; + globalThis.fetch = (async ( + input: URL | RequestInfo, + init?: RequestInit, + ) => { + const outboundRequest = input instanceof Request + ? input + : new Request(input, init); + if ( + outboundRequest.url === + "https://follower.example.com/users/bob/inbox" + ) { + deliveryMethod = outboundRequest.method; + deliveredActivity = await outboundRequest.json(); + return new Response(null, { status: 202 }); + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + const response = await relay.fetch(request); + ok( + response.status === 200 || response.status === 202, + `Unexpected inbox response status: ${response.status}`, + ); + } finally { + globalThis.fetch = originalFetch; + } - // Verify the request was accepted - ok(response.status === 200 || response.status === 202); + ok(deliveredActivity, "Expected Announce delivery to the follower inbox"); + strictEqual(deliveryMethod, "POST"); + deepStrictEqual(deliveredActivity, signedAnnounce); }); test("ignores Follow activity without required fields", async () => { From abb0f0c8982a5aa8f5f5bf2b136114d13c49308f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 13 Aug 2026 14:15:37 +0900 Subject: [PATCH 09/10] Repair malformed LitePub follower rows Only treat validated follower data as an existing subscription. This lets a later valid Follow replace malformed storage instead of leaving the relay subscription permanently blocked. https://github.com/fedify-dev/fedify/pull/984#discussion_r3768416255 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/litepub.test.ts | 44 ++++++++++++++++++++++++++++++ packages/relay/src/litepub.ts | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 0aed8cbdf..14182569f 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -466,6 +466,50 @@ describe("LitePubRelay", () => { strictEqual(followerData, undefined); }); + test("replaces malformed follower data on Follow", async () => { + const kv = new MemoryKvStore(); + const followerId = "https://remote.example.com/users/alice"; + await kv.set(["follower", followerId], { state: "pending" }); + let handlerCallCount = 0; + + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => { + handlerCallCount++; + return Promise.resolve(true); + }, + }); + + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: new URL(followerId), + object: new URL("https://relay.example.com/users/relay"), + }); + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { "Content-Type": "application/activity+json" }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + strictEqual(handlerCallCount, 1); + const follower = await relay.getFollower(followerId); + ok(follower); + strictEqual(follower.state, "pending"); + strictEqual(follower.actor.id?.href, followerId); + }); + for (const state of ["pending", "accepted"] as const) { test(`ignores duplicate Follow activity from ${state} follower`, async () => { const kv = new MemoryKvStore(); diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 7cf457a75..c66a5ddad 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -38,7 +38,7 @@ export class LitePubRelay extends BaseRelay { "follower", follower.id.href, ]); - return existingFollow != null; + return isRelayFollowerData(existingFollow); } protected override async afterFollowApproved( From 615b7e825dd952bb2fc341cccd6fa01ea64a2c27 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 13 Aug 2026 14:48:49 +0900 Subject: [PATCH 10/10] Validate stored relay follower actors Parse stored follower actors before treating their rows as valid. Reject invalid JSON-LD, non-actor objects, and actors whose IDs do not match their KV keys so valid Follow activities can repair malformed storage safely. https://github.com/fedify-dev/fedify/pull/984#discussion_r3772622538 Changelog: none Assisted-by: Codex:gpt-5.6-sol --- packages/relay/src/base.ts | 33 +----- packages/relay/src/builder.ts | 16 +-- packages/relay/src/litepub.test.ts | 170 ++++++++++++++++------------- packages/relay/src/litepub.ts | 12 +- packages/relay/src/types.ts | 25 ++++- 5 files changed, 141 insertions(+), 115 deletions(-) diff --git a/packages/relay/src/base.ts b/packages/relay/src/base.ts index 15ec97a91..e70228cb4 100644 --- a/packages/relay/src/base.ts +++ b/packages/relay/src/base.ts @@ -11,9 +11,7 @@ import { Create, Delete, Follow, - isActor, Move, - Object as APObject, Undo, Update, } from "@fedify/vocab"; @@ -24,7 +22,7 @@ import { validateFollowActivity, } from "./follow.ts"; import { - isRelayFollowerData, + parseRelayFollowerData, type Relay, RELAY_SERVER_ACTOR, type RelayFollower, @@ -63,31 +61,6 @@ export abstract class BaseRelay implements Relay { }); } - /** - * Helper method to parse and validate follower data from storage. - * Deserializes JSON-LD actor data and validates it. - * - * @param actorId The actor ID of the follower - * @param data Raw data from KV store - * @returns RelayFollower object if valid, null otherwise - * @internal - */ - private async parseFollowerData( - actorId: string, - data: unknown, - ): Promise { - if (!isRelayFollowerData(data)) return null; - - const actor = await APObject.fromJsonLd(data.actor); - if (!isActor(actor)) return null; - - return { - actorId, - actor, - state: data.state, - }; - } - /** * Lists all followers of the relay. * @@ -118,7 +91,7 @@ export abstract class BaseRelay implements Relay { const actorId = entry.key[1]; if (typeof actorId !== "string") continue; - const follower = await this.parseFollowerData(actorId, entry.value); + const follower = await parseRelayFollowerData(actorId, entry.value); if (follower) yield follower; } } @@ -153,7 +126,7 @@ export abstract class BaseRelay implements Relay { */ async getFollower(actorId: string): Promise { const followerData = await this.options.kv.get(["follower", actorId]); - return await this.parseFollowerData(actorId, followerData); + return await parseRelayFollowerData(actorId, followerData); } protected shouldSkipFollow( diff --git a/packages/relay/src/builder.ts b/packages/relay/src/builder.ts index 7bd40aa3f..deca78026 100644 --- a/packages/relay/src/builder.ts +++ b/packages/relay/src/builder.ts @@ -7,9 +7,9 @@ import { importJwk, } from "@fedify/fedify"; import type { Actor } from "@fedify/vocab"; -import { Application, isActor, Object } from "@fedify/vocab"; +import { Application } from "@fedify/vocab"; import { - isRelayFollowerData, + parseRelayFollowerData, RELAY_SERVER_ACTOR, type RelayOptions, } from "./types.ts"; @@ -78,12 +78,12 @@ async function getFollowerActors( ): Promise { const actors: Actor[] = []; - for await (const { value } of ctx.data.kv.list(["follower"])) { - if (!isRelayFollowerData(value)) continue; - if (value.state !== "accepted") continue; - const actor = await Object.fromJsonLd(value.actor); - if (!isActor(actor)) continue; - actors.push(actor); + for await (const { key, value } of ctx.data.kv.list(["follower"])) { + const actorId = key[1]; + if (typeof actorId !== "string") continue; + const follower = await parseRelayFollowerData(actorId, value); + if (follower?.state !== "accepted") continue; + actors.push(follower.actor); } return actors; diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 14182569f..bc6a92cb5 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -467,47 +467,60 @@ describe("LitePubRelay", () => { }); test("replaces malformed follower data on Follow", async () => { - const kv = new MemoryKvStore(); const followerId = "https://remote.example.com/users/alice"; - await kv.set(["follower", followerId], { state: "pending" }); - let handlerCallCount = 0; - - const relay = createRelay("litepub", { - kv, - origin: "https://relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - subscriptionHandler: () => { - handlerCallCount++; - return Promise.resolve(true); - }, + const mismatchedActor = new Person({ + id: new URL("https://remote.example.com/users/bob"), }); + const malformedRows = [ + { state: "pending" }, + { actor: null, state: "pending" }, + { actor: {}, state: "pending" }, + { actor: await mismatchedActor.toJsonLd(), state: "pending" }, + ]; - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), - actor: new URL(followerId), - object: new URL("https://relay.example.com/users/relay"), - }); - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { "Content-Type": "application/activity+json" }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id, - ); + for (const malformedRow of malformedRows) { + const kv = new MemoryKvStore(); + await kv.set(["follower", followerId], malformedRow); + let handlerCallCount = 0; - await relay.fetch(request); + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => { + handlerCallCount++; + return Promise.resolve(true); + }, + }); + strictEqual(await relay.getFollower(followerId), null); - strictEqual(handlerCallCount, 1); - const follower = await relay.getFollower(followerId); - ok(follower); - strictEqual(follower.state, "pending"); - strictEqual(follower.actor.id?.href, followerId); + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: new URL(followerId), + object: new URL("https://relay.example.com/users/relay"), + }); + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { "Content-Type": "application/activity+json" }, + body: JSON.stringify( + await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + await relay.fetch(request); + + strictEqual(handlerCallCount, 1); + const follower = await relay.getFollower(followerId); + ok(follower); + strictEqual(follower.state, "pending"); + strictEqual(follower.actor.id?.href, followerId); + } }); for (const state of ["pending", "accepted"] as const) { @@ -632,52 +645,59 @@ describe("LitePubRelay", () => { }); test("ignores Accept activity for invalid follower data", async () => { - const kv = new MemoryKvStore(); const followerId = "https://remote.example.com/users/alice"; - const invalidFollowerData = { state: "pending" }; + const mismatchedActor = new Person({ + id: new URL("https://remote.example.com/users/bob"), + }); + const invalidRows = [ + { state: "pending" }, + { actor: null, state: "pending" }, + { actor: {}, state: "pending" }, + { actor: await mismatchedActor.toJsonLd(), state: "pending" }, + ]; - await kv.set(["follower", followerId], invalidFollowerData); + for (const invalidRow of invalidRows) { + const kv = new MemoryKvStore(); + await kv.set(["follower", followerId], invalidRow); - const relay = createRelay("litepub", { - kv, - origin: "https://relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - subscriptionHandler: () => Promise.resolve(true), - }); + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); - const relayFollow = new Follow({ - id: new URL("https://relay.example.com/activities/follow/1"), - actor: new URL("https://relay.example.com/users/relay"), - object: new URL(followerId), - }); - const acceptActivity = new Accept({ - id: new URL("https://remote.example.com/activities/accept/1"), - actor: new URL(followerId), - object: relayFollow, - }); + const relayFollow = new Follow({ + id: new URL("https://relay.example.com/activities/follow/1"), + actor: new URL("https://relay.example.com/users/relay"), + object: new URL(followerId), + }); + const acceptActivity = new Accept({ + id: new URL("https://remote.example.com/activities/accept/1"), + actor: new URL(followerId), + object: relayFollow, + }); - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - }, - body: JSON.stringify( - await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id, - ); + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); - await relay.fetch(request); + await relay.fetch(request); - deepStrictEqual( - await kv.get(["follower", followerId]), - invalidFollowerData, - ); + deepStrictEqual(await kv.get(["follower", followerId]), invalidRow); + } }); for (const state of ["pending", "accepted"] as const) { diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index c66a5ddad..52b2bde79 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -11,6 +11,7 @@ import { getLogger } from "@logtape/logtape"; import { BaseRelay, type RelayableActivity } from "./base.ts"; import { isRelayFollowerData, + parseRelayFollowerData, RELAY_SERVER_ACTOR, type RelayFollowerData, type RelayOptions, @@ -38,7 +39,11 @@ export class LitePubRelay extends BaseRelay { "follower", follower.id.href, ]); - return isRelayFollowerData(existingFollow); + const storedFollower = await parseRelayFollowerData( + follower.id.href, + existingFollow, + ); + return storedFollower != null; } protected override async afterFollowApproved( @@ -105,6 +110,11 @@ export class LitePubRelay extends BaseRelay { followerActor.id.href, ]); if (!isRelayFollowerData(followerData)) return; + const storedFollower = await parseRelayFollowerData( + followerActor.id.href, + followerData, + ); + if (storedFollower == null) return; // Update follower state to accepted const updatedFollowerData: RelayFollowerData = { diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index da1671dcb..a2dd63468 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -1,5 +1,5 @@ import type { Context, KvStore, MessageQueue } from "@fedify/fedify"; -import type { Actor } from "@fedify/vocab"; +import { type Actor, isActor, Object as APObject } from "@fedify/vocab"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, @@ -169,3 +169,26 @@ export function isRelayFollowerData( (obj.state === "pending" || obj.state === "accepted") ); } + +/** + * Parses and semantically validates follower data from storage. + * + * @param actorId The actor ID used as the follower's storage key. + * @param value The stored follower data. + * @returns The parsed follower, or `null` if the row is invalid. + * @internal + */ +export async function parseRelayFollowerData( + actorId: string, + value: unknown, +): Promise { + if (!isRelayFollowerData(value)) return null; + + try { + const actor = await APObject.fromJsonLd(value.actor); + if (!isActor(actor) || actor.id?.href !== actorId) return null; + return { actorId, actor, state: value.state }; + } catch { + return null; + } +}