diff --git a/packages/relay/README.md b/packages/relay/README.md index d75cbd05..bbf7ce26 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -157,6 +157,35 @@ const relay = createRelay("mastodon", { }); ~~~~ +### Managing followers + +The relay provides methods to query and manage followers without exposing +internal storage details. + +#### Listing all followers + +~~~~ typescript +for await (const follower of relay.listFollowers()) { + console.log(`Follower: ${follower.actorId}`); + console.log(`State: ${follower.state}`); + console.log(`Actor name: ${follower.actor.name}`); + console.log(`Actor type: ${follower.actor.constructor.name}`); +} +~~~~ + +#### Getting a specific follower + +~~~~ typescript +const follower = await relay.getFollower("https://mastodon.example.com/users/alice"); +if (follower) { + console.log(`Found follower in state: ${follower.state}`); + console.log(`Actor username: ${follower.actor.preferredUsername}`); + console.log(`Inbox: ${follower.actor.inboxId?.href}`); +} else { + console.log("Follower not found"); +} +~~~~ + ### Integration with web frameworks The relay's `fetch()` method returns a standard `Response` object, making it @@ -234,7 +263,7 @@ Factory function to create a relay instance. function createRelay( type: "mastodon" | "litepub", options: RelayOptions -): BaseRelay +): Relay ~~~~ **Parameters:** @@ -242,31 +271,28 @@ function createRelay( - `type`: The type of relay to create (`"mastodon"` or `"litepub"`) - `options`: Configuration options for the relay -**Returns:** A relay instance (`MastodonRelay` or `LitePubRelay`) +**Returns:** A `Relay` instance -### `BaseRelay` +### `Relay` -Abstract base class for relay implementations. +Public interface for ActivityPub relay implementations. #### Methods - `fetch(request: Request): Promise`: Handle incoming HTTP requests + - `listFollowers(): AsyncIterableIterator`: Lists all + followers of the relay + - `getFollower(actorId: string): Promise`: Gets + a specific follower by actor ID -### `MastodonRelay` - -A Mastodon-compatible ActivityPub relay implementation that extends `BaseRelay`. +#### Relay types - - Uses direct activity forwarding - - Immediate subscription approval - - Compatible with standard ActivityPub implementations +The relay type is specified when calling `createRelay()`: -### `LitePubRelay` - -A LitePub-compatible ActivityPub relay implementation that extends `BaseRelay`. - - - Uses bidirectional following - - Activities wrapped in `Announce` - - Two-phase subscription (pending → accepted) + - `"mastodon"`: Mastodon-compatible relay using direct activity forwarding, + immediate subscription approval, and LD signatures + - `"litepub"`: LitePub-compatible relay using bidirectional following, + activities wrapped in `Announce`, and two-phase subscription ### `RelayOptions` @@ -304,6 +330,24 @@ type SubscriptionRequestHandler = ( - `true` to approve the subscription - `false` to reject the subscription +### `RelayFollower` + +A follower of the relay with validated Actor instance: + +~~~~ typescript +interface RelayFollower { + readonly actorId: string; + readonly actor: Actor; + readonly state: "pending" | "accepted"; +} +~~~~ + +**Properties:** + + - `actorId`: The actor ID (URL) of the follower + - `actor`: The validated Actor object + - `state`: The follower's state (`"pending"` or `"accepted"`) + [JSR]: https://jsr.io/@fedify/relay [JSR badge]: https://jsr.io/badges/@fedify/relay diff --git a/packages/relay/src/base.ts b/packages/relay/src/base.ts index 81cb0841..5013c608 100644 --- a/packages/relay/src/base.ts +++ b/packages/relay/src/base.ts @@ -1,13 +1,19 @@ import type { Federation, FederationBuilder } from "@fedify/fedify"; -import type { RelayOptions } from "./types.ts"; +import { isActor, Object as APObject } from "@fedify/fedify/vocab"; +import { + isRelayFollowerData, + type Relay, + type RelayFollower, + type RelayOptions, +} from "./types.ts"; /** * Abstract base class for relay implementations. * Provides common infrastructure for both Mastodon and LitePub relays. * - * @since 2.0.0 + * @internal */ -export abstract class BaseRelay { +export abstract class BaseRelay implements Relay { protected federationBuilder: FederationBuilder; protected options: RelayOptions; protected federation?: Federation; @@ -31,6 +37,99 @@ export abstract class BaseRelay { }); } + /** + * 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. + * + * @returns An async iterator of follower entries + * + * @example + * ```ts + * import { createRelay } from "@fedify/relay"; + * import { MemoryKvStore } from "@fedify/fedify"; + * + * const relay = createRelay("mastodon", { + * kv: new MemoryKvStore(), + * domain: "relay.example.com", + * subscriptionHandler: async (ctx, actor) => true, + * }); + * + * for await (const follower of relay.listFollowers()) { + * console.log(`Follower: ${follower.actorId}`); + * console.log(`State: ${follower.state}`); + * console.log(`Actor: ${follower.actor.name}`); + * } + * ``` + * + * @since 2.0.0 + */ + async *listFollowers(): AsyncIterableIterator { + for await (const entry of this.options.kv.list(["follower"])) { + const actorId = entry.key[1]; + if (typeof actorId !== "string") continue; + + const follower = await this.parseFollowerData(actorId, entry.value); + if (follower) yield follower; + } + } + + /** + * Gets a specific follower by actor ID. + * + * @param actorId The actor ID (URL) of the follower to retrieve + * @returns The follower entry if found, null otherwise + * + * @example + * ```ts + * import { createRelay } from "@fedify/relay"; + * import { MemoryKvStore } from "@fedify/fedify"; + * + * const relay = createRelay("mastodon", { + * kv: new MemoryKvStore(), + * domain: "relay.example.com", + * subscriptionHandler: async (ctx, actor) => true, + * }); + * + * const follower = await relay.getFollower( + * "https://mastodon.example.com/users/alice" + * ); + * if (follower) { + * console.log(`State: ${follower.state}`); + * console.log(`Actor: ${follower.actor.preferredUsername}`); + * } + * ``` + * + * @since 2.0.0 + */ + async getFollower(actorId: string): Promise { + const followerData = await this.options.kv.get(["follower", actorId]); + return await this.parseFollowerData(actorId, followerData); + } + /** * Set up inbox listeners for handling ActivityPub activities. * Each relay type implements this method with protocol-specific logic. diff --git a/packages/relay/src/builder.ts b/packages/relay/src/builder.ts index 0731db06..15ea7688 100644 --- a/packages/relay/src/builder.ts +++ b/packages/relay/src/builder.ts @@ -9,7 +9,7 @@ import { import { Application, isActor, Object } from "@fedify/fedify/vocab"; import type { Actor } from "@fedify/fedify/vocab"; import { - isRelayFollower, + isRelayFollowerData, RELAY_SERVER_ACTOR, type RelayOptions, } from "./types.ts"; @@ -79,7 +79,7 @@ async function getFollowerActors( const actors: Actor[] = []; for await (const { value } of ctx.data.kv.list(["follower"])) { - if (!isRelayFollower(value)) continue; + if (!isRelayFollowerData(value)) continue; if (value.state !== "accepted") continue; const actor = await Object.fromJsonLd(value.actor); if (!isActor(actor)) continue; diff --git a/packages/relay/src/factory.ts b/packages/relay/src/factory.ts index f644346f..5e3b8832 100644 --- a/packages/relay/src/factory.ts +++ b/packages/relay/src/factory.ts @@ -1,8 +1,7 @@ -import type { BaseRelay } from "./base.ts"; import { relayBuilder } from "./builder.ts"; import { LitePubRelay } from "./litepub.ts"; import { MastodonRelay } from "./mastodon.ts"; -import type { RelayOptions, RelayType } from "./types.ts"; +import type { Relay, RelayOptions, RelayType } from "./types.ts"; /** * Factory function to create a relay instance. @@ -28,7 +27,7 @@ import type { RelayOptions, RelayType } from "./types.ts"; export function createRelay( type: RelayType, options: RelayOptions, -): BaseRelay { +): Relay { switch (type) { case "mastodon": return new MastodonRelay(options, relayBuilder); diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 59ba8033..af7c96c2 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -19,7 +19,8 @@ import { } from "@fedify/vocab-runtime"; import { ok, strictEqual } from "node:assert"; import test, { describe } from "node:test"; -import { createRelay, isRelayFollower, type RelayOptions } from "@fedify/relay"; +import { createRelay, type RelayOptions } from "@fedify/relay"; +import { isRelayFollowerData } from "./types.ts"; // Simple mock document loader that returns a minimal context const mockDocumentLoader = async (url: string): Promise => { @@ -316,7 +317,7 @@ describe("LitePubRelay", () => { "follower", "https://remote.example.com/users/alice", ]); - ok(isRelayFollower(followerData)); + ok(isRelayFollowerData(followerData)); strictEqual(followerData.state, "pending"); }); @@ -418,7 +419,7 @@ describe("LitePubRelay", () => { "follower", "https://remote.example.com/users/alice", ]); - ok(isRelayFollower(followerData)); + ok(isRelayFollowerData(followerData)); strictEqual(followerData.state, "pending"); }); @@ -578,7 +579,7 @@ describe("LitePubRelay", () => { "follower", "https://remote.example.com/users/alice", ]); - ok(isRelayFollower(followerData)); + ok(isRelayFollowerData(followerData)); strictEqual(followerData.state, "accepted"); }); @@ -930,7 +931,7 @@ describe("LitePubRelay", () => { strictEqual(key.length, 2); strictEqual(key[0], "follower"); retrievedIds.push(key[1] as string); - ok(isRelayFollower(value)); + ok(isRelayFollowerData(value)); strictEqual(value.state, "accepted"); } @@ -1007,7 +1008,7 @@ describe("LitePubRelay", () => { // Verify list returns both with correct states const followers: { id: string; state: string }[] = []; for await (const { key, value } of kv.list(["follower"])) { - if (!isRelayFollower(value)) continue; + if (!isRelayFollowerData(value)) continue; followers.push({ id: key[1] as string, state: value.state, @@ -1044,7 +1045,7 @@ describe("LitePubRelay", () => { // Verify list returns complete actor data for await (const { key, value } of kv.list(["follower"])) { strictEqual(key[1], followerId); - ok(isRelayFollower(value)); + ok(isRelayFollowerData(value)); strictEqual(value.state, "accepted"); ok(value.actor && typeof value.actor === "object"); const actor = value.actor as Record; diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 8c512af0..0b0fff5b 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -20,7 +20,7 @@ import { } from "./follow.ts"; import { RELAY_SERVER_ACTOR, - type RelayFollower, + type RelayFollowerData, type RelayOptions, } from "./types.ts"; @@ -68,7 +68,7 @@ export class LitePubRelay extends BaseRelay { if (!follower || !follower.id) return; // Litepub-specific: check if already in pending state - const existingFollow = await ctx.data.kv.get([ + const existingFollow = await ctx.data.kv.get([ "follower", follower.id.href, ]); diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 7b0bd97c..19825a87 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -17,7 +17,8 @@ import { } from "@fedify/vocab-runtime"; import { ok, strictEqual } from "node:assert"; import test, { describe } from "node:test"; -import { createRelay, isRelayFollower, type RelayOptions } from "@fedify/relay"; +import { createRelay, type RelayOptions } from "@fedify/relay"; +import { isRelayFollowerData } from "./types.ts"; // Simple mock document loader that returns a minimal context const mockDocumentLoader = async (url: string): Promise => { @@ -819,7 +820,7 @@ describe("MastodonRelay", () => { strictEqual(key.length, 2); strictEqual(key[0], "follower"); retrievedIds.push(key[1] as string); - ok(isRelayFollower(value)); + ok(isRelayFollowerData(value)); strictEqual(value.state, "accepted"); } @@ -883,7 +884,7 @@ describe("MastodonRelay", () => { // Verify list returns complete actor data for await (const { key, value } of kv.list(["follower"])) { strictEqual(key[1], followerId); - ok(isRelayFollower(value)); + ok(isRelayFollowerData(value)); strictEqual(value.state, "accepted"); ok(value.actor && typeof value.actor === "object"); const actor = value.actor as Record; @@ -891,4 +892,112 @@ describe("MastodonRelay", () => { strictEqual(actor.name, "Alice Wonderland"); } }); + + test("listFollowers() returns all followers", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); + + // Add multiple followers + const follower1 = new Person({ + id: new URL("https://remote.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote.example.com/users/alice/inbox"), + }); + const follower2 = new Person({ + id: new URL("https://remote.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote.example.com/users/bob/inbox"), + }); + + await kv.set( + ["follower", "https://remote.example.com/users/alice"], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", "https://remote.example.com/users/bob"], + { actor: await follower2.toJsonLd(), state: "pending" }, + ); + + // Test listFollowers + const followers = []; + for await (const follower of relay.listFollowers()) { + followers.push(follower); + } + + strictEqual(followers.length, 2); + ok( + followers.some((f) => + f.actorId === "https://remote.example.com/users/alice" + ), + ); + ok( + followers.some((f) => + f.actorId === "https://remote.example.com/users/bob" + ), + ); + ok(followers.some((f) => f.state === "accepted")); + ok(followers.some((f) => f.state === "pending")); + + // Verify actors are properly typed + const alice = followers.find((f) => + f.actorId === "https://remote.example.com/users/alice" + ); + ok(alice); + strictEqual(alice.actor.preferredUsername, "alice"); + ok(alice.actor.inboxId); + + const bob = followers.find((f) => + f.actorId === "https://remote.example.com/users/bob" + ); + ok(bob); + strictEqual(bob.actor.preferredUsername, "bob"); + ok(bob.actor.inboxId); + }); + + test("getFollower() returns specific follower", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); + + // Add a follower + 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: "accepted" }, + ); + + // Test getFollower + const result = await relay.getFollower( + "https://remote.example.com/users/alice", + ); + ok(result); + strictEqual(result.actorId, "https://remote.example.com/users/alice"); + strictEqual(result.state, "accepted"); + ok(result.actor); + strictEqual(result.actor.preferredUsername, "alice"); + strictEqual( + result.actor.inboxId?.href, + "https://remote.example.com/users/alice/inbox", + ); + + // Test non-existent follower + const nonExistent = await relay.getFollower( + "https://remote.example.com/users/nonexistent", + ); + strictEqual(nonExistent, null); + }); }); diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index aa9b45df..abb96aca 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -7,12 +7,9 @@ * * @module */ -export { relayBuilder } from "./builder.ts"; export { createRelay } from "./factory.ts"; -export { LitePubRelay } from "./litepub.ts"; -export { MastodonRelay } from "./mastodon.ts"; export { - isRelayFollower, + type Relay, RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index 8608f9e9..8a68c296 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -33,19 +33,77 @@ export interface RelayOptions { subscriptionHandler: SubscriptionRequestHandler; } -export interface RelayFollower { +/** + * Internal storage format for follower data in KV store. + * Contains JSON-LD representation of the actor. + * Exported for internal package use but not re-exported from mod.ts. + * + * @internal + */ +export interface RelayFollowerData { + /** The actor's JSON-LD representation (serialized for storage). */ readonly actor: unknown; + /** The follower's state. */ + readonly state: "pending" | "accepted"; +} + +/** + * A follower of the relay with validated Actor instance. + * This is the public API type returned by follower query methods. + * + * @since 2.0.0 + */ +export interface RelayFollower { + /** The actor ID (URL) of the follower. */ + readonly actorId: string; + /** The validated Actor object. */ + readonly actor: Actor; + /** The follower's state. */ readonly state: "pending" | "accepted"; } /** - * Type predicate to check if a value is a valid RelayFollower. - * Provides both runtime validation and compile-time type narrowing. + * Public interface for ActivityPub relay implementations. + * Use {@link createRelay} to create a relay instance. + * + * @since 2.0.0 + */ +export interface Relay { + /** + * Handle incoming HTTP requests. + * + * @param request The incoming HTTP request + * @returns The HTTP response + */ + fetch(request: Request): Promise; + + /** + * Lists all followers of the relay. + * + * @returns An async iterator of follower entries + */ + listFollowers(): AsyncIterableIterator; + + /** + * Gets a specific follower by actor ID. + * + * @param actorId The actor ID (URL) of the follower to retrieve + * @returns The follower entry if found, null otherwise + */ + getFollower(actorId: string): Promise; +} + +/** + * Type predicate to check if a value is valid RelayFollowerData from KV store. + * Validates the storage format (JSON-LD), not the deserialized Actor instance. * * @param value The value to check - * @returns true if the value is a RelayFollower + * @returns true if the value is a RelayFollowerData + * @internal */ -export function isRelayFollower(value: unknown): value is RelayFollower { +export function isRelayFollowerData( + value: unknown, +): value is RelayFollowerData { if (!value || typeof value !== "object") return false; const obj = value as Record; return (