From 1661d5f2147c6438a718c32b5273e10bb7ebb700 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 30 Dec 2025 17:36:43 +0900 Subject: [PATCH 1/2] Make subscriptionHandler required in relay package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscriptionHandler option is now required instead of optional. This prevents the confusing situation where relays appear to work but silently reject all subscriptions. Users must now explicitly define their subscription policy: - For open relays: `subscriptionHandler: async () => true` - For restricted relays: implement custom approval logic Breaking changes: - RelayOptions.subscriptionHandler is now required (not optional) - All relay creations must include a subscriptionHandler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- packages/relay/README.md | 32 ++++++++++++++++++++++------- packages/relay/src/litepub.test.ts | 13 ++++++++++++ packages/relay/src/litepub.ts | 8 ++++---- packages/relay/src/mastodon.test.ts | 11 ++++++++++ packages/relay/src/mastodon.ts | 8 ++++---- packages/relay/src/types.ts | 2 +- 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/packages/relay/README.md b/packages/relay/README.md index e0402fdc..d75cbd05 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -13,6 +13,9 @@ This package provides ActivityPub relay functionality for the [Fedify] ecosystem, enabling the creation and management of relay servers that can forward activities between federated instances. +For comprehensive documentation on building and operating relay servers, +see the [*Relay server* section in the Fedify manual][manual]. + What is an ActivityPub relay? ------------------------------ @@ -33,7 +36,7 @@ This package supports two popular relay protocols used in the fediverse: ### Mastodon-style relay The Mastodon-style relay protocol uses LD signatures for activity -verification and follows the Public collection. This protocol is widely +verification and follows the Public collection. This protocol is widely supported by Mastodon and many other ActivityPub implementations. Key features: @@ -100,10 +103,12 @@ import { MemoryKvStore } from "@fedify/fedify"; const relay = createRelay("mastodon", { kv: new MemoryKvStore(), domain: "relay.example.com", - // Optional: Set a custom subscription handler to approve/reject subscriptions + // Required: Set a subscription handler to approve/reject subscriptions subscriptionHandler: async (ctx, actor) => { - // Implement your approval logic here - // Return true to approve, false to reject + // For an open relay, simply return true + // return true; + + // Or implement custom approval logic: const domain = new URL(actor.id!).hostname; const blockedDomains = ["spam.example", "blocked.example"]; return !blockedDomains.includes(domain); @@ -120,13 +125,24 @@ You can also create a LitePub-style relay by changing the type: const relay = createRelay("litepub", { kv: new MemoryKvStore(), domain: "relay.example.com", + subscriptionHandler: async (ctx, actor) => true, }); ~~~~ ### Subscription handling -By default, the relay automatically rejects all subscription requests. -You can customize this behavior by providing a subscription handler in the options: +The `subscriptionHandler` is required and determines whether to approve or reject +subscription requests. For an open relay that accepts all subscriptions: + +~~~~ typescript +const relay = createRelay("mastodon", { + kv: new MemoryKvStore(), + domain: "relay.example.com", + subscriptionHandler: async (ctx, actor) => true, // Accept all +}); +~~~~ + +You can also implement custom approval logic: ~~~~ typescript const relay = createRelay("mastodon", { @@ -156,6 +172,7 @@ const app = new Hono(); const relay = createRelay("mastodon", { kv: new MemoryKvStore(), domain: "relay.example.com", + subscriptionHandler: async (ctx, actor) => true, }); app.use("*", async (c) => { @@ -258,7 +275,7 @@ Configuration options for the relay: - `kv: KvStore` (required): Key–value store for persisting relay data - `domain?: string`: Relay's domain name (defaults to `"localhost"`) - `name?: string`: Relay's display name (defaults to `"ActivityPub Relay"`) - - `subscriptionHandler?: SubscriptionRequestHandler`: Custom handler for + - `subscriptionHandler: SubscriptionRequestHandler` (required): Handler for subscription approval/rejection - `documentLoaderFactory?: DocumentLoaderFactory`: Custom document loader factory @@ -296,3 +313,4 @@ type SubscriptionRequestHandler = ( [@fedify@hollo.social]: https://hollo.social/@fedify [Fedify]: https://fedify.dev/ [Fedify documentation on key–value stores]: https://fedify.dev/manual/kv +[manual]: https://fedify.dev/manual/relay diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 91adb220..59ba8033 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -101,6 +101,7 @@ describe("LitePubRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request("https://relay.example.com/users/relay", { @@ -117,6 +118,7 @@ describe("LitePubRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request("https://relay.example.com/users/relay", { @@ -137,6 +139,7 @@ describe("LitePubRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request( @@ -156,6 +159,7 @@ describe("LitePubRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request( @@ -204,6 +208,7 @@ describe("LitePubRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request( @@ -229,6 +234,7 @@ describe("LitePubRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request("https://relay.example.com/users/relay", { @@ -534,6 +540,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const relayFollow = new Follow({ @@ -596,6 +603,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const originalFollow = new Follow({ @@ -654,6 +662,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const note = new Note({ @@ -697,6 +706,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const note = new Note({ @@ -740,6 +750,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const moveActivity = new Move({ @@ -779,6 +790,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const deleteActivity = new Delete({ @@ -817,6 +829,7 @@ describe("LitePubRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const announceActivity = new Announce({ diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index ef44d7e7..8c512af0 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -74,10 +74,10 @@ export class LitePubRelay extends BaseRelay { ]); if (existingFollow?.state === "pending") return; - let approved = false; - if (this.options.subscriptionHandler) { - approved = await this.options.subscriptionHandler(ctx, follower); - } + const approved = await this.options.subscriptionHandler( + ctx, + follower, + ); if (approved) { // Litepub-specific: save with "pending" state diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 1f138807..7b0bd97c 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -99,6 +99,7 @@ describe("MastodonRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request("https://relay.example.com/users/relay", { @@ -115,6 +116,7 @@ describe("MastodonRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request("https://relay.example.com/users/relay", { @@ -135,6 +137,7 @@ describe("MastodonRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request( @@ -154,6 +157,7 @@ describe("MastodonRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request( @@ -203,6 +207,7 @@ describe("MastodonRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request( @@ -275,6 +280,7 @@ describe("MastodonRelay", () => { kv, domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const request = new Request("https://relay.example.com/users/relay", { @@ -463,6 +469,7 @@ describe("MastodonRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const originalFollow = new Follow({ @@ -521,6 +528,7 @@ describe("MastodonRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const note = new Note({ @@ -564,6 +572,7 @@ describe("MastodonRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const deleteActivity = new Delete({ @@ -602,6 +611,7 @@ describe("MastodonRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const note = new Note({ @@ -645,6 +655,7 @@ describe("MastodonRelay", () => { domain: "relay.example.com", documentLoaderFactory: () => mockDocumentLoader, authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), }); const moveActivity = new Move({ diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index c4d19d35..6af28ab4 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -52,10 +52,10 @@ export class MastodonRelay extends BaseRelay { const follower = await validateFollowActivity(ctx, follow); if (!follower || !follower.id) return; - let approved = false; - if (this.options.subscriptionHandler) { - approved = await this.options.subscriptionHandler(ctx, follower); - } + const approved = await this.options.subscriptionHandler( + ctx, + follower, + ); if (approved) { // Mastodon-specific: immediately add to followers list with accepted state diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index 0d411ede..8608f9e9 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -30,7 +30,7 @@ export interface RelayOptions { documentLoaderFactory?: DocumentLoaderFactory; authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; queue?: MessageQueue; - subscriptionHandler?: SubscriptionRequestHandler; + subscriptionHandler: SubscriptionRequestHandler; } export interface RelayFollower { From 30c889ea2bdb04d497a7795c51850823694369f2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 30 Dec 2025 17:51:54 +0900 Subject: [PATCH 2/2] Fix JSDoc example in factory.ts to include subscriptionHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example code in the JSDoc comment was missing the now-required subscriptionHandler option, causing TypeScript compilation to fail. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- packages/relay/src/factory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/relay/src/factory.ts b/packages/relay/src/factory.ts index a513f57a..f644346f 100644 --- a/packages/relay/src/factory.ts +++ b/packages/relay/src/factory.ts @@ -19,6 +19,7 @@ import type { RelayOptions, RelayType } from "./types.ts"; * const relay = createRelay("mastodon", { * kv: new MemoryKvStore(), * domain: "relay.example.com", + * subscriptionHandler: async (ctx, actor) => true, * }); * ``` *