Skip to content
Open
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
130 changes: 123 additions & 7 deletions packages/relay/src/base.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,6 +46,9 @@ export abstract class BaseRelay implements Relay {
protected options: RelayOptions;
protected federation?: Federation<RelayOptions>;

protected abstract readonly initialFollowerState: RelayFollowerState;
protected abstract readonly logger: Logger;

constructor(
options: RelayOptions,
relayBuilder: FederationBuilder<RelayOptions>,
Expand Down Expand Up @@ -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<RelayOptions>,
_follower: Actor,
): Promise<boolean> {
return Promise.resolve(false);
}

protected afterFollowApproved(
_ctx: InboxContext<RelayOptions>,
_follower: Actor,
): Promise<void> {
return Promise.resolve();
}

protected abstract deliverActivity(
ctx: InboxContext<RelayOptions>,
activity: RelayableActivity,
excludeBaseUris: URL[],
): Promise<void>;

async #handleFollow(
ctx: InboxContext<RelayOptions>,
follow: Follow,
): Promise<void> {
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<RelayOptions>,
activity: RelayableActivity,
): Promise<void> {
const sender = await activity.getActor(ctx);
const excludeBaseUris = sender?.id == null ? [] : [new URL(sender.id)];
await this.deliverActivity(ctx, activity, excludeBaseUris);
}
Comment on lines +203 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm `actorId` is a supported accessor on Activity and check existing usage patterns for excludeBaseUris.
set -euo pipefail

# Find the actorId accessor definition on the Activity class.
fd -t f 'vocab.ts$' packages/vocab/src | head -n 5
rg -nP -C4 '\bget\s+actorId\b' packages/vocab/src | head -n 40

# Compare with other excludeBaseUris call sites in the repo.
rg -nP -C6 '\bexcludeBaseUris\b' --type=ts -g '!**/*.test.ts' | head -n 80

Repository: fedify-dev/fedify

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -u

echo "Repository top-level files:"
git ls-files | sed -n '1,80p'

echo
echo "Find base.ts and relevant packages:"
fd -t f 'base\.ts$' . | sed -n '1,80p'

echo
echo "Search excludeBaseUris:"
rg -nP -C5 '\bexcludeBaseUris\b' . --glob '*.ts' || true

echo
echo "Search actorId and getActor:"
rg -nP -C3 '\b(actorId|getActor)\b' . --glob '*.ts' | sed -n '1,220p'

Repository: fedify-dev/fedify

Length of output: 29175


🏁 Script executed:

#!/bin/bash
set -u

echo "packages/relay/src/base.ts outline:"
ast-grep outline packages/relay/src/base.ts || true
echo

echo "packages/relay/src/base.ts relevant lines:"
sed -n '1,250p' packages/relay/src/base.ts | nl -ba

echo
echo "Find Activity/getActor type declarations:"
rg -nP -C3 '\bclass\s+Activity\b|\bgetActor\b|getActor\(.*ctx' packages -g '*.ts' | sed -n '1,220p'

Repository: fedify-dev/fedify

Length of output: 15965


🏁 Script executed:

#!/bin/bash
set -u

echo "Find vocab getActor implementation:"
rg -nP -C12 '\bgetActor\b' packages/vocab packages -g '*.ts' | sed -n '1,240p'

echo
echo "Inspect packages/vocab/src files containing method patterns:"
fd -t f '\.ts$' packages/vocab/src | xargs -r rg -n "actorId|getActor|getObject" | sed -n '1,240p'

Repository: fedify-dev/fedify

Length of output: 23500


🏁 Script executed:

#!/bin/bash
set -u

echo "Find files with getActor implementation inside vocab source:"
rg -l '\bclass\s+Activity\b|async\s+getActor\b|getActor\(' packages/vocab/src packages -g '*.ts' | sed -n '1,80p'

echo
echo "Search Activity.getActor implementation in all tracked TS files with line numbers:"
rg -nP -C20 '(^|[^A-Za-z0-9_])getActor[:(]|async\s+getActor\b' packages -g '*.ts' | sed -n '1,280p'

echo
echo "Find declaration files mentioning ActorId/actorId for Activity:"
rg -nP -C8 'actorId\?:|actorId\(|`@property`\(["'\'']actor["'\'']\)|actorId' packages/vocab packages/fedify -g '*.ts' | sed -n '1,220p'

Repository: fedify-dev/fedify

Length of output: 34712


🌐 Web query:

Fedify Activity actorId getter getActor context document loader implementation

💡 Result:

In Fedify, the retrieval of an actor from an Activity object or a context is handled via dedicated methods that support document loaders for secure, authenticated, or custom fetching [1][2][3]. Actor Identification and Retrieval 1. Activity.actorId: This is a property of an Activity object that returns the URI (URL) of the actor [4][5]. It does not perform network requests and is used for quick identification [5]. 2. Activity.getActor: This is a dereferencing accessor method [1]. When called, it fetches the remote actor object from the URI if it is not already cached, effectively hydrating the object [5][6]. It accepts an options object to specify a document loader and a context loader, allowing for authenticated or customized network requests [4][1]. Contextual Document Loading The Context object in Fedify manages the document loader, which is essential for fetching remote JSON-LD documents [3][7]. - Default Loader: The Context.documentLoader property holds the default, unauthenticated document loader configured for your federation instance [2][3]. - Authenticated Loader: When you need to access protected resources (such as followers-only content), you should use the Context.getDocumentLoader method [2][3]. This creates an authenticated document loader, which Fedify then uses to sign requests with the appropriate HTTP Signature [2]. - Implementation Pattern: When using methods like getActor, lookupObject, or various collection accessors, you can pass the document loader via the options object [2][3]: const loader = await ctx.getDocumentLoader({ identifier: "alice" }); const actor = await activity.getActor({ documentLoader: loader }); Document Loader vs. Context Loader - Document Loader: Primarily used for fetching remote resources such as actor profiles or object documents [3][7]. - Context Loader: Used for fetching JSON-LD contexts (e.g., ActivityStreams or W3C security contexts) [3][7]. It is typically cached heavily and generally does not require authentication [7]. When using Fedify's vocabulary API, you can often pass the Context object directly as an option, as it is compatible with the expected { documentLoader, contextLoader } structure [3][7].

Citations:


🌐 Web query:

Fedify Activity.getActor implementation "Activity.prototype.getActor"

💡 Result:

In Fedify, there is a distinction between Activity.getActor (a vocabulary/data-level method) and RequestContext.getActor (a framework/routing-level method). Activity.getActor is a method on instances of the Activity class within the @fedify/vocab package [1][2]. It is a dereferencing accessor that fetches and hydrates the actor object associated with an activity [3][4]. When called, it resolves the actor's URI—fetching the remote object over the network if it is not already available—and returns a typed actor object (e.g., Person, Group, or Service) or null [5][4]. For performance, if you only need the actor's URI without fetching the remote resource, you can use the Activity.actorId property [3][4]. In contrast, RequestContext.getActor is a method provided by the Fedify framework's RequestContext [6]. This method is used within the server's request handling logic to dispatch an actor based on an identifier registered via the federation's actor dispatcher [6][7]. It allows the application to retrieve a local actor object from your database or application logic, rather than fetching remote data [6][7]. Key differences: - Activity.getActor: Part of the vocabulary data model; used for dereferencing/fetching remote actor data from an ActivityStreams object [3][4]. - RequestContext.getActor: Part of the server framework; used to look up local actors based on route parameters or identifiers using your defined dispatcher [6][7].

Citations:


Use activity.actorId instead of fetching the sender actor.

activity.getActor(ctx) dereferences the actor URI through Fedify’s document loader, which adds a request per relayed activity. This relay path only needs the activity sender URI to build excludeBaseUris, and ctx.sendActivity matches excludeBaseUris by origin.

If the actor document is unavailable or getActor(ctx) returns null, the relay can re-forward the activity to the originating server. Use activity.actorId for the exclusion origin, preserving the optional null case with an empty exclusion list.

♻️ Proposed refactor
   async `#relayActivity`(
     ctx: InboxContext<RelayOptions>,
     activity: RelayableActivity,
   ): Promise<void> {
-    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);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/relay/src/base.ts` around lines 203 - 210, Update `#relayActivity` to
use activity.actorId directly when constructing excludeBaseUris instead of
awaiting activity.getActor(ctx). Preserve the null/undefined case as an empty
exclusion list, and continue passing the resulting origins to deliverActivity.


protected setupInboxListeners(): InboxListenerSetters<RelayOptions> {
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<Federation<RelayOptions>> {
if (this.federation == null) {
Expand Down
98 changes: 49 additions & 49 deletions packages/relay/src/litepub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading