From a8a66669e7cbf0dd7b1b388c3f917895d1ca964c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 00:56:46 +0900 Subject: [PATCH 01/45] refactor(relay): add relay factory pattern with federation builder --- packages/relay/src/mod.ts | 10 +- packages/relay/src/relay.ts | 761 ++++++------------------------------ 2 files changed, 125 insertions(+), 646 deletions(-) diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 6cb98fea1..7c977c4ce 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -9,5 +9,11 @@ */ // Export relay functionality here -export type { Relay, RelayOptions } from "./relay.ts"; -export { LitePubRelay, MastodonRelay } from "./relay.ts"; +export { + createRelay, + RELAY_SERVER_ACTOR, + type RelayOptions, + type SubscriptionRequestHandler, +} from "./relay.ts"; + +export { MastodonRelay } from "./mastodon.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 6d9d34fe1..49e9ac8a6 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -1,42 +1,26 @@ import { type Context, - createFederation, + createFederationBuilder, exportJwk, - type Federation, generateCryptoKeyPair, importJwk, type KvStore, type MessageQueue, } from "@fedify/fedify"; -import { - Accept, - type Actor, - Announce, - Application, - Create, - Delete, - Follow, - isActor, - Move, - Object, - PUBLIC_COLLECTION, - Reject, - Service, - Undo, - Update, -} from "@fedify/fedify/vocab"; +import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; +import { MastodonRelay } from "@fedify/relay"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, } from "@fedify/vocab-runtime"; -const RELAY_SERVER_ACTOR = "relay"; +export const RELAY_SERVER_ACTOR = "relay"; /** * Handler for subscription requests (Follow/Undo activities). */ export type SubscriptionRequestHandler = ( - ctx: Context, + ctx: Context, clientActor: Actor, ) => Promise; @@ -48,648 +32,137 @@ export interface RelayOptions { domain?: string; documentLoaderFactory?: DocumentLoaderFactory; authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; - federation?: Federation; queue?: MessageQueue; + subscriptionHandler?: SubscriptionRequestHandler; } /** * Base interface for ActivityPub relay implementations. */ -export interface Relay { +export interface OldRelay { readonly domain: string; fetch(request: Request): Promise; setSubscriptionHandler(handler: SubscriptionRequestHandler): this; } -interface LitePubRelayFollower { +export interface Follower { readonly actor: unknown; readonly state: string; } -/** - * A Mastodon-compatible ActivityPub relay implementation. - * This relay follows Mastodon's relay protocol for maximum compatibility - * with Mastodon instances. - * - * @since 2.0.0 - */ -export class MastodonRelay implements Relay { - #federation: Federation; - #options: RelayOptions; - #subscriptionHandler?: SubscriptionRequestHandler; - - constructor(options: RelayOptions) { - this.#options = options; - this.#federation = options.federation ?? createFederation({ - kv: options.kv, - queue: options.queue, - documentLoaderFactory: options.documentLoaderFactory, - authenticatedDocumentLoaderFactory: - options.authenticatedDocumentLoaderFactory, +export const relayBuilder = createFederationBuilder(); + +relayBuilder.setActorDispatcher( + "/users/{identifier}", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const keys = await ctx.getActorKeyPairs(identifier); + return new Application({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: "ActivityPub Relay", + inbox: ctx.getInboxUri(), // This should be sharedInboxUri + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + url: ctx.getActorUri(identifier), + publicKey: keys[0].cryptographicKey, + + assertionMethods: keys.map((k) => k.multikey), }); - - this.#federation.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Service({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: "ActivityPub Relay", - summary: "Mastodon-compatible ActivityPub relay server", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - assertionMethods: keys.map((k) => k.multikey), - }); - }, - ) - .setKeyPairsDispatcher( - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await options.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await options.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - - this.#federation.setFollowersDispatcher( - "/users/{identifier}/followers", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const activityIds = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const activityId of activityIds) { - const actorJson = await options.kv.get(["follower", activityId]); - - const actor = await Object.fromJsonLd(actorJson); - if (!isActor(actor)) continue; - - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; - - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - let approved = false; - - if (this.#subscriptionHandler) { - approved = await this.#subscriptionHandler( - ctx, - follower, - ); - } - - if (approved) { - const followers = await options.kv.get(["followers"]) ?? []; - followers.push(follow.id.href); - await options.kv.set(["followers"], followers); - - await options.kv.set( - ["follower", follow.id.href], - await follower.toJsonLd(), - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject(ctx); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const activityId = activity.id.href; - const followers = await options.kv.get(["followers"]) ?? - []; - const updatedFollowers = followers.filter((id) => id !== activityId); - await options.kv.set(["followers"], updatedFollowers); - options.kv.delete(["follower", activityId]); - } else { - console.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); - } - - get domain(): string { - return this.#options.domain || "localhost"; - } - - fetch(request: Request): Promise { - return this.#federation.fetch(request, { contextData: undefined }); - } - - setSubscriptionHandler(handler: SubscriptionRequestHandler): this { - this.#subscriptionHandler = handler; - return this; - } -} - -/** - * A LitePub-compatible ActivityPub relay implementation. - * This relay follows LitePub's relay protocol and extensions for - * enhanced federation capabilities. - * - * @since 2.0.0 - */ -export class LitePubRelay implements Relay { - #federation: Federation; - #options: RelayOptions; - #subscriptionHandler?: SubscriptionRequestHandler; - - constructor(options: RelayOptions) { - this.#options = options; - this.#federation = options.federation ?? createFederation({ - kv: options.kv, - queue: options.queue, - documentLoaderFactory: options.documentLoaderFactory, - authenticatedDocumentLoaderFactory: - options.authenticatedDocumentLoaderFactory, - }); - - this.#federation.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Application({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: "ActivityPub Relay", - summary: "LitePub-compatible ActivityPub relay server", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - following: ctx.getFollowingUri(identifier), // LitePub Relay should implement following dispatcher - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - - assertionMethods: keys.map((k) => k.multikey), - }); - }, - ) - .setKeyPairsDispatcher( - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await options.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await options.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - - this.#federation.setFollowingDispatcher( - "/users/{identifier}/following", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await options.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setFollowersDispatcher( - "/users/{identifier}/followers", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await options.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; - - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - - // Check if this is a follow from a client or if we already have a pending state - const existingFollow = await options.kv.get([ - "follower", - follower.id.href, - ]); - - // "pending" follower means this follower client requested subscription already. - if (existingFollow?.state === "pending") return; - - let subscriptionApproved = false; - - // Receive follow request from the relay client. - if (this.#subscriptionHandler) { - subscriptionApproved = await this.#subscriptionHandler( - ctx, - follower, - ); - } - - if (subscriptionApproved) { - // Add state pending - await options.kv.set( - ["follower", follower.id.href], - { "actor": await follower.toJsonLd(), "state": "pending" }, - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - - // Send reciprocal follow - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Follow({ - actor: relayActorUri, - object: follower.id, - to: follower.id, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Accept, async (ctx, accept) => { - // Validate follow activity from accept activity - const follow = await accept.getObject({ - crossOrigin: "trust", - ...ctx, + }, +) + .setKeyPairsDispatcher( + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return []; + + const rsaPairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "rsa", identifier]); + const ed25519PairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "ed25519", identifier]); + if (rsaPairJson == null || ed25519PairJson == null) { + const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + const ed25519Pair = await generateCryptoKeyPair("Ed25519"); + await ctx.data.kv.set(["keypair", "rsa", identifier], { + privateKey: await exportJwk(rsaPair.privateKey), + publicKey: await exportJwk(rsaPair.publicKey), }); - if (!(follow instanceof Follow)) return; - const follower = follow.actorId; - if (follower == null) return; - - // Validate following - accept activity sender - const following = await accept.getActor(); - if (!isActor(following) || !following.id) return; - const parsed = ctx.parseUri(follower); - if (parsed == null || parsed.type !== "actor") return; - - // Get follower from kv store - const followerData = await options.kv.get([ - "follower", - following.id.href, - ]); - if (followerData == null) return; - - // Update follower state - const updatedFollowerData = { ...followerData, state: "accepted" }; - await options.kv.set( - ["follower", following.id.href], - updatedFollowerData, - ); - - // Update followers list - const followers = await options.kv.get(["followers"]) ?? []; - followers.push(following.id.href); - await options.kv.set(["followers"], followers); - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject({ crossOrigin: "trust", ...ctx }); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const followers = await options.kv.get(["followers"]) ?? - []; // actor ids - - const updatedFollowers = followers.filter((id) => - id !== activity.actorId?.href - ); - await options.kv.set(["followers"], updatedFollowers); - options.kv.delete(["follower", activity.actorId?.href]); - } else { - console.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: create.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, update) => { - const sender = await update.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: update.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, move) => { - const sender = await move.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: move.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: deleteActivity.objectId, - to: PUBLIC_COLLECTION, + await ctx.data.kv.set(["keypair", "ed25519", identifier], { + privateKey: await exportJwk(ed25519Pair.privateKey), + publicKey: await exportJwk(ed25519Pair.publicKey), }); - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Announce, async (ctx, announceActivity) => { - const sender = await announceActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: announceActivity.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); - } - - get domain(): string { - return this.#options.domain || "localhost"; - } - - fetch(request: Request): Promise { - return this.#federation.fetch(request, { contextData: undefined }); - } - - setSubscriptionHandler(handler: SubscriptionRequestHandler): this { - this.#subscriptionHandler = handler; - return this; + return [rsaPair, ed25519Pair]; + } + + const rsaPair: CryptoKeyPair = { + privateKey: await importJwk(rsaPairJson.privateKey, "private"), + publicKey: await importJwk(rsaPairJson.publicKey, "public"), + }; + const ed25519Pair: CryptoKeyPair = { + privateKey: await importJwk(ed25519PairJson.privateKey, "private"), + publicKey: await importJwk(ed25519PairJson.publicKey, "public"), + }; + return [rsaPair, ed25519Pair]; + }, + ); + +relayBuilder.setFollowersDispatcher( + "/users/{identifier}/followers", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return { items: actors }; + }, +); + +relayBuilder.setFollowingDispatcher( + "/users/{identifier}/following", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return { items: actors }; + }, +); + +export function createRelay( + type: string, + options: RelayOptions, +): MastodonRelay { + switch (type) { + case "mastodon": + return new MastodonRelay(options, relayBuilder); + default: + throw new Error(`Unsupported relay type: ${type}`); } } From a5660ac700235500ff2dfa915ded75090b9099d5 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:21:59 +0900 Subject: [PATCH 02/45] refactor(relay): add create factory function for mastodon and litepub --- packages/relay/src/mod.ts | 2 ++ packages/relay/src/relay.ts | 22 +++++++--------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 7c977c4ce..c8a6c27b1 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -11,9 +11,11 @@ // Export relay functionality here export { createRelay, + type LitePubFollower, RELAY_SERVER_ACTOR, type RelayOptions, type SubscriptionRequestHandler, } from "./relay.ts"; export { MastodonRelay } from "./mastodon.ts"; +export { LitePubRelay } from "./litepub.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 49e9ac8a6..826d7ece0 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -8,7 +8,7 @@ import { type MessageQueue, } from "@fedify/fedify"; import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; -import { MastodonRelay } from "@fedify/relay"; +import { LitePubRelay, MastodonRelay } from "@fedify/relay"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, @@ -36,17 +36,7 @@ export interface RelayOptions { subscriptionHandler?: SubscriptionRequestHandler; } -/** - * Base interface for ActivityPub relay implementations. - */ -export interface OldRelay { - readonly domain: string; - - fetch(request: Request): Promise; - setSubscriptionHandler(handler: SubscriptionRequestHandler): this; -} - -export interface Follower { +export interface LitePubFollower { readonly actor: unknown; readonly state: string; } @@ -119,7 +109,7 @@ relayBuilder.setFollowersDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); @@ -142,7 +132,7 @@ relayBuilder.setFollowingDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); @@ -158,10 +148,12 @@ relayBuilder.setFollowingDispatcher( export function createRelay( type: string, options: RelayOptions, -): MastodonRelay { +): MastodonRelay | LitePubRelay { switch (type) { case "mastodon": return new MastodonRelay(options, relayBuilder); + case "litepub": + return new LitePubRelay(options, relayBuilder); default: throw new Error(`Unsupported relay type: ${type}`); } From d1f165204690e656495c2ae9ff79f60213549b74 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:25:12 +0900 Subject: [PATCH 03/45] refactor(relay): add mastodon rela y and its test --- packages/relay/src/mastodon.test.ts | 784 ++++++++++++++++++++++++++++ packages/relay/src/mastodon.ts | 191 +++++++ 2 files changed, 975 insertions(+) create mode 100644 packages/relay/src/mastodon.test.ts create mode 100644 packages/relay/src/mastodon.ts diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts new file mode 100644 index 000000000..f7c77da9f --- /dev/null +++ b/packages/relay/src/mastodon.test.ts @@ -0,0 +1,784 @@ +// deno-lint-ignore-file no-explicit-any +import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { + Create, + Delete, + Follow, + Move, + Note, + Person, + Undo, + Update, +} from "@fedify/fedify/vocab"; +import { + exportSpki, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { ok, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { createRelay, type RelayOptions } from "@fedify/relay"; + +// Simple mock document loader that returns a minimal context +const mockDocumentLoader = async (url: string): Promise => { + if ( + url === "https://remote.example.com/users/alice" || + url === "https://remote.example.com/users/alice#main-key" + ) { + return { + contextUrl: null, + documentUrl: url.replace(/#main-key$/, ""), + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: url, + type: "Person", + preferredUsername: "alice", + inbox: "https://remote.example.com/users/alice/inbox", + publicKey: { + id: "https://remote.example.com/users/alice#main-key", + owner: url.replace(/#main-key$/, ""), + publicKeyPem: await exportSpki(rsaKeyPair.publicKey), + }, + }, + }; + } else if (url === "https://remote.example.com/notes/1") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Note", + content: "Hello world", + }, + }; + } else if (url.startsWith("https://remote.example.com/")) { + throw new Error(`Document not found: ${url}`); + } + return await getDocumentLoader()(url); +}; + +// Mock RSA key pair for testing +const rsaKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); + +const rsaPublicKey = { + id: new URL("https://remote.example.com/users/alice#main-key"), + ...rsaKeyPair.publicKey, +}; + +describe("MastodonRelay", () => { + test("constructor with required options", () => { + const options: RelayOptions = { + kv: new MemoryKvStore(), + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(true); + }, + }; + + const relay = createRelay("mastodon", options); + ok(relay); + }); + + test("fetch method returns Response", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + ok(response instanceof Response); + }); + + test("fetching relay actor returns Application", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + }); + + test("fetching non-relay actor returns 404", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/non-existent", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 404); + }); + + test("followers collection returns empty list initially", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + // The followers dispatcher is configured, verify response structure + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + }); + + test("followers collection returns populated list", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate followers + const follower1 = new Person({ + id: new URL("https://remote1.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote1.example.com/users/alice/inbox"), + }); + + const follower2 = new Person({ + id: new URL("https://remote2.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote2.example.com/users/bob/inbox"), + }); + + const follower1Id = "https://remote1.example.com/users/alice"; + const follower2Id = "https://remote2.example.com/users/bob"; + + await kv.set(["followers"], [follower1Id, follower2Id]); + await kv.set( + ["follower", follower1Id], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", follower2Id], + { actor: await follower2.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + // Fedify wraps the items in a collection, check totalItems if available + if (json.totalItems !== undefined) { + strictEqual(json.totalItems, 2); + } + }); + + test("stores follower in KV when Follow is approved", async () => { + const kv = new MemoryKvStore(); + + // Manually simulate what happens when a Follow is approved + const followActivityId = "https://remote.example.com/activities/follow/1"; + 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"), + }); + + // Simulate the relay's internal logic + const followers = (await kv.get(["followers"])) ?? []; + followers.push(followActivityId); + await kv.set(["followers"], followers); + await kv.set( + ["follower", followActivityId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + // Verify storage + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers?.length, 1); + strictEqual(storedFollowers[0], followActivityId); + + const storedActor = await kv.get(["follower", followActivityId]); + ok(storedActor); + }); + + test("removes follower from KV when Undo Follow is received", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + // Simulate the Undo Follow logic + const followers = (await kv.get(["followers"])) ?? []; + const updatedFollowers = followers.filter((id) => id !== followerId); + await kv.set(["followers"], updatedFollowers); + await kv.delete(["follower", followerId]); + + // Verify removal + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 0); + + const storedActor = await kv.get(["follower", followerId]); + strictEqual(storedActor, undefined); + }); + + test("relay actor has correct properties", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + strictEqual(json.id, "https://relay.example.com/users/relay"); + strictEqual(json.inbox, "https://relay.example.com/inbox"); + strictEqual( + json.followers, + "https://relay.example.com/users/relay/followers", + ); + strictEqual( + json.following, + "https://relay.example.com/users/relay/following", + ); + }); + + test("multiple followers can be stored", async () => { + const kv = new MemoryKvStore(); + + // Simulate multiple Follow activities + const followIds = [ + "https://remote1.example.com/users/user1", + "https://remote2.example.com/users/user2", + "https://remote3.example.com/users/user3", + ]; + + const followers: string[] = []; + for (const followId of followIds) { + followers.push(followId); + const actor = new Person({ + id: new URL(followId), + preferredUsername: `user${followers.length}`, + inbox: new URL(`${followId}/inbox`), + }); + await kv.set( + ["follower", followId], + { actor: await actor.toJsonLd(), state: "accepted" }, + ); + } + await kv.set(["followers"], followers); + + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 3); + }); + + test("handles Follow activity with subscription approval", async () => { + const kv = new MemoryKvStore(); + let handlerCalled = false; + let handlerActor: any = null; + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, actor) => { + handlerCalled = true; + handlerActor = actor; + 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"), + }); + + 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"), + }); + + 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); + + // Verify handler was called + strictEqual(handlerCalled, true); + ok(handlerActor); + + // Verify follower was stored + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + strictEqual( + followers[0], + "https://remote.example.com/activities/follow/1", + ); + }); + + test("handles Follow activity with subscription rejection", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(false); // Reject the subscription + }, + }); + + 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"), + }); + + 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"), + }); + + 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); + + // Verify follower was NOT stored + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles Undo Follow activity", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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 followActivityId = "https://remote.example.com/activities/follow/1"; + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const originalFollow = new Follow({ + id: new URL(followActivityId), + 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, + }); + + 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, + ); + + await relay.fetch(request); + + // Verify follower was removed + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 0); + }); + + test("handles Create activity forwarding", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL(followerId), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted (forwarding happens in background) + ok(response.status === 200 || response.status === 202); + }); + + test("handles Delete activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const deleteActivity = new Delete({ + id: new URL("https://remote.example.com/activities/delete/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 deleteActivity.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("handles Update activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Updated content", + }); + + const updateActivity = new Update({ + id: new URL("https://remote.example.com/activities/update/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await updateActivity.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("handles Move activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const moveActivity = new Move({ + id: new URL("https://remote.example.com/activities/move/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/users/alice"), + target: new URL("https://other.example.com/users/alice"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await moveActivity.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(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + // Follow activity without id + const followActivity = new Follow({ + actor: new URL("https://remote.example.com/users/alice"), + 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); + + // Verify follower was NOT stored + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles public Follow activity", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => 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"), + }); + + // Public follow activity + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }); + + 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); + + // Verify follower was stored + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + }); +}); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts new file mode 100644 index 000000000..f20aa3df5 --- /dev/null +++ b/packages/relay/src/mastodon.ts @@ -0,0 +1,191 @@ +import { + Accept, + Create, + Delete, + type Federation, + Follow, + Move, + Reject, + Undo, + Update, +} from "@fedify/fedify"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "@fedify/relay"; +import type { FederationBuilder } from "@fedify/fedify/federation"; + +/** + * A Mastodon-compatible ActivityPub relay implementation. + * This relay follows Mastodon's relay protocol for maximum compatibility + * with Mastodon instances. + * + * @since 2.0.0 + */ +export class MastodonRelay { + #federationBuilder: FederationBuilder; + #options: RelayOptions; + #federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.#options = options; + this.#federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.#federation == null) { + this.#federation = await this.#federationBuilder.build(this.#options); + this.setupInboxListeners(); + } + + return await this.#federation.fetch(request, { + contextData: this.#options, + }); + } + + setupInboxListeners() { + if (this.#federation != null) { + this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.objectId == null) return; + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return; + + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return; + let approved = false; + + if (this.#options.subscriptionHandler) { + approved = await this.#options.subscriptionHandler( + ctx, + follower, + ); + } + + if (approved) { + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + followers.push(follow.id.href); + await ctx.data.kv.set(["followers"], followers); + + await ctx.data.kv.set( + ["follower", follow.id.href], + await follower.toJsonLd(), + ); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Accept({ + id: new URL(`#accepts`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + } else { + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Reject({ + id: new URL(`#rejects`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + } + }) + .on(Undo, async (ctx, undo) => { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + if (activity instanceof Follow) { + if ( + activity.id == null || + activity.actorId == null + ) return; + const followers = await ctx.data.kv.get(["followers"]) ?? + []; // actor ids + + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + await ctx.data.kv.set(["followers"], updatedFollowers); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + console.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } + }) + .on(Create, async (ctx, create) => { + const sender = await create.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Delete, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Move, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Update, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }); + } + } +} From e9a9009494f4f236032aeb320ce29a5895191fcf Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:27:49 +0900 Subject: [PATCH 04/45] refactor(relay): add litepub relay and its test --- packages/relay/src/litepub.test.ts | 896 +++++++++++++++++++++++++++++ packages/relay/src/litepub.ts | 297 ++++++++++ 2 files changed, 1193 insertions(+) create mode 100644 packages/relay/src/litepub.test.ts create mode 100644 packages/relay/src/litepub.ts diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts new file mode 100644 index 000000000..34389d8c1 --- /dev/null +++ b/packages/relay/src/litepub.test.ts @@ -0,0 +1,896 @@ +// deno-lint-ignore-file no-explicit-any +import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { + Accept, + Announce, + Create, + Delete, + Follow, + Move, + Note, + Person, + Undo, + Update, +} from "@fedify/fedify/vocab"; +import { + exportSpki, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { ok, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { createRelay, type RelayOptions } from "@fedify/relay"; + +// Simple mock document loader that returns a minimal context +const mockDocumentLoader = async (url: string): Promise => { + if ( + url === "https://remote.example.com/users/alice" || + url === "https://remote.example.com/users/alice#main-key" + ) { + return { + contextUrl: null, + documentUrl: url.replace(/#main-key$/, ""), + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: url, + type: "Person", + preferredUsername: "alice", + inbox: "https://remote.example.com/users/alice/inbox", + publicKey: { + id: "https://remote.example.com/users/alice#main-key", + owner: url.replace(/#main-key$/, ""), + publicKeyPem: await exportSpki(rsaKeyPair.publicKey), + }, + }, + }; + } else if (url === "https://remote.example.com/notes/1") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Note", + content: "Hello world", + }, + }; + } else if (url.startsWith("https://remote.example.com/")) { + throw new Error(`Document not found: ${url}`); + } + return await getDocumentLoader()(url); +}; + +// Mock RSA key pair for testing +const rsaKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); + +const rsaPublicKey = { + id: new URL("https://remote.example.com/users/alice#main-key"), + ...rsaKeyPair.publicKey, +}; + +describe("LitePubRelay", () => { + test("constructor with required options", () => { + const options: RelayOptions = { + kv: new MemoryKvStore(), + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(true); + }, + }; + + const relay = createRelay("litepub", options); + ok(relay); + }); + + test("fetch method returns Response", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + ok(response instanceof Response); + }); + + test("fetching relay actor returns Application", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + }); + + test("fetching non-relay actor returns 404", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/non-existent", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 404); + }); + + test("followers collection returns empty list initially", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + }); + + test("followers collection returns populated list", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate followers + const follower1 = new Person({ + id: new URL("https://remote1.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote1.example.com/users/alice/inbox"), + }); + + const follower2 = new Person({ + id: new URL("https://remote2.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote2.example.com/users/bob/inbox"), + }); + + const follower1Id = "https://remote1.example.com/users/alice"; + const follower2Id = "https://remote2.example.com/users/bob"; + + await kv.set(["followers"], [follower1Id, follower2Id]); + await kv.set( + ["follower", follower1Id], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", follower2Id], + { actor: await follower2.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + if (json.totalItems !== undefined) { + strictEqual(json.totalItems, 2); + } + }); + + test("relay actor has correct properties", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + strictEqual(json.id, "https://relay.example.com/users/relay"); + strictEqual(json.inbox, "https://relay.example.com/inbox"); + strictEqual( + json.followers, + "https://relay.example.com/users/relay/followers", + ); + strictEqual( + json.following, + "https://relay.example.com/users/relay/following", + ); + }); + + test("handles Follow activity with subscription approval", async () => { + const kv = new MemoryKvStore(); + let handlerCalled = false; + let handlerActor: any = null; + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, actor) => { + handlerCalled = true; + handlerActor = actor; + 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"), + }); + + 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"), + }); + + 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); + + // Verify handler was called + strictEqual(handlerCalled, true); + ok(handlerActor); + + // Verify follower was stored with "pending" state (awaiting reciprocal Accept) + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "pending"); + }); + + test("handles Follow activity with subscription rejection", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(false); // Reject the subscription + }, + }); + + 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"), + }); + + 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"), + }); + + 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); + + // Verify follower was NOT stored + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + strictEqual(followerData, undefined); + + // Verify followers list is empty + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles public Follow activity", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => 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"), + }); + + // Public follow activity + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }); + + 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); + + // Verify follower was stored with "pending" state + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "pending"); + }); + + test("ignores Follow activity without required fields", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + // Follow activity without id + const followActivity = new Follow({ + actor: new URL("https://remote.example.com/users/alice"), + 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); + + // Verify follower was NOT stored + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + strictEqual(followerData, undefined); + }); + + test("ignores duplicate Follow activity from pending follower", async () => { + const kv = new MemoryKvStore(); + let handlerCallCount = 0; + + const relay = createRelay("litepub", { + kv, + domain: "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"), + }); + + // Pre-populate with pending follower + await kv.set( + ["follower", "https://remote.example.com/users/alice"], + { actor: await follower.toJsonLd(), state: "pending" }, + ); + + 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"), + }); + + 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); + + // Verify handler was NOT called (duplicate follow ignored) + strictEqual(handlerCallCount, 0); + }); + + test("handles Accept activity completing reciprocal follow", async () => { + const kv = new MemoryKvStore(); + + 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"), + }); + + // 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, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + 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("https://remote.example.com/users/alice"), + }); + + const acceptActivity = new Accept({ + id: new URL("https://remote.example.com/activities/accept/1"), + actor: new URL("https://remote.example.com/users/alice"), + 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); + + // Verify follower state changed to "accepted" + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "accepted"); + + // Verify follower was added to followers list + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + strictEqual(followers[0], "https://remote.example.com/users/alice"); + }); + + test("handles Undo Follow activity", 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + 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, + }); + + 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, + ); + + await relay.fetch(request); + + // Verify follower was removed + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 0); + + const followerData = await kv.get(["follower", followerId]); + strictEqual(followerData, undefined); + }); + + test("handles Create activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL(followerId), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted (forwarding happens in background) + ok(response.status === 200 || response.status === 202); + }); + + test("handles Update activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Updated content", + }); + + const updateActivity = new Update({ + id: new URL("https://remote.example.com/activities/update/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await updateActivity.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("handles Move activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const moveActivity = new Move({ + id: new URL("https://remote.example.com/activities/move/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/users/alice"), + target: new URL("https://other.example.com/users/alice"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await moveActivity.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("handles Delete activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const deleteActivity = new Delete({ + id: new URL("https://remote.example.com/activities/delete/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 deleteActivity.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("handles Announce activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + 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("multiple followers can be stored", async () => { + const kv = new MemoryKvStore(); + + // Simulate multiple accepted followers + const followIds = [ + "https://remote1.example.com/users/user1", + "https://remote2.example.com/users/user2", + "https://remote3.example.com/users/user3", + ]; + + const followers: string[] = []; + for (const followId of followIds) { + followers.push(followId); + const actor = new Person({ + id: new URL(followId), + preferredUsername: `user${followers.length}`, + inbox: new URL(`${followId}/inbox`), + }); + await kv.set( + ["follower", followId], + { actor: await actor.toJsonLd(), state: "accepted" }, + ); + } + await kv.set(["followers"], followers); + + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 3); + }); +}); diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts new file mode 100644 index 000000000..6d8af1432 --- /dev/null +++ b/packages/relay/src/litepub.ts @@ -0,0 +1,297 @@ +import { + Accept, + Announce, + Create, + Delete, + type Federation, + type FederationBuilder, + Follow, + isActor, + Move, + PUBLIC_COLLECTION, + Reject, + Undo, + Update, +} from "@fedify/fedify"; +import { + type LitePubFollower, + RELAY_SERVER_ACTOR, + type RelayOptions, +} from "@fedify/relay"; + +/** + * A LitePub-compatible ActivityPub relay implementation. + * This relay follows LitePub's relay protocol and extensions for + * enhanced federation capabilities. + * + * @since 2.0.0 + */ +export class LitePubRelay { + #federationBuilder: FederationBuilder; + #options: RelayOptions; + #federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.#options = options; + this.#federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.#federation == null) { + this.#federation = await this.#federationBuilder.build(this.#options); + this.setupInboxListeners(); + } + + return await this.#federation.fetch(request, { + contextData: this.#options, + }); + } + + setupInboxListeners() { + if (this.#federation != null) { + this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.objectId == null) return; + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return; + + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return; + + // Check if this is a follow from a client or if we already have a pending state + const existingFollow = await ctx.data.kv.get([ + "follower", + follower.id.href, + ]); + + // "pending" follower means this follower client requested subscription already. + if (existingFollow?.state === "pending") return; + + let subscriptionApproved = false; + + // Receive follow request from the relay client. + if (this.#options.subscriptionHandler) { + subscriptionApproved = await this.#options.subscriptionHandler( + ctx, + follower, + ); + } + + if (subscriptionApproved) { + await ctx.data.kv.set( + ["follower", follower.id.href], + { "actor": await follower.toJsonLd(), "state": "pending" }, + ); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Accept({ + id: new URL(`#accepts`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + + // Send reciprocal follow + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Follow({ + actor: relayActorUri, + object: follower.id, + to: follower.id, + }), + ); + } else { + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Reject({ + id: new URL(`#rejects`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + } + }) + .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 follower = follow.actorId; + if (follower == null) return; + + // Validate following - accept activity sender + const following = await accept.getActor(); + if (!isActor(following) || !following.id) return; + const parsed = ctx.parseUri(follower); + if (parsed == null || parsed.type !== "actor") return; + + // Get follower from kv store + const followerData = await ctx.data.kv.get([ + "follower", + following.id.href, + ]); + if (followerData == null) return; + + // Update follower state + const updatedFollowerData = { ...followerData, state: "accepted" }; + await ctx.data.kv.set( + ["follower", following.id.href], + updatedFollowerData, + ); + + // Update followers list + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + followers.push(following.id.href); + await ctx.data.kv.set(["followers"], followers); + }) + .on(Undo, async (ctx, undo) => { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + if (activity instanceof Follow) { + if ( + activity.id == null || + activity.actorId == null + ) return; + const followers = await ctx.data.kv.get(["followers"]) ?? + []; // actor ids + + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + await ctx.data.kv.set(["followers"], updatedFollowers); + ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + console.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } + }) + .on(Create, async (ctx, create) => { + const sender = await create.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: create.objectId, + to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Update, async (ctx, update) => { + const sender = await update.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: update.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Move, async (ctx, move) => { + const sender = await move.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: move.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Delete, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: deleteActivity.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Announce, async (ctx, announceActivity) => { + const sender = await announceActivity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: announceActivity.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }); + } + } +} From b292911993eea23dd5df67ddef8277237d2afcf1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:30:37 +0900 Subject: [PATCH 05/45] refactor(relay): delete old relay test --- packages/relay/src/relay.test.ts | 1128 ------------------------------ 1 file changed, 1128 deletions(-) delete mode 100644 packages/relay/src/relay.test.ts diff --git a/packages/relay/src/relay.test.ts b/packages/relay/src/relay.test.ts deleted file mode 100644 index c9f76c9ac..000000000 --- a/packages/relay/src/relay.test.ts +++ /dev/null @@ -1,1128 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { ok, strictEqual } from "node:assert/strict"; -import { describe, test } from "node:test"; -import { MemoryKvStore } from "@fedify/fedify"; -import { Accept, Follow, Person } from "@fedify/fedify/vocab"; -import { signRequest } from "@fedify/fedify/sig"; -import { LitePubRelay, MastodonRelay, type RelayOptions } from "@fedify/relay"; -import { createFederation } from "@fedify/testing"; -import { - exportSpki, - getDocumentLoader, - type RemoteDocument, -} from "@fedify/vocab-runtime"; - -// Simple mock document loader that returns a minimal context -const mockDocumentLoader = async (url: string): Promise => { - if ( - url === "https://remote.example.com/users/alice" || - url === "https://remote.example.com/users/alice#main-key" - ) { - return { - contextUrl: null, - documentUrl: url.replace(/#main-key$/, ""), - document: { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], - id: url, - type: "Person", - preferredUsername: "alice", - inbox: "https://remote.example.com/users/alice/inbox", - publicKey: { - id: "https://remote.example.com/users/alice#main-key", - owner: url.replace(/#main-key$/, ""), - publicKeyPem: await exportSpki(rsaKeyPair.publicKey), - }, - }, - }; - } else if (url === "https://remote.example.com/notes/1") { - return { - contextUrl: null, - documentUrl: url, - document: { - "@context": "https://www.w3.org/ns/activitystreams", - id: url, - type: "Note", - content: "Hello world", - }, - }; - } else if (url.startsWith("https://remote.example.com/")) { - throw new Error(`Document not found: ${url}`); - } - return await getDocumentLoader()(url); -}; - -// Mock RSA key pair for testing -const rsaKeyPair = await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], -); - -const rsaPublicKey = { - id: new URL("https://remote.example.com/users/alice#main-key"), - ...rsaKeyPair.publicKey, -}; - -describe("MastodonRelay", () => { - test("constructor with required options", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }; - - const relay = new MastodonRelay(options); - strictEqual(relay.domain, "relay.example.com"); - }); - - test("creates relay with default domain", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - documentLoaderFactory: () => mockDocumentLoader, - }; - - const relay = new MastodonRelay(options); - strictEqual(relay.domain, "localhost"); - }); - - test("setSubscriptionHandler returns relay instance for chaining", () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const result = relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - strictEqual(result, relay); - }); - - test("fetch method returns Response", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - ok(response instanceof Response); - }); - - test("fetching relay actor returns Service", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - strictEqual(json.type, "Service"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - }); - - test("fetching non-relay actor returns 404", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/non-existent", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 404); - }); - - test("followers collection returns empty list initially", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - // The followers dispatcher is configured, verify response structure - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - }); - - test("followers collection returns populated list", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const followActivity1Id = "https://remote1.example.com/activities/follow/1"; - const followActivity2Id = "https://remote2.example.com/activities/follow/2"; - - await kv.set(["followers"], [followActivity1Id, followActivity2Id]); - await kv.set(["follower", followActivity1Id], follower1.toJsonLd()); - await kv.set(["follower", followActivity2Id], follower2.toJsonLd()); - - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - // Fedify wraps the items in a collection, check totalItems if available - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 2); - } - }); - - test("subscription handler is called on Follow activity", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCalled = false; - let handlerActor: unknown = null; - - relay.setSubscriptionHandler(async (_ctx, actor) => { - handlerCalled = true; - handlerActor = actor; - return await Promise.resolve(true); - }); - - // Create a Follow activity - 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"), - }); - - 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"), - }); - - // Sign and send the Follow activity to the relay's inbox - let request = new Request("https://relay.example.com/users/relay/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - const _response = await relay.fetch(request); - - strictEqual(handlerCalled, true); - ok(handlerActor); - }); - - test("stores follower in KV when Follow is approved", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - // Manually simulate what happens when a Follow is approved - const followActivityId = "https://remote.example.com/activities/follow/1"; - 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"), - }); - - // Simulate the relay's internal logic - const followers = (await kv.get(["followers"])) ?? []; - followers.push(followActivityId); - await kv.set(["followers"], followers); - await kv.set(["follower", followActivityId], follower.toJsonLd()); - - // Verify storage - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers?.length, 1); - strictEqual(storedFollowers[0], followActivityId); - - const storedActor = await kv.get(["follower", followActivityId]); - ok(storedActor); - }); - - test("removes follower from KV when Undo Follow is received", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate with a follower - const followActivityId = "https://remote.example.com/activities/follow/1"; - 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(["followers"], [followActivityId]); - await kv.set(["follower", followActivityId], follower.toJsonLd()); - - const _relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - // Simulate the Undo Follow logic - const followers = (await kv.get(["followers"])) ?? []; - const updatedFollowers = followers.filter((id) => id !== followActivityId); - await kv.set(["followers"], updatedFollowers); - await kv.delete(["follower", followActivityId]); - - // Verify removal - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 0); - - const storedActor = await kv.get(["follower", followActivityId]); - strictEqual(storedActor, undefined); - }); - - test("does not store follower when Follow is rejected", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(false); - }); - - // Verify no followers are stored initially - const followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - }); - - test("relay actor has correct properties", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - - strictEqual(json.type, "Service"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - strictEqual( - json.summary, - "Mastodon-compatible ActivityPub relay server", - ); - strictEqual(json.id, "https://relay.example.com/users/relay"); - strictEqual(json.inbox, "https://relay.example.com/inbox"); - strictEqual( - json.followers, - "https://relay.example.com/users/relay/followers", - ); - }); - - test("multiple followers can be stored", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - // Simulate multiple Follow activities - const followIds = [ - "https://remote1.example.com/activities/follow/1", - "https://remote2.example.com/activities/follow/2", - "https://remote3.example.com/activities/follow/3", - ]; - - const followers: string[] = []; - for (const followId of followIds) { - followers.push(followId); - const actor = new Person({ - id: new URL(followId.replace("/activities/follow/", "/users/user")), - preferredUsername: `user${followers.length}`, - inbox: new URL( - followId.replace("/activities/follow/", "/users/user") + "/inbox", - ), - }); - await kv.set(["follower", followId], actor.toJsonLd()); - } - await kv.set(["followers"], followers); - - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 3); - }); -}); - -describe("LitePubRelay", () => { - test("creates relay with required options", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }; - - const relay = new LitePubRelay(options); - strictEqual(relay.domain, "relay.example.com"); - }); - - test("creates relay with default domain", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - documentLoaderFactory: () => mockDocumentLoader, - }; - - const relay = new LitePubRelay(options); - strictEqual(relay.domain, "localhost"); - }); - - test("setSubscriptionHandler returns relay instance for chaining", () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const result = relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - strictEqual(result, relay); - }); - - test("fetch method returns Response", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - ok(response instanceof Response); - }); - - test("fetching relay actor returns Application", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - strictEqual(json.type, "Application"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - }); - - test("fetching non-relay actor returns 404", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/non-existent", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 404); - }); - - test("followers collection returns empty list initially", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - }); - - test("followers collection returns populated list", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers with LitePub structure (actor + state) - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const follower1Id = "https://remote1.example.com/users/alice"; - const follower2Id = "https://remote2.example.com/users/bob"; - - // LitePub stores actor IDs in followers list and uses LitePubRelayFollower structure - await kv.set(["followers"], [follower1Id, follower2Id]); - await kv.set(["follower", follower1Id], { - actor: await follower1.toJsonLd(), - state: "accepted", - }); - await kv.set(["follower", follower2Id], { - actor: await follower2.toJsonLd(), - state: "accepted", - }); - - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 2); - } - }); - - test("subscription handler is called on Follow activity", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCalled = false; - let handlerActor: unknown = null; - - relay.setSubscriptionHandler(async (_ctx, actor) => { - handlerCalled = true; - handlerActor = actor; - return await Promise.resolve(true); - }); - - // Create a Follow activity - 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"), - }); - - 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"), - }); - - // Sign and send the Follow activity to the relay's inbox - let request = new Request("https://relay.example.com/users/relay/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - const _response = await relay.fetch(request); - - strictEqual(handlerCalled, true); - ok(handlerActor); - }); - - test("stores follower with pending state then accepted after two-step handshake", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - 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"), - }); - - const followerId = follower.id!.href; - - // Step 1: Simulate Follow received and stored with "pending" state - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Verify follower is in pending state - const followerData = await kv.get<{ actor: unknown; state: string }>([ - "follower", - followerId, - ]); - ok(followerData); - strictEqual(followerData.state, "pending"); - - // Verify follower is NOT in followers list yet - let followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - - // Step 2: Simulate Accept received - state changes to "accepted" - if (followerData) { - const updatedFollowerData = { ...followerData, state: "accepted" }; - await kv.set(["follower", followerId], updatedFollowerData); - } - - // Now add to followers list - followers = (await kv.get(["followers"])) ?? []; - followers.push(followerId); - await kv.set(["followers"], followers); - - // Verify final state - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 1); - strictEqual(storedFollowers[0], followerId); - - const storedFollowerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(storedFollowerData); - strictEqual(storedFollowerData.state, "accepted"); - }); - - test("removes follower from KV when Undo Follow is received", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate with a follower (LitePub uses actor ID as key) - 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"), - }); - - const followerId = follower.id!.href; - - await kv.set(["followers"], [followerId]); - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "accepted", - }); - - const _relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - // Simulate the Undo Follow logic (uses actor ID) - const followers = (await kv.get(["followers"])) ?? []; - const updatedFollowers = followers.filter((id) => id !== followerId); - await kv.set(["followers"], updatedFollowers); - await kv.delete(["follower", followerId]); - - // Verify removal - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 0); - - const storedActor = await kv.get(["follower", followerId]); - strictEqual(storedActor, undefined); - }); - - test("does not store follower when Follow is rejected", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(false); - }); - - // Verify no followers are stored initially - const followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - }); - - test("relay actor has correct properties", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - - strictEqual(json.type, "Application"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - strictEqual( - json.summary, - "LitePub-compatible ActivityPub relay server", - ); - strictEqual(json.id, "https://relay.example.com/users/relay"); - strictEqual(json.inbox, "https://relay.example.com/inbox"); - strictEqual( - json.followers, - "https://relay.example.com/users/relay/followers", - ); - }); - - test("multiple followers can be stored", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - // Simulate multiple Follow activities (LitePub uses actor IDs as keys) - const actorIds = [ - "https://remote1.example.com/users/user1", - "https://remote2.example.com/users/user2", - "https://remote3.example.com/users/user3", - ]; - - const followers: string[] = []; - for (let i = 0; i < actorIds.length; i++) { - const actorId = actorIds[i]; - followers.push(actorId); - const actor = new Person({ - id: new URL(actorId), - preferredUsername: `user${i + 1}`, - inbox: new URL(`${actorId}/inbox`), - }); - await kv.set(["follower", actorId], { - actor: await actor.toJsonLd(), - state: "accepted", - }); - } - await kv.set(["followers"], followers); - - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 3); - }); - - test("Accept handler updates follower state and adds to followers list", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - 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"), - }); - - const followerId = follower.id!.href; - - // Pre-populate with pending follower (simulating initial Follow was processed) - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Verify not in followers list yet - let followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - - // Create Accept activity from the client - const relayFollowActivity = new Follow({ - id: new URL("https://relay.example.com/activities/follow/1"), - actor: new URL("https://relay.example.com/users/relay"), - object: follower.id, - }); - - const acceptActivity = new Accept({ - id: new URL("https://remote.example.com/activities/accept/1"), - actor: follower.id, - object: relayFollowActivity, - }); - - // Sign and send the Accept activity to the relay's inbox - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - await relay.fetch(request); - - // Verify state changed to accepted - const updatedFollowerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(updatedFollowerData); - strictEqual(updatedFollowerData.state, "accepted"); - - // Verify added to followers list - followers = await kv.get(["followers"]); - ok(followers); - strictEqual(followers.length, 1); - strictEqual(followers[0], followerId); - }); - - test("following dispatcher returns same actors as followers", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers with accepted state - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const follower1Id = follower1.id!.href; - const follower2Id = follower2.id!.href; - - await kv.set(["followers"], [follower1Id, follower2Id]); - await kv.set(["follower", follower1Id], { - actor: await follower1.toJsonLd(), - state: "accepted", - }); - await kv.set(["follower", follower2Id], { - actor: await follower2.toJsonLd(), - state: "accepted", - }); - - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - // Fetch following collection - const followingRequest = new Request( - "https://relay.example.com/users/relay/following", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const followingResponse = await relay.fetch(followingRequest); - - strictEqual(followingResponse.status, 200); - const followingJson = await followingResponse.json() as any; - ok(followingJson); - ok( - followingJson.type === "Collection" || - followingJson.type === "OrderedCollection", - ); - - // Fetch followers collection - const followersRequest = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const followersResponse = await relay.fetch(followersRequest); - - strictEqual(followersResponse.status, 200); - const followersJson = await followersResponse.json() as any; - - // Verify both collections have same count - if (followingJson.totalItems !== undefined) { - strictEqual(followingJson.totalItems, 2); - strictEqual(followersJson.totalItems, 2); - } - }); - - test("pending followers are not in followers list", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - 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"), - }); - - const followerId = follower.id!.href; - - // Store follower with pending state - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Fetch followers collection - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - - // Verify pending follower is NOT in collection - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 0); - } - }); - - test("duplicate Follow is ignored when follower is pending", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCallCount = 0; - - relay.setSubscriptionHandler(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"), - }); - - const followerId = follower.id!.href; - - // Pre-populate with pending follower - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Send another Follow activity from the same user - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/2"), - actor: follower.id, - 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", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - await relay.fetch(request); - - // Verify subscription handler was NOT called (duplicate was ignored) - strictEqual(handlerCallCount, 0); - - // Verify state is still pending - const followerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(followerData); - strictEqual(followerData.state, "pending"); - }); -}); From 8058130edecb6bcd13b0c726b0ff4099578a1c40 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 02:32:01 +0900 Subject: [PATCH 06/45] fix(relay): add temporal polyfill --- packages/relay/package.json | 3 +++ packages/relay/src/relay.ts | 3 ++- packages/relay/tsdown.config.ts | 12 ++++++++++++ pnpm-lock.yaml | 3 +++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/relay/package.json b/packages/relay/package.json index 801b6adc8..feee2e895 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -47,6 +47,9 @@ "dist/", "package.json" ], + "dependencies": { + "@js-temporal/polyfill": "catalog:" + }, "peerDependencies": { "@fedify/fedify": "workspace:^" }, diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 826d7ece0..e2c325eab 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -8,11 +8,12 @@ import { type MessageQueue, } from "@fedify/fedify"; import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; -import { LitePubRelay, MastodonRelay } from "@fedify/relay"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, } from "@fedify/vocab-runtime"; +import { LitePubRelay } from "./litepub.ts"; +import { MastodonRelay } from "./mastodon.ts"; export const RELAY_SERVER_ACTOR = "relay"; diff --git a/packages/relay/tsdown.config.ts b/packages/relay/tsdown.config.ts index 27a91233e..a89df939d 100644 --- a/packages/relay/tsdown.config.ts +++ b/packages/relay/tsdown.config.ts @@ -5,4 +5,16 @@ export default defineConfig({ dts: true, format: ["esm", "cjs"], platform: "node", + outputOptions(outputOptions, format) { + if (format === "cjs") { + outputOptions.intro = ` + const { Temporal } = require("@js-temporal/polyfill"); + `; + } else { + outputOptions.intro = ` + import { Temporal } from "@js-temporal/polyfill"; + `; + } + return outputOptions; + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc69f0fc3..b1db7788d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1064,6 +1064,9 @@ importers: '@fedify/fedify': specifier: workspace:^ version: link:../fedify + '@js-temporal/polyfill': + specifier: 'catalog:' + version: 0.5.1 devDependencies: '@fedify/testing': specifier: workspace:^ From c25ebf59d78a4d249eb5ce7e964d5d1154c624ef Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Nov 2025 15:00:28 +0900 Subject: [PATCH 07/45] refactor(relay): fix import --- packages/relay/src/litepub.ts | 2 +- packages/relay/src/mastodon.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 6d8af1432..2ce7eee76 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -17,7 +17,7 @@ import { type LitePubFollower, RELAY_SERVER_ACTOR, type RelayOptions, -} from "@fedify/relay"; +} from "./relay.ts"; /** * A LitePub-compatible ActivityPub relay implementation. diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index f20aa3df5..5996d2983 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -9,7 +9,7 @@ import { Undo, Update, } from "@fedify/fedify"; -import { RELAY_SERVER_ACTOR, type RelayOptions } from "@fedify/relay"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; import type { FederationBuilder } from "@fedify/fedify/federation"; /** From bccda7c1f72e2290db525ef13379e78b1827d1ec Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Nov 2025 15:19:08 +0900 Subject: [PATCH 08/45] fix(relay): add changed data model when mastodon save follower to kv - add actor and state --- packages/relay/src/mastodon.test.ts | 2 +- packages/relay/src/mastodon.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index f7c77da9f..13544a204 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -404,7 +404,7 @@ describe("MastodonRelay", () => { strictEqual(followers.length, 1); strictEqual( followers[0], - "https://remote.example.com/activities/follow/1", + "https://remote.example.com/users/alice", ); }); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 5996d2983..1eb3f5c3e 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -72,12 +72,12 @@ export class MastodonRelay { if (approved) { const followers = await ctx.data.kv.get(["followers"]) ?? []; - followers.push(follow.id.href); + followers.push(follower.id.href); await ctx.data.kv.set(["followers"], followers); await ctx.data.kv.set( - ["follower", follow.id.href], - await follower.toJsonLd(), + ["follower", follower.id.href], + { "actor": await follower.toJsonLd(), "state": "accepted" }, ); await ctx.sendActivity( From 9112d4df70364ad83a21375dd6d710adf5fba3d5 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Nov 2025 15:37:33 +0900 Subject: [PATCH 09/45] refactor(relay): change type name from LitePubFollower to RelayFollower --- packages/relay/src/litepub.ts | 4 ++-- packages/relay/src/mod.ts | 2 +- packages/relay/src/relay.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 2ce7eee76..91a023cc1 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -14,8 +14,8 @@ import { Update, } from "@fedify/fedify"; import { - type LitePubFollower, RELAY_SERVER_ACTOR, + type RelayFollower, type RelayOptions, } from "./relay.ts"; @@ -69,7 +69,7 @@ export class LitePubRelay { ) return; // Check if this is a follow from a client or if we already have a 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/mod.ts b/packages/relay/src/mod.ts index c8a6c27b1..bed83df89 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -11,8 +11,8 @@ // Export relay functionality here export { createRelay, - type LitePubFollower, RELAY_SERVER_ACTOR, + type RelayFollower, type RelayOptions, type SubscriptionRequestHandler, } from "./relay.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index e2c325eab..3a3a8f3bd 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -37,7 +37,7 @@ export interface RelayOptions { subscriptionHandler?: SubscriptionRequestHandler; } -export interface LitePubFollower { +export interface RelayFollower { readonly actor: unknown; readonly state: string; } @@ -110,7 +110,7 @@ relayBuilder.setFollowersDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); @@ -133,7 +133,7 @@ relayBuilder.setFollowingDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); From 955508ab8bb43a4eaebb018d24ec5f5db5017b1c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Dec 2025 09:26:26 +0900 Subject: [PATCH 10/45] refactor(relay): address PR #484 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses all 6 unresolved review comments from @dahlia on PR #484: 1. Add type safety to createRelay parameter by introducing RelayType union type 2. Create common Relay interface and use it as return type for createRelay factory function 3. Add missing await keyword in litepub.ts for kv.delete operation 4. Fix poorly named variable in Move handler (mastodon.ts): deleteActivity → move 5. Fix poorly named variable in Update handler (mastodon.ts): deleteActivity → update 6. Extract duplicate code in followers/following dispatchers into shared getFollowerActors function Changes: - Add RelayType = "mastodon" | "litepub" type alias - Add Relay interface with fetch method - Update MastodonRelay and LitePubRelay to implement Relay interface - Change createRelay return type from union to abstract Relay interface - Remove unnecessary default case in createRelay switch (exhaustive with RelayType) - Extract getFollowerActors helper to eliminate duplicate dispatcher logic - Fix missing await in litepub.ts:182 - Rename handler parameters to match their actual activity types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/relay/src/litepub.ts | 5 ++- packages/relay/src/mastodon.ts | 12 +++--- packages/relay/src/relay.ts | 69 +++++++++++++++++----------------- pnpm-lock.yaml | 3 +- 4 files changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 91a023cc1..51d19a027 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -14,6 +14,7 @@ import { Update, } from "@fedify/fedify"; import { + type Relay, RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, @@ -26,7 +27,7 @@ import { * * @since 2.0.0 */ -export class LitePubRelay { +export class LitePubRelay implements Relay { #federationBuilder: FederationBuilder; #options: RelayOptions; #federation?: Federation; @@ -178,7 +179,7 @@ export class LitePubRelay { id !== activity.actorId?.href ); await ctx.data.kv.set(["followers"], updatedFollowers); - ctx.data.kv.delete(["follower", activity.actorId?.href]); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); } else { console.warn( "Unsupported object type ({type}) for Undo activity: {object}", diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 1eb3f5c3e..519f09c7c 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -9,7 +9,7 @@ import { Undo, Update, } from "@fedify/fedify"; -import { RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; +import { type Relay, RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; import type { FederationBuilder } from "@fedify/fedify/federation"; /** @@ -19,7 +19,7 @@ import type { FederationBuilder } from "@fedify/fedify/federation"; * * @since 2.0.0 */ -export class MastodonRelay { +export class MastodonRelay implements Relay { #federationBuilder: FederationBuilder; #options: RelayOptions; #federation?: Federation; @@ -156,8 +156,8 @@ export class MastodonRelay { }, ); }) - .on(Move, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); + .on(Move, async (ctx, move) => { + const sender = await move.getActor(ctx); // Exclude the sender's origin to prevent forwarding back to them const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; @@ -171,8 +171,8 @@ export class MastodonRelay { }, ); }) - .on(Update, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); + .on(Update, async (ctx, update) => { + const sender = await update.getActor(ctx); // Exclude the sender's origin to prevent forwarding back to them const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 3a3a8f3bd..119103e00 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -17,6 +17,18 @@ import { MastodonRelay } from "./mastodon.ts"; export const RELAY_SERVER_ACTOR = "relay"; +/** + * Supported relay types. + */ +export type RelayType = "mastodon" | "litepub"; + +/** + * Common interface for all relay implementations. + */ +export interface Relay { + fetch(request: Request): Promise; +} + /** * Handler for subscription requests (Follow/Undo activities). */ @@ -100,25 +112,30 @@ relayBuilder.setActorDispatcher( }, ); +async function getFollowerActors( + ctx: Context, +): Promise { + const followers = await ctx.data.kv.get(["followers"]) ?? []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return actors; +} + relayBuilder.setFollowersDispatcher( "/users/{identifier}/followers", async (ctx, identifier) => { if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await ctx.data.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await ctx.data.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } + const actors = await getFollowerActors(ctx); return { items: actors }; }, ); @@ -127,35 +144,19 @@ relayBuilder.setFollowingDispatcher( "/users/{identifier}/following", async (ctx, identifier) => { if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await ctx.data.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await ctx.data.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } + const actors = await getFollowerActors(ctx); return { items: actors }; }, ); export function createRelay( - type: string, + type: RelayType, options: RelayOptions, -): MastodonRelay | LitePubRelay { +): Relay { switch (type) { case "mastodon": return new MastodonRelay(options, relayBuilder); case "litepub": return new LitePubRelay(options, relayBuilder); - default: - throw new Error(`Unsupported relay type: ${type}`); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1db7788d..d6685b252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6713,6 +6713,7 @@ packages: next@14.2.30: resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -12956,7 +12957,7 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color From b2e020e5eab9c55b1738b833a1567e840154d80f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Dec 2025 14:46:45 +0900 Subject: [PATCH 11/45] Add changes in the document --- CHANGES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 3eb6417bd..191d8069c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -138,7 +138,7 @@ To be released. ### @fedify/relay - Created ActivityPub relay integration as the *@fedify/relay* package. - [[#359], [#459], [#471] by Jiwon Kwon] + [[#359], [#459], [#471], [#490] by Jiwon Kwon] - Added `Relay` interface defining the common contract for relay implementations. @@ -149,10 +149,13 @@ To be released. - Added `SubscriptionRequestHandler` type for custom subscription approval logic. - Added `RelayOptions` interface for relay configuration. + - Added `RelayType` type alias to document the type-safe parameter + - Added `createRelay()` factory function as a key public API [#359]: https://github.com/fedify-dev/fedify/issues/359 [#459]: https://github.com/fedify-dev/fedify/pull/459 [#471]: https://github.com/fedify-dev/fedify/pull/471 +[#490]: https://github.com/fedify-dev/fedify/pull/490 ### @fedify/vocab-tools From f14078ce9b860c9a5109c58bcee8d1fcdf5a3d35 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Dec 2025 14:55:25 +0900 Subject: [PATCH 12/45] Add missing published field in Activity types --- packages/relay/src/litepub.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 51d19a027..9b327773f 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -218,6 +218,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: update.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( @@ -239,6 +240,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: move.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( @@ -260,6 +262,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: deleteActivity.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( @@ -281,6 +284,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: announceActivity.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( From 518628ee9540d0feefe3ef65e52c606c0fd06d75 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 00:56:46 +0900 Subject: [PATCH 13/45] refactor(relay): add relay factory pattern with federation builder --- packages/relay/src/mod.ts | 10 +- packages/relay/src/relay.ts | 761 ++++++------------------------------ 2 files changed, 125 insertions(+), 646 deletions(-) diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 6cb98fea1..7c977c4ce 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -9,5 +9,11 @@ */ // Export relay functionality here -export type { Relay, RelayOptions } from "./relay.ts"; -export { LitePubRelay, MastodonRelay } from "./relay.ts"; +export { + createRelay, + RELAY_SERVER_ACTOR, + type RelayOptions, + type SubscriptionRequestHandler, +} from "./relay.ts"; + +export { MastodonRelay } from "./mastodon.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 6d9d34fe1..49e9ac8a6 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -1,42 +1,26 @@ import { type Context, - createFederation, + createFederationBuilder, exportJwk, - type Federation, generateCryptoKeyPair, importJwk, type KvStore, type MessageQueue, } from "@fedify/fedify"; -import { - Accept, - type Actor, - Announce, - Application, - Create, - Delete, - Follow, - isActor, - Move, - Object, - PUBLIC_COLLECTION, - Reject, - Service, - Undo, - Update, -} from "@fedify/fedify/vocab"; +import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; +import { MastodonRelay } from "@fedify/relay"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, } from "@fedify/vocab-runtime"; -const RELAY_SERVER_ACTOR = "relay"; +export const RELAY_SERVER_ACTOR = "relay"; /** * Handler for subscription requests (Follow/Undo activities). */ export type SubscriptionRequestHandler = ( - ctx: Context, + ctx: Context, clientActor: Actor, ) => Promise; @@ -48,648 +32,137 @@ export interface RelayOptions { domain?: string; documentLoaderFactory?: DocumentLoaderFactory; authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; - federation?: Federation; queue?: MessageQueue; + subscriptionHandler?: SubscriptionRequestHandler; } /** * Base interface for ActivityPub relay implementations. */ -export interface Relay { +export interface OldRelay { readonly domain: string; fetch(request: Request): Promise; setSubscriptionHandler(handler: SubscriptionRequestHandler): this; } -interface LitePubRelayFollower { +export interface Follower { readonly actor: unknown; readonly state: string; } -/** - * A Mastodon-compatible ActivityPub relay implementation. - * This relay follows Mastodon's relay protocol for maximum compatibility - * with Mastodon instances. - * - * @since 2.0.0 - */ -export class MastodonRelay implements Relay { - #federation: Federation; - #options: RelayOptions; - #subscriptionHandler?: SubscriptionRequestHandler; - - constructor(options: RelayOptions) { - this.#options = options; - this.#federation = options.federation ?? createFederation({ - kv: options.kv, - queue: options.queue, - documentLoaderFactory: options.documentLoaderFactory, - authenticatedDocumentLoaderFactory: - options.authenticatedDocumentLoaderFactory, +export const relayBuilder = createFederationBuilder(); + +relayBuilder.setActorDispatcher( + "/users/{identifier}", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const keys = await ctx.getActorKeyPairs(identifier); + return new Application({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: "ActivityPub Relay", + inbox: ctx.getInboxUri(), // This should be sharedInboxUri + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + url: ctx.getActorUri(identifier), + publicKey: keys[0].cryptographicKey, + + assertionMethods: keys.map((k) => k.multikey), }); - - this.#federation.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Service({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: "ActivityPub Relay", - summary: "Mastodon-compatible ActivityPub relay server", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - assertionMethods: keys.map((k) => k.multikey), - }); - }, - ) - .setKeyPairsDispatcher( - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await options.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await options.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - - this.#federation.setFollowersDispatcher( - "/users/{identifier}/followers", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const activityIds = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const activityId of activityIds) { - const actorJson = await options.kv.get(["follower", activityId]); - - const actor = await Object.fromJsonLd(actorJson); - if (!isActor(actor)) continue; - - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; - - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - let approved = false; - - if (this.#subscriptionHandler) { - approved = await this.#subscriptionHandler( - ctx, - follower, - ); - } - - if (approved) { - const followers = await options.kv.get(["followers"]) ?? []; - followers.push(follow.id.href); - await options.kv.set(["followers"], followers); - - await options.kv.set( - ["follower", follow.id.href], - await follower.toJsonLd(), - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject(ctx); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const activityId = activity.id.href; - const followers = await options.kv.get(["followers"]) ?? - []; - const updatedFollowers = followers.filter((id) => id !== activityId); - await options.kv.set(["followers"], updatedFollowers); - options.kv.delete(["follower", activityId]); - } else { - console.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); - } - - get domain(): string { - return this.#options.domain || "localhost"; - } - - fetch(request: Request): Promise { - return this.#federation.fetch(request, { contextData: undefined }); - } - - setSubscriptionHandler(handler: SubscriptionRequestHandler): this { - this.#subscriptionHandler = handler; - return this; - } -} - -/** - * A LitePub-compatible ActivityPub relay implementation. - * This relay follows LitePub's relay protocol and extensions for - * enhanced federation capabilities. - * - * @since 2.0.0 - */ -export class LitePubRelay implements Relay { - #federation: Federation; - #options: RelayOptions; - #subscriptionHandler?: SubscriptionRequestHandler; - - constructor(options: RelayOptions) { - this.#options = options; - this.#federation = options.federation ?? createFederation({ - kv: options.kv, - queue: options.queue, - documentLoaderFactory: options.documentLoaderFactory, - authenticatedDocumentLoaderFactory: - options.authenticatedDocumentLoaderFactory, - }); - - this.#federation.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Application({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: "ActivityPub Relay", - summary: "LitePub-compatible ActivityPub relay server", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - following: ctx.getFollowingUri(identifier), // LitePub Relay should implement following dispatcher - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - - assertionMethods: keys.map((k) => k.multikey), - }); - }, - ) - .setKeyPairsDispatcher( - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await options.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await options.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await options.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - - this.#federation.setFollowingDispatcher( - "/users/{identifier}/following", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await options.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setFollowersDispatcher( - "/users/{identifier}/followers", - async (_ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await options.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await options.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return { items: actors }; - }, - ); - - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; - - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - - // Check if this is a follow from a client or if we already have a pending state - const existingFollow = await options.kv.get([ - "follower", - follower.id.href, - ]); - - // "pending" follower means this follower client requested subscription already. - if (existingFollow?.state === "pending") return; - - let subscriptionApproved = false; - - // Receive follow request from the relay client. - if (this.#subscriptionHandler) { - subscriptionApproved = await this.#subscriptionHandler( - ctx, - follower, - ); - } - - if (subscriptionApproved) { - // Add state pending - await options.kv.set( - ["follower", follower.id.href], - { "actor": await follower.toJsonLd(), "state": "pending" }, - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - - // Send reciprocal follow - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Follow({ - actor: relayActorUri, - object: follower.id, - to: follower.id, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Accept, async (ctx, accept) => { - // Validate follow activity from accept activity - const follow = await accept.getObject({ - crossOrigin: "trust", - ...ctx, + }, +) + .setKeyPairsDispatcher( + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return []; + + const rsaPairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "rsa", identifier]); + const ed25519PairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "ed25519", identifier]); + if (rsaPairJson == null || ed25519PairJson == null) { + const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + const ed25519Pair = await generateCryptoKeyPair("Ed25519"); + await ctx.data.kv.set(["keypair", "rsa", identifier], { + privateKey: await exportJwk(rsaPair.privateKey), + publicKey: await exportJwk(rsaPair.publicKey), }); - if (!(follow instanceof Follow)) return; - const follower = follow.actorId; - if (follower == null) return; - - // Validate following - accept activity sender - const following = await accept.getActor(); - if (!isActor(following) || !following.id) return; - const parsed = ctx.parseUri(follower); - if (parsed == null || parsed.type !== "actor") return; - - // Get follower from kv store - const followerData = await options.kv.get([ - "follower", - following.id.href, - ]); - if (followerData == null) return; - - // Update follower state - const updatedFollowerData = { ...followerData, state: "accepted" }; - await options.kv.set( - ["follower", following.id.href], - updatedFollowerData, - ); - - // Update followers list - const followers = await options.kv.get(["followers"]) ?? []; - followers.push(following.id.href); - await options.kv.set(["followers"], followers); - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject({ crossOrigin: "trust", ...ctx }); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const followers = await options.kv.get(["followers"]) ?? - []; // actor ids - - const updatedFollowers = followers.filter((id) => - id !== activity.actorId?.href - ); - await options.kv.set(["followers"], updatedFollowers); - options.kv.delete(["follower", activity.actorId?.href]); - } else { - console.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: create.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, update) => { - const sender = await update.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: update.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, move) => { - const sender = await move.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: move.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: deleteActivity.objectId, - to: PUBLIC_COLLECTION, + await ctx.data.kv.set(["keypair", "ed25519", identifier], { + privateKey: await exportJwk(ed25519Pair.privateKey), + publicKey: await exportJwk(ed25519Pair.publicKey), }); - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Announce, async (ctx, announceActivity) => { - const sender = await announceActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: announceActivity.objectId, - to: PUBLIC_COLLECTION, - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); - } - - get domain(): string { - return this.#options.domain || "localhost"; - } - - fetch(request: Request): Promise { - return this.#federation.fetch(request, { contextData: undefined }); - } - - setSubscriptionHandler(handler: SubscriptionRequestHandler): this { - this.#subscriptionHandler = handler; - return this; + return [rsaPair, ed25519Pair]; + } + + const rsaPair: CryptoKeyPair = { + privateKey: await importJwk(rsaPairJson.privateKey, "private"), + publicKey: await importJwk(rsaPairJson.publicKey, "public"), + }; + const ed25519Pair: CryptoKeyPair = { + privateKey: await importJwk(ed25519PairJson.privateKey, "private"), + publicKey: await importJwk(ed25519PairJson.publicKey, "public"), + }; + return [rsaPair, ed25519Pair]; + }, + ); + +relayBuilder.setFollowersDispatcher( + "/users/{identifier}/followers", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return { items: actors }; + }, +); + +relayBuilder.setFollowingDispatcher( + "/users/{identifier}/following", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return { items: actors }; + }, +); + +export function createRelay( + type: string, + options: RelayOptions, +): MastodonRelay { + switch (type) { + case "mastodon": + return new MastodonRelay(options, relayBuilder); + default: + throw new Error(`Unsupported relay type: ${type}`); } } From 10155f9852db6ab4bf1405afd60d35671254ed55 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:21:59 +0900 Subject: [PATCH 14/45] refactor(relay): add create factory function for mastodon and litepub --- packages/relay/src/mod.ts | 2 ++ packages/relay/src/relay.ts | 22 +++++++--------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 7c977c4ce..c8a6c27b1 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -11,9 +11,11 @@ // Export relay functionality here export { createRelay, + type LitePubFollower, RELAY_SERVER_ACTOR, type RelayOptions, type SubscriptionRequestHandler, } from "./relay.ts"; export { MastodonRelay } from "./mastodon.ts"; +export { LitePubRelay } from "./litepub.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 49e9ac8a6..826d7ece0 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -8,7 +8,7 @@ import { type MessageQueue, } from "@fedify/fedify"; import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; -import { MastodonRelay } from "@fedify/relay"; +import { LitePubRelay, MastodonRelay } from "@fedify/relay"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, @@ -36,17 +36,7 @@ export interface RelayOptions { subscriptionHandler?: SubscriptionRequestHandler; } -/** - * Base interface for ActivityPub relay implementations. - */ -export interface OldRelay { - readonly domain: string; - - fetch(request: Request): Promise; - setSubscriptionHandler(handler: SubscriptionRequestHandler): this; -} - -export interface Follower { +export interface LitePubFollower { readonly actor: unknown; readonly state: string; } @@ -119,7 +109,7 @@ relayBuilder.setFollowersDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); @@ -142,7 +132,7 @@ relayBuilder.setFollowingDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); @@ -158,10 +148,12 @@ relayBuilder.setFollowingDispatcher( export function createRelay( type: string, options: RelayOptions, -): MastodonRelay { +): MastodonRelay | LitePubRelay { switch (type) { case "mastodon": return new MastodonRelay(options, relayBuilder); + case "litepub": + return new LitePubRelay(options, relayBuilder); default: throw new Error(`Unsupported relay type: ${type}`); } From 900ea8d66c23dbe081262f3dd4b78f3f9b809e17 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:25:12 +0900 Subject: [PATCH 15/45] refactor(relay): add mastodon rela y and its test --- packages/relay/src/mastodon.test.ts | 784 ++++++++++++++++++++++++++++ packages/relay/src/mastodon.ts | 191 +++++++ 2 files changed, 975 insertions(+) create mode 100644 packages/relay/src/mastodon.test.ts create mode 100644 packages/relay/src/mastodon.ts diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts new file mode 100644 index 000000000..f7c77da9f --- /dev/null +++ b/packages/relay/src/mastodon.test.ts @@ -0,0 +1,784 @@ +// deno-lint-ignore-file no-explicit-any +import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { + Create, + Delete, + Follow, + Move, + Note, + Person, + Undo, + Update, +} from "@fedify/fedify/vocab"; +import { + exportSpki, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { ok, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { createRelay, type RelayOptions } from "@fedify/relay"; + +// Simple mock document loader that returns a minimal context +const mockDocumentLoader = async (url: string): Promise => { + if ( + url === "https://remote.example.com/users/alice" || + url === "https://remote.example.com/users/alice#main-key" + ) { + return { + contextUrl: null, + documentUrl: url.replace(/#main-key$/, ""), + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: url, + type: "Person", + preferredUsername: "alice", + inbox: "https://remote.example.com/users/alice/inbox", + publicKey: { + id: "https://remote.example.com/users/alice#main-key", + owner: url.replace(/#main-key$/, ""), + publicKeyPem: await exportSpki(rsaKeyPair.publicKey), + }, + }, + }; + } else if (url === "https://remote.example.com/notes/1") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Note", + content: "Hello world", + }, + }; + } else if (url.startsWith("https://remote.example.com/")) { + throw new Error(`Document not found: ${url}`); + } + return await getDocumentLoader()(url); +}; + +// Mock RSA key pair for testing +const rsaKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); + +const rsaPublicKey = { + id: new URL("https://remote.example.com/users/alice#main-key"), + ...rsaKeyPair.publicKey, +}; + +describe("MastodonRelay", () => { + test("constructor with required options", () => { + const options: RelayOptions = { + kv: new MemoryKvStore(), + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(true); + }, + }; + + const relay = createRelay("mastodon", options); + ok(relay); + }); + + test("fetch method returns Response", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + ok(response instanceof Response); + }); + + test("fetching relay actor returns Application", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + }); + + test("fetching non-relay actor returns 404", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/non-existent", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 404); + }); + + test("followers collection returns empty list initially", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + // The followers dispatcher is configured, verify response structure + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + }); + + test("followers collection returns populated list", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate followers + const follower1 = new Person({ + id: new URL("https://remote1.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote1.example.com/users/alice/inbox"), + }); + + const follower2 = new Person({ + id: new URL("https://remote2.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote2.example.com/users/bob/inbox"), + }); + + const follower1Id = "https://remote1.example.com/users/alice"; + const follower2Id = "https://remote2.example.com/users/bob"; + + await kv.set(["followers"], [follower1Id, follower2Id]); + await kv.set( + ["follower", follower1Id], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", follower2Id], + { actor: await follower2.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + // Fedify wraps the items in a collection, check totalItems if available + if (json.totalItems !== undefined) { + strictEqual(json.totalItems, 2); + } + }); + + test("stores follower in KV when Follow is approved", async () => { + const kv = new MemoryKvStore(); + + // Manually simulate what happens when a Follow is approved + const followActivityId = "https://remote.example.com/activities/follow/1"; + 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"), + }); + + // Simulate the relay's internal logic + const followers = (await kv.get(["followers"])) ?? []; + followers.push(followActivityId); + await kv.set(["followers"], followers); + await kv.set( + ["follower", followActivityId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + // Verify storage + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers?.length, 1); + strictEqual(storedFollowers[0], followActivityId); + + const storedActor = await kv.get(["follower", followActivityId]); + ok(storedActor); + }); + + test("removes follower from KV when Undo Follow is received", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + // Simulate the Undo Follow logic + const followers = (await kv.get(["followers"])) ?? []; + const updatedFollowers = followers.filter((id) => id !== followerId); + await kv.set(["followers"], updatedFollowers); + await kv.delete(["follower", followerId]); + + // Verify removal + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 0); + + const storedActor = await kv.get(["follower", followerId]); + strictEqual(storedActor, undefined); + }); + + test("relay actor has correct properties", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + strictEqual(json.id, "https://relay.example.com/users/relay"); + strictEqual(json.inbox, "https://relay.example.com/inbox"); + strictEqual( + json.followers, + "https://relay.example.com/users/relay/followers", + ); + strictEqual( + json.following, + "https://relay.example.com/users/relay/following", + ); + }); + + test("multiple followers can be stored", async () => { + const kv = new MemoryKvStore(); + + // Simulate multiple Follow activities + const followIds = [ + "https://remote1.example.com/users/user1", + "https://remote2.example.com/users/user2", + "https://remote3.example.com/users/user3", + ]; + + const followers: string[] = []; + for (const followId of followIds) { + followers.push(followId); + const actor = new Person({ + id: new URL(followId), + preferredUsername: `user${followers.length}`, + inbox: new URL(`${followId}/inbox`), + }); + await kv.set( + ["follower", followId], + { actor: await actor.toJsonLd(), state: "accepted" }, + ); + } + await kv.set(["followers"], followers); + + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 3); + }); + + test("handles Follow activity with subscription approval", async () => { + const kv = new MemoryKvStore(); + let handlerCalled = false; + let handlerActor: any = null; + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, actor) => { + handlerCalled = true; + handlerActor = actor; + 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"), + }); + + 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"), + }); + + 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); + + // Verify handler was called + strictEqual(handlerCalled, true); + ok(handlerActor); + + // Verify follower was stored + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + strictEqual( + followers[0], + "https://remote.example.com/activities/follow/1", + ); + }); + + test("handles Follow activity with subscription rejection", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(false); // Reject the subscription + }, + }); + + 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"), + }); + + 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"), + }); + + 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); + + // Verify follower was NOT stored + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles Undo Follow activity", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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 followActivityId = "https://remote.example.com/activities/follow/1"; + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const originalFollow = new Follow({ + id: new URL(followActivityId), + 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, + }); + + 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, + ); + + await relay.fetch(request); + + // Verify follower was removed + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 0); + }); + + test("handles Create activity forwarding", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL(followerId), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted (forwarding happens in background) + ok(response.status === 200 || response.status === 202); + }); + + test("handles Delete activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const deleteActivity = new Delete({ + id: new URL("https://remote.example.com/activities/delete/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 deleteActivity.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("handles Update activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Updated content", + }); + + const updateActivity = new Update({ + id: new URL("https://remote.example.com/activities/update/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await updateActivity.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("handles Move activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const moveActivity = new Move({ + id: new URL("https://remote.example.com/activities/move/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/users/alice"), + target: new URL("https://other.example.com/users/alice"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await moveActivity.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(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + // Follow activity without id + const followActivity = new Follow({ + actor: new URL("https://remote.example.com/users/alice"), + 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); + + // Verify follower was NOT stored + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles public Follow activity", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("mastodon", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => 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"), + }); + + // Public follow activity + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }); + + 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); + + // Verify follower was stored + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + }); +}); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts new file mode 100644 index 000000000..f20aa3df5 --- /dev/null +++ b/packages/relay/src/mastodon.ts @@ -0,0 +1,191 @@ +import { + Accept, + Create, + Delete, + type Federation, + Follow, + Move, + Reject, + Undo, + Update, +} from "@fedify/fedify"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "@fedify/relay"; +import type { FederationBuilder } from "@fedify/fedify/federation"; + +/** + * A Mastodon-compatible ActivityPub relay implementation. + * This relay follows Mastodon's relay protocol for maximum compatibility + * with Mastodon instances. + * + * @since 2.0.0 + */ +export class MastodonRelay { + #federationBuilder: FederationBuilder; + #options: RelayOptions; + #federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.#options = options; + this.#federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.#federation == null) { + this.#federation = await this.#federationBuilder.build(this.#options); + this.setupInboxListeners(); + } + + return await this.#federation.fetch(request, { + contextData: this.#options, + }); + } + + setupInboxListeners() { + if (this.#federation != null) { + this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.objectId == null) return; + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return; + + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return; + let approved = false; + + if (this.#options.subscriptionHandler) { + approved = await this.#options.subscriptionHandler( + ctx, + follower, + ); + } + + if (approved) { + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + followers.push(follow.id.href); + await ctx.data.kv.set(["followers"], followers); + + await ctx.data.kv.set( + ["follower", follow.id.href], + await follower.toJsonLd(), + ); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Accept({ + id: new URL(`#accepts`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + } else { + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Reject({ + id: new URL(`#rejects`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + } + }) + .on(Undo, async (ctx, undo) => { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + if (activity instanceof Follow) { + if ( + activity.id == null || + activity.actorId == null + ) return; + const followers = await ctx.data.kv.get(["followers"]) ?? + []; // actor ids + + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + await ctx.data.kv.set(["followers"], updatedFollowers); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + console.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } + }) + .on(Create, async (ctx, create) => { + const sender = await create.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Delete, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Move, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Update, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + // Exclude the sender's origin to prevent forwarding back to them + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }); + } + } +} From 9301739baea3fdf8637f007edc5bb7e020f268b9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:27:49 +0900 Subject: [PATCH 16/45] refactor(relay): add litepub relay and its test --- packages/relay/src/litepub.test.ts | 896 +++++++++++++++++++++++++++++ packages/relay/src/litepub.ts | 297 ++++++++++ 2 files changed, 1193 insertions(+) create mode 100644 packages/relay/src/litepub.test.ts create mode 100644 packages/relay/src/litepub.ts diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts new file mode 100644 index 000000000..34389d8c1 --- /dev/null +++ b/packages/relay/src/litepub.test.ts @@ -0,0 +1,896 @@ +// deno-lint-ignore-file no-explicit-any +import { MemoryKvStore, signRequest } from "@fedify/fedify"; +import { + Accept, + Announce, + Create, + Delete, + Follow, + Move, + Note, + Person, + Undo, + Update, +} from "@fedify/fedify/vocab"; +import { + exportSpki, + getDocumentLoader, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import { ok, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { createRelay, type RelayOptions } from "@fedify/relay"; + +// Simple mock document loader that returns a minimal context +const mockDocumentLoader = async (url: string): Promise => { + if ( + url === "https://remote.example.com/users/alice" || + url === "https://remote.example.com/users/alice#main-key" + ) { + return { + contextUrl: null, + documentUrl: url.replace(/#main-key$/, ""), + document: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: url, + type: "Person", + preferredUsername: "alice", + inbox: "https://remote.example.com/users/alice/inbox", + publicKey: { + id: "https://remote.example.com/users/alice#main-key", + owner: url.replace(/#main-key$/, ""), + publicKeyPem: await exportSpki(rsaKeyPair.publicKey), + }, + }, + }; + } else if (url === "https://remote.example.com/notes/1") { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Note", + content: "Hello world", + }, + }; + } else if (url.startsWith("https://remote.example.com/")) { + throw new Error(`Document not found: ${url}`); + } + return await getDocumentLoader()(url); +}; + +// Mock RSA key pair for testing +const rsaKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); + +const rsaPublicKey = { + id: new URL("https://remote.example.com/users/alice#main-key"), + ...rsaKeyPair.publicKey, +}; + +describe("LitePubRelay", () => { + test("constructor with required options", () => { + const options: RelayOptions = { + kv: new MemoryKvStore(), + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(true); + }, + }; + + const relay = createRelay("litepub", options); + ok(relay); + }); + + test("fetch method returns Response", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + ok(response instanceof Response); + }); + + test("fetching relay actor returns Application", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + }); + + test("fetching non-relay actor returns 404", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/non-existent", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 404); + }); + + test("followers collection returns empty list initially", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + }); + + test("followers collection returns populated list", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate followers + const follower1 = new Person({ + id: new URL("https://remote1.example.com/users/alice"), + preferredUsername: "alice", + inbox: new URL("https://remote1.example.com/users/alice/inbox"), + }); + + const follower2 = new Person({ + id: new URL("https://remote2.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://remote2.example.com/users/bob/inbox"), + }); + + const follower1Id = "https://remote1.example.com/users/alice"; + const follower2Id = "https://remote2.example.com/users/bob"; + + await kv.set(["followers"], [follower1Id, follower2Id]); + await kv.set( + ["follower", follower1Id], + { actor: await follower1.toJsonLd(), state: "accepted" }, + ); + await kv.set( + ["follower", follower2Id], + { actor: await follower2.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request( + "https://relay.example.com/users/relay/followers", + { + headers: { "Accept": "application/activity+json" }, + }, + ); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + ok(json); + ok(json.type === "Collection" || json.type === "OrderedCollection"); + if (json.totalItems !== undefined) { + strictEqual(json.totalItems, 2); + } + }); + + test("relay actor has correct properties", async () => { + const kv = new MemoryKvStore(); + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + }); + + const request = new Request("https://relay.example.com/users/relay", { + headers: { "Accept": "application/activity+json" }, + }); + const response = await relay.fetch(request); + + strictEqual(response.status, 200); + const json = await response.json() as any; + + strictEqual(json.type, "Application"); + strictEqual(json.preferredUsername, "relay"); + strictEqual(json.name, "ActivityPub Relay"); + strictEqual(json.id, "https://relay.example.com/users/relay"); + strictEqual(json.inbox, "https://relay.example.com/inbox"); + strictEqual( + json.followers, + "https://relay.example.com/users/relay/followers", + ); + strictEqual( + json.following, + "https://relay.example.com/users/relay/following", + ); + }); + + test("handles Follow activity with subscription approval", async () => { + const kv = new MemoryKvStore(); + let handlerCalled = false; + let handlerActor: any = null; + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, actor) => { + handlerCalled = true; + handlerActor = actor; + 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"), + }); + + 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"), + }); + + 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); + + // Verify handler was called + strictEqual(handlerCalled, true); + ok(handlerActor); + + // Verify follower was stored with "pending" state (awaiting reciprocal Accept) + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "pending"); + }); + + test("handles Follow activity with subscription rejection", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => { + return await Promise.resolve(false); // Reject the subscription + }, + }); + + 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"), + }); + + 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"), + }); + + 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); + + // Verify follower was NOT stored + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + strictEqual(followerData, undefined); + + // Verify followers list is empty + const followers = await kv.get(["followers"]); + ok(!followers || followers.length === 0); + }); + + test("handles public Follow activity", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => 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"), + }); + + // Public follow activity + const followActivity = new Follow({ + id: new URL("https://remote.example.com/activities/follow/1"), + actor: follower.id, + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }); + + 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); + + // Verify follower was stored with "pending" state + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "pending"); + }); + + test("ignores Follow activity without required fields", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: async (_ctx, _actor) => await Promise.resolve(true), + }); + + // Follow activity without id + const followActivity = new Follow({ + actor: new URL("https://remote.example.com/users/alice"), + 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); + + // Verify follower was NOT stored + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + strictEqual(followerData, undefined); + }); + + test("ignores duplicate Follow activity from pending follower", async () => { + const kv = new MemoryKvStore(); + let handlerCallCount = 0; + + const relay = createRelay("litepub", { + kv, + domain: "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"), + }); + + // Pre-populate with pending follower + await kv.set( + ["follower", "https://remote.example.com/users/alice"], + { actor: await follower.toJsonLd(), state: "pending" }, + ); + + 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"), + }); + + 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); + + // Verify handler was NOT called (duplicate follow ignored) + strictEqual(handlerCallCount, 0); + }); + + test("handles Accept activity completing reciprocal follow", async () => { + const kv = new MemoryKvStore(); + + 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"), + }); + + // 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, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + 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("https://remote.example.com/users/alice"), + }); + + const acceptActivity = new Accept({ + id: new URL("https://remote.example.com/activities/accept/1"), + actor: new URL("https://remote.example.com/users/alice"), + 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); + + // Verify follower state changed to "accepted" + const followerData = await kv.get([ + "follower", + "https://remote.example.com/users/alice", + ]); + ok(followerData); + strictEqual((followerData as any).state, "accepted"); + + // Verify follower was added to followers list + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 1); + strictEqual(followers[0], "https://remote.example.com/users/alice"); + }); + + test("handles Undo Follow activity", 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + 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, + }); + + 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, + ); + + await relay.fetch(request); + + // Verify follower was removed + const followers = await kv.get(["followers"]); + ok(followers); + strictEqual(followers.length, 0); + + const followerData = await kv.get(["follower", followerId]); + strictEqual(followerData, undefined); + }); + + test("handles Create activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + // Pre-populate with a 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"), + }); + + await kv.set(["followers"], [followerId]); + await kv.set( + ["follower", followerId], + { actor: await follower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL(followerId), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const response = await relay.fetch(request); + + // Verify the request was accepted (forwarding happens in background) + ok(response.status === 200 || response.status === 202); + }); + + test("handles Update activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const note = new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Updated content", + }); + + const updateActivity = new Update({ + id: new URL("https://remote.example.com/activities/update/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: note, + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await updateActivity.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("handles Move activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const moveActivity = new Move({ + id: new URL("https://remote.example.com/activities/move/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new URL("https://remote.example.com/users/alice"), + target: new URL("https://other.example.com/users/alice"), + }); + + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify( + await moveActivity.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("handles Delete activity with Announce forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + const deleteActivity = new Delete({ + id: new URL("https://remote.example.com/activities/delete/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 deleteActivity.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("handles Announce activity forwarding", async () => { + const kv = new MemoryKvStore(); + + const relay = createRelay("litepub", { + kv, + domain: "relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + }); + + 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("multiple followers can be stored", async () => { + const kv = new MemoryKvStore(); + + // Simulate multiple accepted followers + const followIds = [ + "https://remote1.example.com/users/user1", + "https://remote2.example.com/users/user2", + "https://remote3.example.com/users/user3", + ]; + + const followers: string[] = []; + for (const followId of followIds) { + followers.push(followId); + const actor = new Person({ + id: new URL(followId), + preferredUsername: `user${followers.length}`, + inbox: new URL(`${followId}/inbox`), + }); + await kv.set( + ["follower", followId], + { actor: await actor.toJsonLd(), state: "accepted" }, + ); + } + await kv.set(["followers"], followers); + + const storedFollowers = await kv.get(["followers"]); + ok(storedFollowers); + strictEqual(storedFollowers.length, 3); + }); +}); diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts new file mode 100644 index 000000000..6d8af1432 --- /dev/null +++ b/packages/relay/src/litepub.ts @@ -0,0 +1,297 @@ +import { + Accept, + Announce, + Create, + Delete, + type Federation, + type FederationBuilder, + Follow, + isActor, + Move, + PUBLIC_COLLECTION, + Reject, + Undo, + Update, +} from "@fedify/fedify"; +import { + type LitePubFollower, + RELAY_SERVER_ACTOR, + type RelayOptions, +} from "@fedify/relay"; + +/** + * A LitePub-compatible ActivityPub relay implementation. + * This relay follows LitePub's relay protocol and extensions for + * enhanced federation capabilities. + * + * @since 2.0.0 + */ +export class LitePubRelay { + #federationBuilder: FederationBuilder; + #options: RelayOptions; + #federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.#options = options; + this.#federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.#federation == null) { + this.#federation = await this.#federationBuilder.build(this.#options); + this.setupInboxListeners(); + } + + return await this.#federation.fetch(request, { + contextData: this.#options, + }); + } + + setupInboxListeners() { + if (this.#federation != null) { + this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Follow, async (ctx, follow) => { + if (follow.id == null || follow.objectId == null) return; + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return; + + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return; + + // Check if this is a follow from a client or if we already have a pending state + const existingFollow = await ctx.data.kv.get([ + "follower", + follower.id.href, + ]); + + // "pending" follower means this follower client requested subscription already. + if (existingFollow?.state === "pending") return; + + let subscriptionApproved = false; + + // Receive follow request from the relay client. + if (this.#options.subscriptionHandler) { + subscriptionApproved = await this.#options.subscriptionHandler( + ctx, + follower, + ); + } + + if (subscriptionApproved) { + await ctx.data.kv.set( + ["follower", follower.id.href], + { "actor": await follower.toJsonLd(), "state": "pending" }, + ); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Accept({ + id: new URL(`#accepts`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + + // Send reciprocal follow + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Follow({ + actor: relayActorUri, + object: follower.id, + to: follower.id, + }), + ); + } else { + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Reject({ + id: new URL(`#rejects`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); + } + }) + .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 follower = follow.actorId; + if (follower == null) return; + + // Validate following - accept activity sender + const following = await accept.getActor(); + if (!isActor(following) || !following.id) return; + const parsed = ctx.parseUri(follower); + if (parsed == null || parsed.type !== "actor") return; + + // Get follower from kv store + const followerData = await ctx.data.kv.get([ + "follower", + following.id.href, + ]); + if (followerData == null) return; + + // Update follower state + const updatedFollowerData = { ...followerData, state: "accepted" }; + await ctx.data.kv.set( + ["follower", following.id.href], + updatedFollowerData, + ); + + // Update followers list + const followers = await ctx.data.kv.get(["followers"]) ?? + []; + followers.push(following.id.href); + await ctx.data.kv.set(["followers"], followers); + }) + .on(Undo, async (ctx, undo) => { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + if (activity instanceof Follow) { + if ( + activity.id == null || + activity.actorId == null + ) return; + const followers = await ctx.data.kv.get(["followers"]) ?? + []; // actor ids + + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + await ctx.data.kv.set(["followers"], updatedFollowers); + ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + console.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } + }) + .on(Create, async (ctx, create) => { + const sender = await create.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: create.objectId, + to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Update, async (ctx, update) => { + const sender = await update.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: update.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Move, async (ctx, move) => { + const sender = await move.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: move.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Delete, async (ctx, deleteActivity) => { + const sender = await deleteActivity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: deleteActivity.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }) + .on(Announce, async (ctx, announceActivity) => { + const sender = await announceActivity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: announceActivity.objectId, + to: PUBLIC_COLLECTION, + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + }); + } + } +} From 079cb1a3f3e0dcf9c2538dcf9b61d795cb258797 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 01:30:37 +0900 Subject: [PATCH 17/45] refactor(relay): delete old relay test --- packages/relay/src/relay.test.ts | 1128 ------------------------------ 1 file changed, 1128 deletions(-) delete mode 100644 packages/relay/src/relay.test.ts diff --git a/packages/relay/src/relay.test.ts b/packages/relay/src/relay.test.ts deleted file mode 100644 index c9f76c9ac..000000000 --- a/packages/relay/src/relay.test.ts +++ /dev/null @@ -1,1128 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { ok, strictEqual } from "node:assert/strict"; -import { describe, test } from "node:test"; -import { MemoryKvStore } from "@fedify/fedify"; -import { Accept, Follow, Person } from "@fedify/fedify/vocab"; -import { signRequest } from "@fedify/fedify/sig"; -import { LitePubRelay, MastodonRelay, type RelayOptions } from "@fedify/relay"; -import { createFederation } from "@fedify/testing"; -import { - exportSpki, - getDocumentLoader, - type RemoteDocument, -} from "@fedify/vocab-runtime"; - -// Simple mock document loader that returns a minimal context -const mockDocumentLoader = async (url: string): Promise => { - if ( - url === "https://remote.example.com/users/alice" || - url === "https://remote.example.com/users/alice#main-key" - ) { - return { - contextUrl: null, - documentUrl: url.replace(/#main-key$/, ""), - document: { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], - id: url, - type: "Person", - preferredUsername: "alice", - inbox: "https://remote.example.com/users/alice/inbox", - publicKey: { - id: "https://remote.example.com/users/alice#main-key", - owner: url.replace(/#main-key$/, ""), - publicKeyPem: await exportSpki(rsaKeyPair.publicKey), - }, - }, - }; - } else if (url === "https://remote.example.com/notes/1") { - return { - contextUrl: null, - documentUrl: url, - document: { - "@context": "https://www.w3.org/ns/activitystreams", - id: url, - type: "Note", - content: "Hello world", - }, - }; - } else if (url.startsWith("https://remote.example.com/")) { - throw new Error(`Document not found: ${url}`); - } - return await getDocumentLoader()(url); -}; - -// Mock RSA key pair for testing -const rsaKeyPair = await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: "SHA-256", - }, - true, - ["sign", "verify"], -); - -const rsaPublicKey = { - id: new URL("https://remote.example.com/users/alice#main-key"), - ...rsaKeyPair.publicKey, -}; - -describe("MastodonRelay", () => { - test("constructor with required options", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }; - - const relay = new MastodonRelay(options); - strictEqual(relay.domain, "relay.example.com"); - }); - - test("creates relay with default domain", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - documentLoaderFactory: () => mockDocumentLoader, - }; - - const relay = new MastodonRelay(options); - strictEqual(relay.domain, "localhost"); - }); - - test("setSubscriptionHandler returns relay instance for chaining", () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const result = relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - strictEqual(result, relay); - }); - - test("fetch method returns Response", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - ok(response instanceof Response); - }); - - test("fetching relay actor returns Service", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - strictEqual(json.type, "Service"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - }); - - test("fetching non-relay actor returns 404", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/non-existent", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 404); - }); - - test("followers collection returns empty list initially", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - // The followers dispatcher is configured, verify response structure - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - }); - - test("followers collection returns populated list", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const followActivity1Id = "https://remote1.example.com/activities/follow/1"; - const followActivity2Id = "https://remote2.example.com/activities/follow/2"; - - await kv.set(["followers"], [followActivity1Id, followActivity2Id]); - await kv.set(["follower", followActivity1Id], follower1.toJsonLd()); - await kv.set(["follower", followActivity2Id], follower2.toJsonLd()); - - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - // Fedify wraps the items in a collection, check totalItems if available - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 2); - } - }); - - test("subscription handler is called on Follow activity", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCalled = false; - let handlerActor: unknown = null; - - relay.setSubscriptionHandler(async (_ctx, actor) => { - handlerCalled = true; - handlerActor = actor; - return await Promise.resolve(true); - }); - - // Create a Follow activity - 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"), - }); - - 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"), - }); - - // Sign and send the Follow activity to the relay's inbox - let request = new Request("https://relay.example.com/users/relay/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - const _response = await relay.fetch(request); - - strictEqual(handlerCalled, true); - ok(handlerActor); - }); - - test("stores follower in KV when Follow is approved", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(true); - }); - - // Manually simulate what happens when a Follow is approved - const followActivityId = "https://remote.example.com/activities/follow/1"; - 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"), - }); - - // Simulate the relay's internal logic - const followers = (await kv.get(["followers"])) ?? []; - followers.push(followActivityId); - await kv.set(["followers"], followers); - await kv.set(["follower", followActivityId], follower.toJsonLd()); - - // Verify storage - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers?.length, 1); - strictEqual(storedFollowers[0], followActivityId); - - const storedActor = await kv.get(["follower", followActivityId]); - ok(storedActor); - }); - - test("removes follower from KV when Undo Follow is received", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate with a follower - const followActivityId = "https://remote.example.com/activities/follow/1"; - 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(["followers"], [followActivityId]); - await kv.set(["follower", followActivityId], follower.toJsonLd()); - - const _relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - // Simulate the Undo Follow logic - const followers = (await kv.get(["followers"])) ?? []; - const updatedFollowers = followers.filter((id) => id !== followActivityId); - await kv.set(["followers"], updatedFollowers); - await kv.delete(["follower", followActivityId]); - - // Verify removal - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 0); - - const storedActor = await kv.get(["follower", followActivityId]); - strictEqual(storedActor, undefined); - }); - - test("does not store follower when Follow is rejected", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(false); - }); - - // Verify no followers are stored initially - const followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - }); - - test("relay actor has correct properties", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - - strictEqual(json.type, "Service"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - strictEqual( - json.summary, - "Mastodon-compatible ActivityPub relay server", - ); - strictEqual(json.id, "https://relay.example.com/users/relay"); - strictEqual(json.inbox, "https://relay.example.com/inbox"); - strictEqual( - json.followers, - "https://relay.example.com/users/relay/followers", - ); - }); - - test("multiple followers can be stored", async () => { - const kv = new MemoryKvStore(); - const relay = new MastodonRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - // Simulate multiple Follow activities - const followIds = [ - "https://remote1.example.com/activities/follow/1", - "https://remote2.example.com/activities/follow/2", - "https://remote3.example.com/activities/follow/3", - ]; - - const followers: string[] = []; - for (const followId of followIds) { - followers.push(followId); - const actor = new Person({ - id: new URL(followId.replace("/activities/follow/", "/users/user")), - preferredUsername: `user${followers.length}`, - inbox: new URL( - followId.replace("/activities/follow/", "/users/user") + "/inbox", - ), - }); - await kv.set(["follower", followId], actor.toJsonLd()); - } - await kv.set(["followers"], followers); - - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 3); - }); -}); - -describe("LitePubRelay", () => { - test("creates relay with required options", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }; - - const relay = new LitePubRelay(options); - strictEqual(relay.domain, "relay.example.com"); - }); - - test("creates relay with default domain", () => { - const options: RelayOptions = { - kv: new MemoryKvStore(), - documentLoaderFactory: () => mockDocumentLoader, - }; - - const relay = new LitePubRelay(options); - strictEqual(relay.domain, "localhost"); - }); - - test("setSubscriptionHandler returns relay instance for chaining", () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const result = relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - strictEqual(result, relay); - }); - - test("fetch method returns Response", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - ok(response instanceof Response); - }); - - test("fetching relay actor returns Application", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - strictEqual(json.type, "Application"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - }); - - test("fetching non-relay actor returns 404", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/non-existent", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 404); - }); - - test("followers collection returns empty list initially", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - }); - - test("followers collection returns populated list", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers with LitePub structure (actor + state) - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const follower1Id = "https://remote1.example.com/users/alice"; - const follower2Id = "https://remote2.example.com/users/bob"; - - // LitePub stores actor IDs in followers list and uses LitePubRelayFollower structure - await kv.set(["followers"], [follower1Id, follower2Id]); - await kv.set(["follower", follower1Id], { - actor: await follower1.toJsonLd(), - state: "accepted", - }); - await kv.set(["follower", follower2Id], { - actor: await follower2.toJsonLd(), - state: "accepted", - }); - - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - ok(json.type === "Collection" || json.type === "OrderedCollection"); - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 2); - } - }); - - test("subscription handler is called on Follow activity", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCalled = false; - let handlerActor: unknown = null; - - relay.setSubscriptionHandler(async (_ctx, actor) => { - handlerCalled = true; - handlerActor = actor; - return await Promise.resolve(true); - }); - - // Create a Follow activity - 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"), - }); - - 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"), - }); - - // Sign and send the Follow activity to the relay's inbox - let request = new Request("https://relay.example.com/users/relay/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - const _response = await relay.fetch(request); - - strictEqual(handlerCalled, true); - ok(handlerActor); - }); - - test("stores follower with pending state then accepted after two-step handshake", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - 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"), - }); - - const followerId = follower.id!.href; - - // Step 1: Simulate Follow received and stored with "pending" state - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Verify follower is in pending state - const followerData = await kv.get<{ actor: unknown; state: string }>([ - "follower", - followerId, - ]); - ok(followerData); - strictEqual(followerData.state, "pending"); - - // Verify follower is NOT in followers list yet - let followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - - // Step 2: Simulate Accept received - state changes to "accepted" - if (followerData) { - const updatedFollowerData = { ...followerData, state: "accepted" }; - await kv.set(["follower", followerId], updatedFollowerData); - } - - // Now add to followers list - followers = (await kv.get(["followers"])) ?? []; - followers.push(followerId); - await kv.set(["followers"], followers); - - // Verify final state - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 1); - strictEqual(storedFollowers[0], followerId); - - const storedFollowerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(storedFollowerData); - strictEqual(storedFollowerData.state, "accepted"); - }); - - test("removes follower from KV when Undo Follow is received", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate with a follower (LitePub uses actor ID as key) - 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"), - }); - - const followerId = follower.id!.href; - - await kv.set(["followers"], [followerId]); - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "accepted", - }); - - const _relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - // Simulate the Undo Follow logic (uses actor ID) - const followers = (await kv.get(["followers"])) ?? []; - const updatedFollowers = followers.filter((id) => id !== followerId); - await kv.set(["followers"], updatedFollowers); - await kv.delete(["follower", followerId]); - - // Verify removal - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 0); - - const storedActor = await kv.get(["follower", followerId]); - strictEqual(storedActor, undefined); - }); - - test("does not store follower when Follow is rejected", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => { - return await Promise.resolve(false); - }); - - // Verify no followers are stored initially - const followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - }); - - test("relay actor has correct properties", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - const request = new Request("https://relay.example.com/users/relay", { - headers: { "Accept": "application/activity+json" }, - }); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - - strictEqual(json.type, "Application"); - strictEqual(json.preferredUsername, "relay"); - strictEqual(json.name, "ActivityPub Relay"); - strictEqual( - json.summary, - "LitePub-compatible ActivityPub relay server", - ); - strictEqual(json.id, "https://relay.example.com/users/relay"); - strictEqual(json.inbox, "https://relay.example.com/inbox"); - strictEqual( - json.followers, - "https://relay.example.com/users/relay/followers", - ); - }); - - test("multiple followers can be stored", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - federation: createFederation({ contextData: undefined }), - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - await Promise.resolve(true) - ); - - // Simulate multiple Follow activities (LitePub uses actor IDs as keys) - const actorIds = [ - "https://remote1.example.com/users/user1", - "https://remote2.example.com/users/user2", - "https://remote3.example.com/users/user3", - ]; - - const followers: string[] = []; - for (let i = 0; i < actorIds.length; i++) { - const actorId = actorIds[i]; - followers.push(actorId); - const actor = new Person({ - id: new URL(actorId), - preferredUsername: `user${i + 1}`, - inbox: new URL(`${actorId}/inbox`), - }); - await kv.set(["follower", actorId], { - actor: await actor.toJsonLd(), - state: "accepted", - }); - } - await kv.set(["followers"], followers); - - const storedFollowers = await kv.get(["followers"]); - ok(storedFollowers); - strictEqual(storedFollowers.length, 3); - }); - - test("Accept handler updates follower state and adds to followers list", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - relay.setSubscriptionHandler(async (_ctx, _actor) => - 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"), - }); - - const followerId = follower.id!.href; - - // Pre-populate with pending follower (simulating initial Follow was processed) - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Verify not in followers list yet - let followers = await kv.get(["followers"]); - ok(!followers || followers.length === 0); - - // Create Accept activity from the client - const relayFollowActivity = new Follow({ - id: new URL("https://relay.example.com/activities/follow/1"), - actor: new URL("https://relay.example.com/users/relay"), - object: follower.id, - }); - - const acceptActivity = new Accept({ - id: new URL("https://remote.example.com/activities/accept/1"), - actor: follower.id, - object: relayFollowActivity, - }); - - // Sign and send the Accept activity to the relay's inbox - let request = new Request("https://relay.example.com/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await acceptActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - await relay.fetch(request); - - // Verify state changed to accepted - const updatedFollowerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(updatedFollowerData); - strictEqual(updatedFollowerData.state, "accepted"); - - // Verify added to followers list - followers = await kv.get(["followers"]); - ok(followers); - strictEqual(followers.length, 1); - strictEqual(followers[0], followerId); - }); - - test("following dispatcher returns same actors as followers", async () => { - const kv = new MemoryKvStore(); - - // Pre-populate followers with accepted state - const follower1 = new Person({ - id: new URL("https://remote1.example.com/users/alice"), - preferredUsername: "alice", - inbox: new URL("https://remote1.example.com/users/alice/inbox"), - }); - - const follower2 = new Person({ - id: new URL("https://remote2.example.com/users/bob"), - preferredUsername: "bob", - inbox: new URL("https://remote2.example.com/users/bob/inbox"), - }); - - const follower1Id = follower1.id!.href; - const follower2Id = follower2.id!.href; - - await kv.set(["followers"], [follower1Id, follower2Id]); - await kv.set(["follower", follower1Id], { - actor: await follower1.toJsonLd(), - state: "accepted", - }); - await kv.set(["follower", follower2Id], { - actor: await follower2.toJsonLd(), - state: "accepted", - }); - - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - // Fetch following collection - const followingRequest = new Request( - "https://relay.example.com/users/relay/following", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const followingResponse = await relay.fetch(followingRequest); - - strictEqual(followingResponse.status, 200); - const followingJson = await followingResponse.json() as any; - ok(followingJson); - ok( - followingJson.type === "Collection" || - followingJson.type === "OrderedCollection", - ); - - // Fetch followers collection - const followersRequest = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const followersResponse = await relay.fetch(followersRequest); - - strictEqual(followersResponse.status, 200); - const followersJson = await followersResponse.json() as any; - - // Verify both collections have same count - if (followingJson.totalItems !== undefined) { - strictEqual(followingJson.totalItems, 2); - strictEqual(followersJson.totalItems, 2); - } - }); - - test("pending followers are not in followers list", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - }); - - 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"), - }); - - const followerId = follower.id!.href; - - // Store follower with pending state - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Fetch followers collection - const request = new Request( - "https://relay.example.com/users/relay/followers", - { - headers: { "Accept": "application/activity+json" }, - }, - ); - const response = await relay.fetch(request); - - strictEqual(response.status, 200); - const json = await response.json() as any; - ok(json); - - // Verify pending follower is NOT in collection - if (json.totalItems !== undefined) { - strictEqual(json.totalItems, 0); - } - }); - - test("duplicate Follow is ignored when follower is pending", async () => { - const kv = new MemoryKvStore(); - const relay = new LitePubRelay({ - kv, - domain: "relay.example.com", - documentLoaderFactory: () => mockDocumentLoader, - authenticatedDocumentLoaderFactory: () => mockDocumentLoader, - }); - - let handlerCallCount = 0; - - relay.setSubscriptionHandler(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"), - }); - - const followerId = follower.id!.href; - - // Pre-populate with pending follower - await kv.set(["follower", followerId], { - actor: await follower.toJsonLd(), - state: "pending", - }); - - // Send another Follow activity from the same user - const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/2"), - actor: follower.id, - 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", - "Accept": "application/activity+json", - }, - body: JSON.stringify( - await followActivity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), - }); - - request = await signRequest( - request, - rsaKeyPair.privateKey, - rsaPublicKey.id!, - ); - - await relay.fetch(request); - - // Verify subscription handler was NOT called (duplicate was ignored) - strictEqual(handlerCallCount, 0); - - // Verify state is still pending - const followerData = await kv.get<{ actor: unknown; state: string }>( - ["follower", followerId], - ); - ok(followerData); - strictEqual(followerData.state, "pending"); - }); -}); From 6a3f9dc8dc0b473216ff95eee7e3353fc5feb177 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 23 Nov 2025 02:32:01 +0900 Subject: [PATCH 18/45] fix(relay): add temporal polyfill --- packages/relay/package.json | 3 +++ packages/relay/src/relay.ts | 3 ++- packages/relay/tsdown.config.ts | 12 ++++++++++++ pnpm-lock.yaml | 3 +++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/relay/package.json b/packages/relay/package.json index 801b6adc8..feee2e895 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -47,6 +47,9 @@ "dist/", "package.json" ], + "dependencies": { + "@js-temporal/polyfill": "catalog:" + }, "peerDependencies": { "@fedify/fedify": "workspace:^" }, diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 826d7ece0..e2c325eab 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -8,11 +8,12 @@ import { type MessageQueue, } from "@fedify/fedify"; import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; -import { LitePubRelay, MastodonRelay } from "@fedify/relay"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, } from "@fedify/vocab-runtime"; +import { LitePubRelay } from "./litepub.ts"; +import { MastodonRelay } from "./mastodon.ts"; export const RELAY_SERVER_ACTOR = "relay"; diff --git a/packages/relay/tsdown.config.ts b/packages/relay/tsdown.config.ts index 27a91233e..a89df939d 100644 --- a/packages/relay/tsdown.config.ts +++ b/packages/relay/tsdown.config.ts @@ -5,4 +5,16 @@ export default defineConfig({ dts: true, format: ["esm", "cjs"], platform: "node", + outputOptions(outputOptions, format) { + if (format === "cjs") { + outputOptions.intro = ` + const { Temporal } = require("@js-temporal/polyfill"); + `; + } else { + outputOptions.intro = ` + import { Temporal } from "@js-temporal/polyfill"; + `; + } + return outputOptions; + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 218525179..a951f8219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,6 +1073,9 @@ importers: '@fedify/fedify': specifier: workspace:^ version: link:../fedify + '@js-temporal/polyfill': + specifier: 'catalog:' + version: 0.5.1 devDependencies: '@fedify/testing': specifier: workspace:^ From efa92db7215b24808fb88435511c221272f77289 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Nov 2025 15:00:28 +0900 Subject: [PATCH 19/45] refactor(relay): fix import --- packages/relay/src/litepub.ts | 2 +- packages/relay/src/mastodon.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 6d8af1432..2ce7eee76 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -17,7 +17,7 @@ import { type LitePubFollower, RELAY_SERVER_ACTOR, type RelayOptions, -} from "@fedify/relay"; +} from "./relay.ts"; /** * A LitePub-compatible ActivityPub relay implementation. diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index f20aa3df5..5996d2983 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -9,7 +9,7 @@ import { Undo, Update, } from "@fedify/fedify"; -import { RELAY_SERVER_ACTOR, type RelayOptions } from "@fedify/relay"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; import type { FederationBuilder } from "@fedify/fedify/federation"; /** From 8283800d6b353af894be9301d4b2817c3f0db7d8 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Nov 2025 15:19:08 +0900 Subject: [PATCH 20/45] fix(relay): add changed data model when mastodon save follower to kv - add actor and state --- packages/relay/src/mastodon.test.ts | 2 +- packages/relay/src/mastodon.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index f7c77da9f..13544a204 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -404,7 +404,7 @@ describe("MastodonRelay", () => { strictEqual(followers.length, 1); strictEqual( followers[0], - "https://remote.example.com/activities/follow/1", + "https://remote.example.com/users/alice", ); }); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 5996d2983..1eb3f5c3e 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -72,12 +72,12 @@ export class MastodonRelay { if (approved) { const followers = await ctx.data.kv.get(["followers"]) ?? []; - followers.push(follow.id.href); + followers.push(follower.id.href); await ctx.data.kv.set(["followers"], followers); await ctx.data.kv.set( - ["follower", follow.id.href], - await follower.toJsonLd(), + ["follower", follower.id.href], + { "actor": await follower.toJsonLd(), "state": "accepted" }, ); await ctx.sendActivity( From 5cd2a6db9008c16d1de4e799033dbdd9be6d2979 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Nov 2025 15:37:33 +0900 Subject: [PATCH 21/45] refactor(relay): change type name from LitePubFollower to RelayFollower --- packages/relay/src/litepub.ts | 4 ++-- packages/relay/src/mod.ts | 2 +- packages/relay/src/relay.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 2ce7eee76..91a023cc1 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -14,8 +14,8 @@ import { Update, } from "@fedify/fedify"; import { - type LitePubFollower, RELAY_SERVER_ACTOR, + type RelayFollower, type RelayOptions, } from "./relay.ts"; @@ -69,7 +69,7 @@ export class LitePubRelay { ) return; // Check if this is a follow from a client or if we already have a 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/mod.ts b/packages/relay/src/mod.ts index c8a6c27b1..bed83df89 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -11,8 +11,8 @@ // Export relay functionality here export { createRelay, - type LitePubFollower, RELAY_SERVER_ACTOR, + type RelayFollower, type RelayOptions, type SubscriptionRequestHandler, } from "./relay.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index e2c325eab..3a3a8f3bd 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -37,7 +37,7 @@ export interface RelayOptions { subscriptionHandler?: SubscriptionRequestHandler; } -export interface LitePubFollower { +export interface RelayFollower { readonly actor: unknown; readonly state: string; } @@ -110,7 +110,7 @@ relayBuilder.setFollowersDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); @@ -133,7 +133,7 @@ relayBuilder.setFollowingDispatcher( const actors: Actor[] = []; for (const followerId of followers) { - const follower = await ctx.data.kv.get([ + const follower = await ctx.data.kv.get([ "follower", followerId, ]); From 5fc79c40607561c3250f2d840fd66b1ab709854b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 19 Dec 2025 18:23:02 +0900 Subject: [PATCH 22/45] refactor(relay): sync with upstream --- packages/relay/src/litepub.ts | 5 ++- packages/relay/src/mastodon.ts | 12 +++--- packages/relay/src/relay.ts | 69 +++++++++++++++++----------------- pnpm-lock.yaml | 16 ++++++++ 4 files changed, 60 insertions(+), 42 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 91a023cc1..51d19a027 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -14,6 +14,7 @@ import { Update, } from "@fedify/fedify"; import { + type Relay, RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, @@ -26,7 +27,7 @@ import { * * @since 2.0.0 */ -export class LitePubRelay { +export class LitePubRelay implements Relay { #federationBuilder: FederationBuilder; #options: RelayOptions; #federation?: Federation; @@ -178,7 +179,7 @@ export class LitePubRelay { id !== activity.actorId?.href ); await ctx.data.kv.set(["followers"], updatedFollowers); - ctx.data.kv.delete(["follower", activity.actorId?.href]); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); } else { console.warn( "Unsupported object type ({type}) for Undo activity: {object}", diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 1eb3f5c3e..519f09c7c 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -9,7 +9,7 @@ import { Undo, Update, } from "@fedify/fedify"; -import { RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; +import { type Relay, RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; import type { FederationBuilder } from "@fedify/fedify/federation"; /** @@ -19,7 +19,7 @@ import type { FederationBuilder } from "@fedify/fedify/federation"; * * @since 2.0.0 */ -export class MastodonRelay { +export class MastodonRelay implements Relay { #federationBuilder: FederationBuilder; #options: RelayOptions; #federation?: Federation; @@ -156,8 +156,8 @@ export class MastodonRelay { }, ); }) - .on(Move, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); + .on(Move, async (ctx, move) => { + const sender = await move.getActor(ctx); // Exclude the sender's origin to prevent forwarding back to them const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; @@ -171,8 +171,8 @@ export class MastodonRelay { }, ); }) - .on(Update, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); + .on(Update, async (ctx, update) => { + const sender = await update.getActor(ctx); // Exclude the sender's origin to prevent forwarding back to them const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 3a3a8f3bd..119103e00 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -17,6 +17,18 @@ import { MastodonRelay } from "./mastodon.ts"; export const RELAY_SERVER_ACTOR = "relay"; +/** + * Supported relay types. + */ +export type RelayType = "mastodon" | "litepub"; + +/** + * Common interface for all relay implementations. + */ +export interface Relay { + fetch(request: Request): Promise; +} + /** * Handler for subscription requests (Follow/Undo activities). */ @@ -100,25 +112,30 @@ relayBuilder.setActorDispatcher( }, ); +async function getFollowerActors( + ctx: Context, +): Promise { + const followers = await ctx.data.kv.get(["followers"]) ?? []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return actors; +} + relayBuilder.setFollowersDispatcher( "/users/{identifier}/followers", async (ctx, identifier) => { if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await ctx.data.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await ctx.data.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } + const actors = await getFollowerActors(ctx); return { items: actors }; }, ); @@ -127,35 +144,19 @@ relayBuilder.setFollowingDispatcher( "/users/{identifier}/following", async (ctx, identifier) => { if (identifier !== RELAY_SERVER_ACTOR) return null; - - const followers = await ctx.data.kv.get(["followers"]) ?? - []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await ctx.data.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } + const actors = await getFollowerActors(ctx); return { items: actors }; }, ); export function createRelay( - type: string, + type: RelayType, options: RelayOptions, -): MastodonRelay | LitePubRelay { +): Relay { switch (type) { case "mastodon": return new MastodonRelay(options, relayBuilder); case "litepub": return new LitePubRelay(options, relayBuilder); - default: - throw new Error(`Unsupported relay type: ${type}`); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a951f8219..29036a911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6725,6 +6725,7 @@ packages: next@14.2.30: resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -12987,6 +12988,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.1 + eslint: 9.32.0(jiti@2.5.1) + get-tsconfig: 4.10.1 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.14 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 From 6349c999c866f0a0d9713911e295f13414d22679 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Dec 2025 14:46:45 +0900 Subject: [PATCH 23/45] Add changes in the document --- CHANGES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 11c3552b5..266b56be5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -138,7 +138,7 @@ To be released. ### @fedify/relay - Created ActivityPub relay integration as the *@fedify/relay* package. - [[#359], [#459], [#471] by Jiwon Kwon] + [[#359], [#459], [#471], [#490] by Jiwon Kwon] - Added `Relay` interface defining the common contract for relay implementations. @@ -149,10 +149,13 @@ To be released. - Added `SubscriptionRequestHandler` type for custom subscription approval logic. - Added `RelayOptions` interface for relay configuration. + - Added `RelayType` type alias to document the type-safe parameter + - Added `createRelay()` factory function as a key public API [#359]: https://github.com/fedify-dev/fedify/issues/359 [#459]: https://github.com/fedify-dev/fedify/pull/459 [#471]: https://github.com/fedify-dev/fedify/pull/471 +[#490]: https://github.com/fedify-dev/fedify/pull/490 ### @fedify/vocab-tools From 69e0525625742fb8e45d36d751e7f17ecc751d45 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Dec 2025 14:55:25 +0900 Subject: [PATCH 24/45] Add missing published field in Activity types --- packages/relay/src/litepub.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 51d19a027..9b327773f 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -218,6 +218,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: update.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( @@ -239,6 +240,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: move.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( @@ -260,6 +262,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: deleteActivity.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( @@ -281,6 +284,7 @@ export class LitePubRelay implements Relay { actor: ctx.getActorUri(RELAY_SERVER_ACTOR), object: announceActivity.objectId, to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), }); await ctx.sendActivity( From 99a396d591479994b24dacbf67abe9565ea02268 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 19 Dec 2025 19:29:23 +0900 Subject: [PATCH 25/45] refactor(relay): clarify variable names in Accept handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed confusing variables in the LitePub relay's Accept handler: - `follower` → `relayActorId` (the relay's actor ID) - `following` → `followerActor` (the client actor following the relay) This improves code readability and makes the logic clearer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- packages/relay/src/litepub.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 9b327773f..cd8d8452b 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -133,33 +133,33 @@ export class LitePubRelay implements Relay { ...ctx, }); if (!(follow instanceof Follow)) return; - const follower = follow.actorId; - if (follower == null) return; + const relayActorId = follow.actorId; + if (relayActorId == null) return; - // Validate following - accept activity sender - const following = await accept.getActor(); - if (!isActor(following) || !following.id) return; - const parsed = ctx.parseUri(follower); + // 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", - following.id.href, + followerActor.id.href, ]); if (followerData == null) return; // Update follower state const updatedFollowerData = { ...followerData, state: "accepted" }; await ctx.data.kv.set( - ["follower", following.id.href], + ["follower", followerActor.id.href], updatedFollowerData, ); // Update followers list const followers = await ctx.data.kv.get(["followers"]) ?? []; - followers.push(following.id.href); + followers.push(followerActor.id.href); await ctx.data.kv.set(["followers"], followers); }) .on(Undo, async (ctx, undo) => { From 80858b6fe9b807e06baa935b268728a9102052dd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 15:14:49 +0900 Subject: [PATCH 26/45] Address PR #490 review feedback - simple fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses several review comments from PR #490: 1. Type safety: Changed RelayFollower.state from string to "pending" | "accepted" union type 2. Configurable name: Added optional name field to RelayOptions (defaults to "ActivityPub Relay") 3. Logging: Replaced console.warn with LogTape logger in both Mastodon and LitePub relays 4. Exports: Added Relay interface and RelayType to mod.ts exports for better type annotations 5. Cleanup: Removed inappropriate test:cfworkers script from package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- packages/relay/package.json | 3 +-- packages/relay/src/litepub.ts | 5 ++++- packages/relay/src/mastodon.ts | 5 ++++- packages/relay/src/mod.ts | 2 ++ packages/relay/src/relay.ts | 5 +++-- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/relay/package.json b/packages/relay/package.json index feee2e895..d66111408 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -64,7 +64,6 @@ "prepack": "deno task codegen && tsdown", "prepublish": "deno task codegen && tsdown", "test": "deno task codegen && tsdown && node --test", - "test:bun": "deno task codegen && tsdown && bun test --timeout 60000", - "test:cfworkers": "deno task codegen && wrangler deploy --dry-run --outdir src/cfworkers && node --import=tsx src/cfworkers/client.ts" + "test:bun": "deno task codegen && tsdown && bun test --timeout 60000" } } diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index cd8d8452b..a2ca75239 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -13,6 +13,7 @@ import { Undo, Update, } from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; import { type Relay, RELAY_SERVER_ACTOR, @@ -20,6 +21,8 @@ import { type RelayOptions, } from "./relay.ts"; +const logger = getLogger(["fedify", "relay", "litepub"]); + /** * A LitePub-compatible ActivityPub relay implementation. * This relay follows LitePub's relay protocol and extensions for @@ -181,7 +184,7 @@ export class LitePubRelay implements Relay { await ctx.data.kv.set(["followers"], updatedFollowers); await ctx.data.kv.delete(["follower", activity.actorId?.href]); } else { - console.warn( + logger.warn( "Unsupported object type ({type}) for Undo activity: {object}", { type: activity?.constructor.name, object: activity }, ); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 519f09c7c..54b422edf 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -9,9 +9,12 @@ import { Undo, Update, } from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; import { type Relay, RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; import type { FederationBuilder } from "@fedify/fedify/federation"; +const logger = getLogger(["fedify", "relay", "mastodon"]); + /** * A Mastodon-compatible ActivityPub relay implementation. * This relay follows Mastodon's relay protocol for maximum compatibility @@ -120,7 +123,7 @@ export class MastodonRelay implements Relay { await ctx.data.kv.set(["followers"], updatedFollowers); await ctx.data.kv.delete(["follower", activity.actorId?.href]); } else { - console.warn( + logger.warn( "Unsupported object type ({type}) for Undo activity: {object}", { type: activity?.constructor.name, object: activity }, ); diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index bed83df89..5f8113e58 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -11,9 +11,11 @@ // Export relay functionality here export { createRelay, + type Relay, RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, + type RelayType, type SubscriptionRequestHandler, } from "./relay.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 119103e00..4228669d9 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -43,6 +43,7 @@ export type SubscriptionRequestHandler = ( export interface RelayOptions { kv: KvStore; domain?: string; + name?: string; documentLoaderFactory?: DocumentLoaderFactory; authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; queue?: MessageQueue; @@ -51,7 +52,7 @@ export interface RelayOptions { export interface RelayFollower { readonly actor: unknown; - readonly state: string; + readonly state: "pending" | "accepted"; } export const relayBuilder = createFederationBuilder(); @@ -64,7 +65,7 @@ relayBuilder.setActorDispatcher( return new Application({ id: ctx.getActorUri(identifier), preferredUsername: identifier, - name: "ActivityPub Relay", + name: ctx.data.name ?? "ActivityPub Relay", inbox: ctx.getInboxUri(), // This should be sharedInboxUri followers: ctx.getFollowersUri(identifier), following: ctx.getFollowingUri(identifier), From 14807f8889382d0a932d0fb98e9882e0e33be5f8 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 18:04:01 +0900 Subject: [PATCH 27/45] Convert duplicated logic in both LitePub and Mastodon into helper functions --- packages/relay/src/relay.ts | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 4228669d9..c50bd37ae 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -1,13 +1,18 @@ import { + Accept, type Context, createFederationBuilder, exportJwk, + Follow, generateCryptoKeyPair, importJwk, type KvStore, type MessageQueue, + Reject, + type Undo, } from "@fedify/fedify"; import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; +import type { getLogger } from "@logtape/logtape"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, @@ -132,6 +137,101 @@ async function getFollowerActors( return actors; } +/** + * Validate Follow activity and return follower actor if valid. + * This validation is common to both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param follow The Follow activity to validate + * @returns The follower Actor if valid, null otherwise + */ +export async function validateFollowActivity( + ctx: Context, + follow: Follow, +): Promise { + if (follow.id == null || follow.objectId == null) return null; + + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return null; + + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return null; + + return follower; +} + +/** + * Send Accept or Reject response for a Follow activity. + * This is common to both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param follow The Follow activity being responded to + * @param follower The actor who sent the Follow + * @param approved Whether the follow was approved + */ +export async function sendFollowResponse( + ctx: Context, + follow: Follow, + follower: Actor, + approved: boolean, +): Promise { + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const Activity = approved ? Accept : Reject; + const action = approved ? "accepts" : "rejects"; + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Activity({ + id: new URL(`#${action}`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); +} + +/** + * Handle Undo activity for Follow. + * This logic is identical for both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param undo The Undo activity to handle + * @param logger The logger instance to use for warnings + */ +export async function handleUndoFollow( + ctx: Context, + undo: Undo, + logger: ReturnType, +): Promise { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + + if (activity instanceof Follow) { + if (activity.id == null || activity.actorId == null) return; + + const followers = await ctx.data.kv.get(["followers"]) ?? []; + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + + await ctx.data.kv.set(["followers"], updatedFollowers); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + logger.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } +} + relayBuilder.setFollowersDispatcher( "/users/{identifier}/followers", async (ctx, identifier) => { From 01894b145e508ef697cbadbd122caa8190d759f1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 18:11:15 +0900 Subject: [PATCH 28/45] Add forward activity helper function --- packages/relay/src/mastodon.ts | 167 +++++++++------------------------ 1 file changed, 42 insertions(+), 125 deletions(-) diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 54b422edf..ce2f4fa61 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -1,16 +1,22 @@ import { - Accept, Create, Delete, type Federation, Follow, + type InboxContext, Move, - Reject, Undo, Update, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; -import { type Relay, RELAY_SERVER_ACTOR, type RelayOptions } from "./relay.ts"; +import { + handleUndoFollow, + type Relay, + RELAY_SERVER_ACTOR, + type RelayOptions, + sendFollowResponse, + validateFollowActivity, +} from "./relay.ts"; import type { FederationBuilder } from "@fedify/fedify/federation"; const logger = getLogger(["fedify", "relay", "mastodon"]); @@ -46,33 +52,42 @@ export class MastodonRelay implements Relay { }); } + /** + * Forward activity to all followers (mastodon-specific pattern). + * Used for Create, Delete, Move, and Update activities. + */ + async #forwardToFollowers( + ctx: InboxContext, + activity: Create | Delete | Move | Update, + ): Promise { + const sender = await activity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + await ctx.forwardActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + { + skipIfUnsigned: true, + excludeBaseUris, + preferSharedInbox: true, + }, + ); + } + setupInboxListeners() { if (this.#federation != null) { this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; + const follower = await validateFollowActivity(ctx, follow); + if (!follower || !follower.id) return; - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; let approved = false; - if (this.#options.subscriptionHandler) { - approved = await this.#options.subscriptionHandler( - ctx, - follower, - ); + approved = await this.#options.subscriptionHandler(ctx, follower); } if (approved) { + // mastodon-specific: immediately add to followers list const followers = await ctx.data.kv.get(["followers"]) ?? []; followers.push(follower.id.href); @@ -80,115 +95,17 @@ export class MastodonRelay implements Relay { await ctx.data.kv.set( ["follower", follower.id.href], - { "actor": await follower.toJsonLd(), "state": "accepted" }, - ); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); - } - }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject({ - crossOrigin: "trust", - ...ctx, - }); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const followers = await ctx.data.kv.get(["followers"]) ?? - []; // actor ids - - const updatedFollowers = followers.filter((id) => - id !== activity.actorId?.href - ); - await ctx.data.kv.set(["followers"], updatedFollowers); - await ctx.data.kv.delete(["follower", activity.actorId?.href]); - } else { - logger.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, + { actor: await follower.toJsonLd(), state: "accepted" }, ); } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); + await sendFollowResponse(ctx, follow, follower, approved); }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, move) => { - const sender = await move.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, update) => { - const sender = await update.getActor(ctx); - // Exclude the sender's origin to prevent forwarding back to them - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - await ctx.forwardActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - { - skipIfUnsigned: true, - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); + .on(Undo, (ctx, undo) => handleUndoFollow(ctx, undo, logger)) + .on(Create, (ctx, create) => this.#forwardToFollowers(ctx, create)) + .on(Delete, (ctx, del) => this.#forwardToFollowers(ctx, del)) + .on(Move, (ctx, move) => this.#forwardToFollowers(ctx, move)) + .on(Update, (ctx, update) => this.#forwardToFollowers(ctx, update)); } } } From f63fd714d8d41ea1fa9f010bb9f76d70ac0d0532 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 18:13:21 +0900 Subject: [PATCH 29/45] Add Announce inbox listener and async on methods --- packages/relay/src/mastodon.ts | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index ce2f4fa61..94a94b374 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -1,4 +1,5 @@ import { + Announce, Create, Delete, type Federation, @@ -58,7 +59,7 @@ export class MastodonRelay implements Relay { */ async #forwardToFollowers( ctx: InboxContext, - activity: Create | Delete | Move | Update, + activity: Create | Delete | Move | Update | Announce, ): Promise { const sender = await activity.getActor(ctx); const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; @@ -101,11 +102,32 @@ export class MastodonRelay implements Relay { await sendFollowResponse(ctx, follow, follower, approved); }) - .on(Undo, (ctx, undo) => handleUndoFollow(ctx, undo, logger)) - .on(Create, (ctx, create) => this.#forwardToFollowers(ctx, create)) - .on(Delete, (ctx, del) => this.#forwardToFollowers(ctx, del)) - .on(Move, (ctx, move) => this.#forwardToFollowers(ctx, move)) - .on(Update, (ctx, update) => this.#forwardToFollowers(ctx, update)); + .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), + ); } } } From 564f5b4348ce83e7a2d01f6e8f562b8de7b6ce23 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 18:27:12 +0900 Subject: [PATCH 30/45] Add helper functions of announce activities --- packages/relay/src/litepub.ts | 248 ++++++++++------------------------ 1 file changed, 68 insertions(+), 180 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index a2ca75239..87cf666d1 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -6,19 +6,22 @@ import { type Federation, type FederationBuilder, Follow, + type InboxContext, isActor, Move, PUBLIC_COLLECTION, - Reject, Undo, Update, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { + handleUndoFollow, type Relay, RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, + sendFollowResponse, + validateFollowActivity, } from "./relay.ts"; const logger = getLogger(["fedify", "relay", "litepub"]); @@ -54,60 +57,62 @@ export class LitePubRelay implements Relay { }); } + async #announceToFollowers( + ctx: InboxContext, + activity: Create | Delete | Move | Update | Announce, + ): Promise { + const sender = await activity.getActor(ctx); + const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; + + const announce = new Announce({ + id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), + actor: ctx.getActorUri(RELAY_SERVER_ACTOR), + object: activity.objectId, + to: PUBLIC_COLLECTION, + published: Temporal.Now.instant(), + }); + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + "followers", + announce, + { + excludeBaseUris, + preferSharedInbox: true, + }, + ); + } + setupInboxListeners() { if (this.#federation != null) { this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - if (follow.id == null || follow.objectId == null) return; - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return; + const follower = await validateFollowActivity(ctx, follow); + if (!follower || !follower.id) return; - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return; - - // Check if this is a follow from a client or if we already have a pending state + // Litepub-specific: check if already in pending state const existingFollow = await ctx.data.kv.get([ "follower", follower.id.href, ]); - - // "pending" follower means this follower client requested subscription already. if (existingFollow?.state === "pending") return; - let subscriptionApproved = false; - - // Receive follow request from the relay client. + let approved = false; if (this.#options.subscriptionHandler) { - subscriptionApproved = await this.#options.subscriptionHandler( - ctx, - follower, - ); + approved = await this.#options.subscriptionHandler(ctx, follower); } - if (subscriptionApproved) { + if (approved) { + // Litepub-specific: save with "pending" state await ctx.data.kv.set( ["follower", follower.id.href], - { "actor": await follower.toJsonLd(), "state": "pending" }, + { actor: await follower.toJsonLd(), state: "pending" }, ); - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Accept({ - id: new URL(`#accepts`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); + await sendFollowResponse(ctx, follow, follower, approved); - // Send reciprocal follow + // Litepub-specific: send reciprocal follow + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); await ctx.sendActivity( { identifier: RELAY_SERVER_ACTOR }, follower, @@ -118,15 +123,7 @@ export class LitePubRelay implements Relay { }), ); } else { - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Reject({ - id: new URL(`#rejects`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); + await sendFollowResponse(ctx, follow, follower, approved); } }) .on(Accept, async (ctx, accept) => { @@ -165,141 +162,32 @@ export class LitePubRelay implements Relay { followers.push(followerActor.id.href); await ctx.data.kv.set(["followers"], followers); }) - .on(Undo, async (ctx, undo) => { - const activity = await undo.getObject({ - crossOrigin: "trust", - ...ctx, - }); - if (activity instanceof Follow) { - if ( - activity.id == null || - activity.actorId == null - ) return; - const followers = await ctx.data.kv.get(["followers"]) ?? - []; // actor ids - - const updatedFollowers = followers.filter((id) => - id !== activity.actorId?.href - ); - await ctx.data.kv.set(["followers"], updatedFollowers); - await ctx.data.kv.delete(["follower", activity.actorId?.href]); - } else { - logger.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } - }) - .on(Create, async (ctx, create) => { - const sender = await create.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: create.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Update, async (ctx, update) => { - const sender = await update.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: update.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Move, async (ctx, move) => { - const sender = await move.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: move.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Delete, async (ctx, deleteActivity) => { - const sender = await deleteActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: deleteActivity.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }) - .on(Announce, async (ctx, announceActivity) => { - const sender = await announceActivity.getActor(ctx); - const excludeBaseUris = sender?.id ? [new URL(sender.id)] : []; - - const announce = new Announce({ - id: new URL(`/announce#${crypto.randomUUID()}`, ctx.origin), - actor: ctx.getActorUri(RELAY_SERVER_ACTOR), - object: announceActivity.objectId, - to: PUBLIC_COLLECTION, - published: Temporal.Now.instant(), - }); - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - "followers", - announce, - { - excludeBaseUris, - preferSharedInbox: true, - }, - ); - }); + .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), + ); } } } From c8a9c769707b1f13a06782657fdec0c6b5a77b24 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 22:12:22 +0900 Subject: [PATCH 31/45] Implement abstract BaseRelay Class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- packages/relay/package.json | 3 +- packages/relay/src/litepub.ts | 39 +++++------------------ packages/relay/src/mastodon.ts | 39 +++++------------------ packages/relay/src/mod.ts | 40 +++++++++++++++++++++++- packages/relay/src/relay.ts | 56 +++++++++++++++++++++++++--------- pnpm-lock.yaml | 3 ++ 6 files changed, 99 insertions(+), 81 deletions(-) diff --git a/packages/relay/package.json b/packages/relay/package.json index d66111408..a96af7f30 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -48,7 +48,8 @@ "package.json" ], "dependencies": { - "@js-temporal/polyfill": "catalog:" + "@js-temporal/polyfill": "catalog:", + "@logtape/logtape": "catalog:" }, "peerDependencies": { "@fedify/fedify": "workspace:^" diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index 87cf666d1..d584766ce 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -3,8 +3,6 @@ import { Announce, Create, Delete, - type Federation, - type FederationBuilder, Follow, type InboxContext, isActor, @@ -15,8 +13,8 @@ import { } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { + BaseRelay, handleUndoFollow, - type Relay, RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, @@ -33,30 +31,7 @@ const logger = getLogger(["fedify", "relay", "litepub"]); * * @since 2.0.0 */ -export class LitePubRelay implements Relay { - #federationBuilder: FederationBuilder; - #options: RelayOptions; - #federation?: Federation; - - constructor( - options: RelayOptions, - relayBuilder: FederationBuilder, - ) { - this.#options = options; - this.#federationBuilder = relayBuilder; - } - - async fetch(request: Request): Promise { - if (this.#federation == null) { - this.#federation = await this.#federationBuilder.build(this.#options); - this.setupInboxListeners(); - } - - return await this.#federation.fetch(request, { - contextData: this.#options, - }); - } - +export class LitePubRelay extends BaseRelay { async #announceToFollowers( ctx: InboxContext, activity: Create | Delete | Move | Update | Announce, @@ -83,9 +58,9 @@ export class LitePubRelay implements Relay { ); } - setupInboxListeners() { - if (this.#federation != null) { - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + 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; @@ -98,8 +73,8 @@ export class LitePubRelay implements Relay { if (existingFollow?.state === "pending") return; let approved = false; - if (this.#options.subscriptionHandler) { - approved = await this.#options.subscriptionHandler(ctx, follower); + if (this.options.subscriptionHandler) { + approved = await this.options.subscriptionHandler(ctx, follower); } if (approved) { diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 94a94b374..1584161c5 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -2,7 +2,6 @@ import { Announce, Create, Delete, - type Federation, Follow, type InboxContext, Move, @@ -11,14 +10,13 @@ import { } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { + BaseRelay, handleUndoFollow, - type Relay, RELAY_SERVER_ACTOR, type RelayOptions, sendFollowResponse, validateFollowActivity, } from "./relay.ts"; -import type { FederationBuilder } from "@fedify/fedify/federation"; const logger = getLogger(["fedify", "relay", "mastodon"]); @@ -29,30 +27,7 @@ const logger = getLogger(["fedify", "relay", "mastodon"]); * * @since 2.0.0 */ -export class MastodonRelay implements Relay { - #federationBuilder: FederationBuilder; - #options: RelayOptions; - #federation?: Federation; - - constructor( - options: RelayOptions, - relayBuilder: FederationBuilder, - ) { - this.#options = options; - this.#federationBuilder = relayBuilder; - } - - async fetch(request: Request): Promise { - if (this.#federation == null) { - this.#federation = await this.#federationBuilder.build(this.#options); - this.setupInboxListeners(); - } - - return await this.#federation.fetch(request, { - contextData: this.#options, - }); - } - +export class MastodonRelay extends BaseRelay { /** * Forward activity to all followers (mastodon-specific pattern). * Used for Create, Delete, Move, and Update activities. @@ -75,16 +50,16 @@ export class MastodonRelay implements Relay { ); } - setupInboxListeners() { - if (this.#federation != null) { - this.#federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + 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; let approved = false; - if (this.#options.subscriptionHandler) { - approved = await this.#options.subscriptionHandler(ctx, follower); + if (this.options.subscriptionHandler) { + approved = await this.options.subscriptionHandler(ctx, follower); } if (approved) { diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 5f8113e58..93239d000 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -10,9 +10,10 @@ // Export relay functionality here export { - createRelay, + BaseRelay, type Relay, RELAY_SERVER_ACTOR, + relayBuilder, type RelayFollower, type RelayOptions, type RelayType, @@ -21,3 +22,40 @@ export { export { MastodonRelay } from "./mastodon.ts"; export { LitePubRelay } from "./litepub.ts"; + +import type { Relay, RelayOptions, RelayType } from "./relay.ts"; +import { relayBuilder } from "./relay.ts"; +import { MastodonRelay } from "./mastodon.ts"; +import { LitePubRelay } from "./litepub.ts"; + +/** + * Factory function to create a relay instance. + * + * @param type The type of relay to create ("mastodon" or "litepub") + * @param options Configuration options for the relay + * @returns A relay instance + * + * @example + * ```ts + * import { createRelay } from "@fedify/relay"; + * import { MemoryKvStore } from "@fedify/fedify"; + * + * const relay = createRelay("mastodon", { + * kv: new MemoryKvStore(), + * domain: "relay.example.com", + * }); + * ``` + * + * @since 2.0.0 + */ +export function createRelay( + type: RelayType, + options: RelayOptions, +): Relay { + switch (type) { + case "mastodon": + return new MastodonRelay(options, relayBuilder); + case "litepub": + return new LitePubRelay(options, relayBuilder); + } +} diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index c50bd37ae..c69e55388 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -3,6 +3,8 @@ import { type Context, createFederationBuilder, exportJwk, + type Federation, + type FederationBuilder, Follow, generateCryptoKeyPair, importJwk, @@ -17,8 +19,6 @@ import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, } from "@fedify/vocab-runtime"; -import { LitePubRelay } from "./litepub.ts"; -import { MastodonRelay } from "./mastodon.ts"; export const RELAY_SERVER_ACTOR = "relay"; @@ -60,7 +60,45 @@ export interface RelayFollower { readonly state: "pending" | "accepted"; } -export const relayBuilder = createFederationBuilder(); +/** + * Abstract base class for relay implementations. + * Provides common infrastructure for both Mastodon and LitePub relays. + * + * @since 2.0.0 + */ +export abstract class BaseRelay implements Relay { + protected federationBuilder: FederationBuilder; + protected options: RelayOptions; + protected federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.options = options; + this.federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.federation == null) { + this.federation = await this.federationBuilder.build(this.options); + this.setupInboxListeners(); + } + + return await this.federation.fetch(request, { + contextData: this.options, + }); + } + + /** + * Set up inbox listeners for handling ActivityPub activities. + * Each relay type implements this method with protocol-specific logic. + */ + protected abstract setupInboxListeners(): void; +} + +export const relayBuilder: FederationBuilder = + createFederationBuilder(); relayBuilder.setActorDispatcher( "/users/{identifier}", @@ -249,15 +287,3 @@ relayBuilder.setFollowingDispatcher( return { items: actors }; }, ); - -export function createRelay( - type: RelayType, - options: RelayOptions, -): Relay { - switch (type) { - case "mastodon": - return new MastodonRelay(options, relayBuilder); - case "litepub": - return new LitePubRelay(options, relayBuilder); - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63577056b..4812fb8b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1070,6 +1070,9 @@ importers: '@js-temporal/polyfill': specifier: 'catalog:' version: 0.5.1 + '@logtape/logtape': + specifier: 'catalog:' + version: 1.3.5 devDependencies: '@fedify/testing': specifier: workspace:^ From 3abbf2dea2ead3868adc3681310ad361fb654069 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Dec 2025 23:48:28 +0900 Subject: [PATCH 32/45] Extract dispatchRelayActors helper function --- packages/relay/src/relay.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index c69e55388..718b7f8aa 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -270,20 +270,21 @@ export async function handleUndoFollow( } } +async function dispatchRelayActors( + ctx: Context, + identifier: string, +) { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const actors = await getFollowerActors(ctx); + return { items: actors }; +} + relayBuilder.setFollowersDispatcher( "/users/{identifier}/followers", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const actors = await getFollowerActors(ctx); - return { items: actors }; - }, + dispatchRelayActors, ); relayBuilder.setFollowingDispatcher( "/users/{identifier}/following", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const actors = await getFollowerActors(ctx); - return { items: actors }; - }, + dispatchRelayActors, ); From de8a11a414ff90547ed33e7a423d3835f399b9c0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 00:53:49 +0900 Subject: [PATCH 33/45] Remove Relay interface --- packages/relay/src/mod.ts | 14 ++++++-------- packages/relay/src/relay.ts | 11 ++--------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 93239d000..ba1843fd7 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -8,10 +8,13 @@ * @module */ -// Export relay functionality here +import type { BaseRelay, RelayOptions, RelayType } from "./relay.ts"; +import { relayBuilder } from "./relay.ts"; +import { MastodonRelay } from "./mastodon.ts"; +import { LitePubRelay } from "./litepub.ts"; + export { BaseRelay, - type Relay, RELAY_SERVER_ACTOR, relayBuilder, type RelayFollower, @@ -23,11 +26,6 @@ export { export { MastodonRelay } from "./mastodon.ts"; export { LitePubRelay } from "./litepub.ts"; -import type { Relay, RelayOptions, RelayType } from "./relay.ts"; -import { relayBuilder } from "./relay.ts"; -import { MastodonRelay } from "./mastodon.ts"; -import { LitePubRelay } from "./litepub.ts"; - /** * Factory function to create a relay instance. * @@ -51,7 +49,7 @@ import { LitePubRelay } from "./litepub.ts"; export function createRelay( type: RelayType, options: RelayOptions, -): Relay { +): BaseRelay { switch (type) { case "mastodon": return new MastodonRelay(options, relayBuilder); diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 718b7f8aa..199ea18a5 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -14,11 +14,11 @@ import { type Undo, } from "@fedify/fedify"; import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; -import type { getLogger } from "@logtape/logtape"; import type { AuthenticatedDocumentLoaderFactory, DocumentLoaderFactory, } from "@fedify/vocab-runtime"; +import type { getLogger } from "@logtape/logtape"; export const RELAY_SERVER_ACTOR = "relay"; @@ -27,13 +27,6 @@ export const RELAY_SERVER_ACTOR = "relay"; */ export type RelayType = "mastodon" | "litepub"; -/** - * Common interface for all relay implementations. - */ -export interface Relay { - fetch(request: Request): Promise; -} - /** * Handler for subscription requests (Follow/Undo activities). */ @@ -66,7 +59,7 @@ export interface RelayFollower { * * @since 2.0.0 */ -export abstract class BaseRelay implements Relay { +export abstract class BaseRelay { protected federationBuilder: FederationBuilder; protected options: RelayOptions; protected federation?: Federation; From f748672ea651450a7a089770633114ae64969b90 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 00:57:08 +0900 Subject: [PATCH 34/45] Delete unnecessary comments --- packages/relay/src/mastodon.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 1584161c5..d1618222e 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -28,10 +28,6 @@ const logger = getLogger(["fedify", "relay", "mastodon"]); * @since 2.0.0 */ export class MastodonRelay extends BaseRelay { - /** - * Forward activity to all followers (mastodon-specific pattern). - * Used for Create, Delete, Move, and Update activities. - */ async #forwardToFollowers( ctx: InboxContext, activity: Create | Delete | Move | Update | Announce, From 264f3fa9ed5cf7ae662306455312832257368534 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 23:41:59 +0900 Subject: [PATCH 35/45] Extract relay types and constants to types.ts --- deno.lock | 76 ++++++++++++++++++++++++++++++++++--- packages/relay/src/types.ts | 39 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 packages/relay/src/types.ts diff --git a/deno.lock b/deno.lock index 3c6ae2c75..60acc5551 100644 --- a/deno.lock +++ b/deno.lock @@ -1,26 +1,39 @@ { "version": "5", "specifiers": { + "jsr:@alinea/suite@~0.6.3": "0.6.3", "jsr:@david/console-static-text@0.3": "0.3.0", "jsr:@david/dax@~0.43.2": "0.43.2", "jsr:@david/path@0.2": "0.2.0", "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@es-toolkit/es-toolkit@^1.39.5": "1.39.10", "jsr:@hongminhee/localtunnel@0.3": "0.3.0", + "jsr:@hono/hono@^4.7.1": "4.10.2", "jsr:@hono/hono@^4.8.3": "4.10.2", "jsr:@logtape/file@^1.2.2": "1.2.2", "jsr:@logtape/logtape@^1.0.4": "1.2.2", "jsr:@logtape/logtape@^1.2.2": "1.2.2", "jsr:@optique/core@~0.6.1": "0.6.1", "jsr:@optique/run@~0.6.1": "0.6.1", + "jsr:@std/assert@0.224": "0.224.0", + "jsr:@std/assert@0.226": "0.226.0", + "jsr:@std/assert@^1.0.13": "1.0.16", + "jsr:@std/async@^1.0.13": "1.0.15", "jsr:@std/bytes@^1.0.5": "1.0.6", + "jsr:@std/fmt@0.224": "0.224.0", "jsr:@std/fmt@1": "1.0.8", + "jsr:@std/fs@0.224": "0.224.0", "jsr:@std/fs@1": "1.0.20", + "jsr:@std/fs@^1.0.3": "1.0.20", + "jsr:@std/internal@0.224": "0.224.0", + "jsr:@std/internal@1": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/io@0.225": "0.225.2", + "jsr:@std/path@0.224": "0.224.0", "jsr:@std/path@1": "1.1.3", "jsr:@std/path@^1.0.6": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/testing@0.224": "0.224.0", "jsr:@std/yaml@^1.0.8": "1.0.10", "npm:@alinea/suite@~0.6.3": "0.6.3", "npm:@cfworker/json-schema@^4.1.1": "4.1.1", @@ -41,6 +54,7 @@ "npm:@optique/run@~0.6.1": "0.6.5", "npm:@poppanator/http-constants@^1.1.1": "1.1.1", "npm:@sveltejs/kit@2": "2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.0___acorn@8.15.0__vite@7.2.7___@types+node@22.19.2___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.2__tsx@4.21.0__yaml@2.8.2_svelte@5.46.0__acorn@8.15.0_vite@7.2.7__@types+node@22.19.2__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.2_tsx@4.21.0_yaml@2.8.2", + "npm:@types/amqplib@*": "0.10.8", "npm:@types/amqplib@~0.10.7": "0.10.8", "npm:@types/node@^22.16.0": "22.19.2", "npm:@types/node@^24.2.1": "24.10.3", @@ -93,6 +107,9 @@ "npm:yaml@^2.8.1": "2.8.2" }, "jsr": { + "@alinea/suite@0.6.3": { + "integrity": "7d24a38729663b84d8a263d64ff7e3f8c72ac7cbb1db8ec5f414d0416b6b72e2" + }, "@david/console-static-text@0.3.0": { "integrity": "2dfb46ecee525755f7989f94ece30bba85bd8ffe3e8666abc1bf926e1ee0698d" }, @@ -102,8 +119,8 @@ "jsr:@david/console-static-text", "jsr:@david/path", "jsr:@david/which", - "jsr:@std/fmt", - "jsr:@std/fs", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", "jsr:@std/io", "jsr:@std/path@1" ] @@ -111,7 +128,7 @@ "@david/path@0.2.0": { "integrity": "f2d7aa7f02ce5a55e27c09f9f1381794acb09d328f8d3c8a2e3ab3ffc294dccd", "dependencies": [ - "jsr:@std/fs", + "jsr:@std/fs@1", "jsr:@std/path@1" ] }, @@ -148,19 +165,56 @@ "jsr:@optique/core@~0.6.1" ] }, + "@std/assert@0.224.0": { + "integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f", + "dependencies": [ + "jsr:@std/fmt@0.224", + "jsr:@std/internal@0.224" + ] + }, + "@std/assert@0.226.0": { + "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3", + "dependencies": [ + "jsr:@std/internal@1" + ] + }, + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/async@1.0.15": { + "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, + "@std/fmt@0.224.0": { + "integrity": "e20e9a2312a8b5393272c26191c0a68eda8d2c4b08b046bad1673148f1d69851" + }, "@std/fmt@1.0.8": { "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, + "@std/fs@0.224.0": { + "integrity": "52a5ec89731ac0ca8f971079339286f88c571a4d61686acf75833f03a89d8e69", + "dependencies": [ + "jsr:@std/path@0.224" + ] + }, "@std/fs@1.0.20": { "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.12", "jsr:@std/path@^1.1.3" ] }, + "@std/internal@0.224.0": { + "integrity": "afc50644f9cdf4495eeb80523a8f6d27226b4b36c45c7c195dfccad4b8509291", + "dependencies": [ + "jsr:@std/fmt@0.224" + ] + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, @@ -170,10 +224,22 @@ "jsr:@std/bytes" ] }, + "@std/path@0.224.0": { + "integrity": "55bca6361e5a6d158b9380e82d4981d82d338ec587de02951e2b7c3a24910ee6" + }, "@std/path@1.1.3": { "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/testing@0.224.0": { + "integrity": "371b8a929aa7132240d5dd766a439be8f780ef5c176ab194e0bcab72370c761e", + "dependencies": [ + "jsr:@std/assert@0.224", + "jsr:@std/fmt@0.224", + "jsr:@std/fs@0.224", + "jsr:@std/path@0.224" ] }, "@std/yaml@1.0.10": { diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts new file mode 100644 index 000000000..aba752155 --- /dev/null +++ b/packages/relay/src/types.ts @@ -0,0 +1,39 @@ +import type { Context, KvStore, MessageQueue } from "@fedify/fedify"; +import type { Actor } from "@fedify/fedify/vocab"; +import type { + AuthenticatedDocumentLoaderFactory, + DocumentLoaderFactory, +} from "@fedify/vocab-runtime"; + +export const RELAY_SERVER_ACTOR = "relay"; + +/** + * Supported relay types. + */ +export type RelayType = "mastodon" | "litepub"; + +/** + * Handler for subscription requests (Follow/Undo activities). + */ +export type SubscriptionRequestHandler = ( + ctx: Context, + clientActor: Actor, +) => Promise; + +/** + * Configuration options for the ActivityPub relay. + */ +export interface RelayOptions { + kv: KvStore; + domain?: string; + name?: string; + documentLoaderFactory?: DocumentLoaderFactory; + authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; + queue?: MessageQueue; + subscriptionHandler?: SubscriptionRequestHandler; +} + +export interface RelayFollower { + readonly actor: unknown; + readonly state: "pending" | "accepted"; +} From 3ea4cf71bb385e729120589152f7985ee5217df8 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 23:47:30 +0900 Subject: [PATCH 36/45] Extract shared FederationBuilder and dispatchers - Add relayBuilder with actor, key pairs dispatchers - Add shared followers/following dispatchers - Extract getFollowerActors and dispatchRelayActors helpers --- packages/relay/src/builder.ts | 112 ++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/relay/src/builder.ts diff --git a/packages/relay/src/builder.ts b/packages/relay/src/builder.ts new file mode 100644 index 000000000..5abc058d7 --- /dev/null +++ b/packages/relay/src/builder.ts @@ -0,0 +1,112 @@ +import { + type Context, + createFederationBuilder, + exportJwk, + type FederationBuilder, + generateCryptoKeyPair, + importJwk, +} from "@fedify/fedify"; +import { Application, isActor, Object } from "@fedify/fedify/vocab"; +import type { Actor } from "@fedify/fedify/vocab"; +import { + RELAY_SERVER_ACTOR, + type RelayFollower, + type RelayOptions, +} from "./types.ts"; + +export const relayBuilder: FederationBuilder = + createFederationBuilder(); + +relayBuilder.setActorDispatcher( + "/users/{identifier}", + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const keys = await ctx.getActorKeyPairs(identifier); + return new Application({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: ctx.data.name ?? "ActivityPub Relay", + inbox: ctx.getInboxUri(), // This should be sharedInboxUri + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + url: ctx.getActorUri(identifier), + publicKey: keys[0].cryptographicKey, + + assertionMethods: keys.map((k) => k.multikey), + }); + }, +) + .setKeyPairsDispatcher( + async (ctx, identifier) => { + if (identifier !== RELAY_SERVER_ACTOR) return []; + + const rsaPairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "rsa", identifier]); + const ed25519PairJson = await ctx.data.kv.get< + { privateKey: JsonWebKey; publicKey: JsonWebKey } + >(["keypair", "ed25519", identifier]); + if (rsaPairJson == null || ed25519PairJson == null) { + const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + const ed25519Pair = await generateCryptoKeyPair("Ed25519"); + await ctx.data.kv.set(["keypair", "rsa", identifier], { + privateKey: await exportJwk(rsaPair.privateKey), + publicKey: await exportJwk(rsaPair.publicKey), + }); + await ctx.data.kv.set(["keypair", "ed25519", identifier], { + privateKey: await exportJwk(ed25519Pair.privateKey), + publicKey: await exportJwk(ed25519Pair.publicKey), + }); + + return [rsaPair, ed25519Pair]; + } + + const rsaPair: CryptoKeyPair = { + privateKey: await importJwk(rsaPairJson.privateKey, "private"), + publicKey: await importJwk(rsaPairJson.publicKey, "public"), + }; + const ed25519Pair: CryptoKeyPair = { + privateKey: await importJwk(ed25519PairJson.privateKey, "private"), + publicKey: await importJwk(ed25519PairJson.publicKey, "public"), + }; + return [rsaPair, ed25519Pair]; + }, + ); + +async function getFollowerActors( + ctx: Context, +): Promise { + const followers = await ctx.data.kv.get(["followers"]) ?? []; + + const actors: Actor[] = []; + for (const followerId of followers) { + const follower = await ctx.data.kv.get([ + "follower", + followerId, + ]); + if (!follower) continue; + const actor = await Object.fromJsonLd(follower.actor); + if (!isActor(actor)) continue; + actors.push(actor); + } + return actors; +} + +async function dispatchRelayActors( + ctx: Context, + identifier: string, +) { + if (identifier !== RELAY_SERVER_ACTOR) return null; + const actors = await getFollowerActors(ctx); + return { items: actors }; +} + +relayBuilder.setFollowersDispatcher( + "/users/{identifier}/followers", + dispatchRelayActors, +); + +relayBuilder.setFollowingDispatcher( + "/users/{identifier}/following", + dispatchRelayActors, +); From b5e1cadf9cf598afd2209da4fce0adb0d0650f4d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 23:51:15 +0900 Subject: [PATCH 37/45] Extract follow activity handling helpers - Add validateFollowActivity for common validation - Add sendFollowResponse for Accept/Reject responses - Add handleUndoFollow for unsubscription handling - These functions are shared by both relay types --- packages/relay/src/follow.ts | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/relay/src/follow.ts diff --git a/packages/relay/src/follow.ts b/packages/relay/src/follow.ts new file mode 100644 index 000000000..1f0475278 --- /dev/null +++ b/packages/relay/src/follow.ts @@ -0,0 +1,105 @@ +import { + Accept, + type Context, + Follow, + Reject, + type Undo, +} from "@fedify/fedify"; +import type { Actor } from "@fedify/fedify/vocab"; +import type { getLogger } from "@logtape/logtape"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "./types.ts"; + +/** + * Validate Follow activity and return follower actor if valid. + * This validation is common to both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param follow The Follow activity to validate + * @returns The follower Actor if valid, null otherwise + */ +export async function validateFollowActivity( + ctx: Context, + follow: Follow, +): Promise { + if (follow.id == null || follow.objectId == null) return null; + + const parsed = ctx.parseUri(follow.objectId); + const isPublicFollow = follow.objectId.href === + "https://www.w3.org/ns/activitystreams#Public"; + if (!isPublicFollow && parsed?.type !== "actor") return null; + + const follower = await follow.getActor(ctx); + if ( + follower == null || follower.id == null || + follower.preferredUsername == null || + follower.inboxId == null + ) return null; + + return follower; +} + +/** + * Send Accept or Reject response for a Follow activity. + * This is common to both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param follow The Follow activity being responded to + * @param follower The actor who sent the Follow + * @param approved Whether the follow was approved + */ +export async function sendFollowResponse( + ctx: Context, + follow: Follow, + follower: Actor, + approved: boolean, +): Promise { + const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); + const Activity = approved ? Accept : Reject; + const action = approved ? "accepts" : "rejects"; + + await ctx.sendActivity( + { identifier: RELAY_SERVER_ACTOR }, + follower, + new Activity({ + id: new URL(`#${action}`, relayActorUri), + actor: relayActorUri, + object: follow, + }), + ); +} + +/** + * Handle Undo activity for Follow. + * This logic is identical for both Mastodon and LitePub relay protocols. + * + * @param ctx The federation context + * @param undo The Undo activity to handle + * @param logger The logger instance to use for warnings + */ +export async function handleUndoFollow( + ctx: Context, + undo: Undo, + logger: ReturnType, +): Promise { + const activity = await undo.getObject({ + crossOrigin: "trust", + ...ctx, + }); + + if (activity instanceof Follow) { + if (activity.id == null || activity.actorId == null) return; + + const followers = await ctx.data.kv.get(["followers"]) ?? []; + const updatedFollowers = followers.filter((id) => + id !== activity.actorId?.href + ); + + await ctx.data.kv.set(["followers"], updatedFollowers); + await ctx.data.kv.delete(["follower", activity.actorId?.href]); + } else { + logger.warn( + "Unsupported object type ({type}) for Undo activity: {object}", + { type: activity?.constructor.name, object: activity }, + ); + } +} From 0f98be54f352e0a91df4dd850ae0c169050d8279 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 23:51:30 +0900 Subject: [PATCH 38/45] Add BaseRelay abstract class - Provides common fetch() method and federation setup - Defines abstract setupInboxListeners() for protocol-specific logic - Base for both Mastodon and LitePub relay implementations --- packages/relay/src/base.ts | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/relay/src/base.ts diff --git a/packages/relay/src/base.ts b/packages/relay/src/base.ts new file mode 100644 index 000000000..81cb08411 --- /dev/null +++ b/packages/relay/src/base.ts @@ -0,0 +1,39 @@ +import type { Federation, FederationBuilder } from "@fedify/fedify"; +import type { RelayOptions } from "./types.ts"; + +/** + * Abstract base class for relay implementations. + * Provides common infrastructure for both Mastodon and LitePub relays. + * + * @since 2.0.0 + */ +export abstract class BaseRelay { + protected federationBuilder: FederationBuilder; + protected options: RelayOptions; + protected federation?: Federation; + + constructor( + options: RelayOptions, + relayBuilder: FederationBuilder, + ) { + this.options = options; + this.federationBuilder = relayBuilder; + } + + async fetch(request: Request): Promise { + if (this.federation == null) { + this.federation = await this.federationBuilder.build(this.options); + this.setupInboxListeners(); + } + + return await this.federation.fetch(request, { + contextData: this.options, + }); + } + + /** + * Set up inbox listeners for handling ActivityPub activities. + * Each relay type implements this method with protocol-specific logic. + */ + protected abstract setupInboxListeners(): void; +} From 71e50f6bedc90b9e63fbb57c35e1f1e70e23f3b3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 23:55:03 +0900 Subject: [PATCH 39/45] Refactor relay implementations to extend BaseRelay - MastodonRelay: Use forwardActivity for direct forwarding - LitePubRelay: Use Announce wrapping and reciprocal follows - Both now extend BaseRelay and implement setupInboxListeners() --- packages/relay/src/litepub.ts | 10 ++++++---- packages/relay/src/mastodon.ts | 7 +++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/relay/src/litepub.ts b/packages/relay/src/litepub.ts index d584766ce..a51a77d4f 100644 --- a/packages/relay/src/litepub.ts +++ b/packages/relay/src/litepub.ts @@ -12,15 +12,17 @@ import { Update, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; +import { BaseRelay } from "./base.ts"; import { - BaseRelay, handleUndoFollow, + sendFollowResponse, + validateFollowActivity, +} from "./follow.ts"; +import { RELAY_SERVER_ACTOR, type RelayFollower, type RelayOptions, - sendFollowResponse, - validateFollowActivity, -} from "./relay.ts"; +} from "./types.ts"; const logger = getLogger(["fedify", "relay", "litepub"]); diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index d1618222e..7c2b99c41 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -9,14 +9,13 @@ import { Update, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; +import { BaseRelay } from "./base.ts"; import { - BaseRelay, handleUndoFollow, - RELAY_SERVER_ACTOR, - type RelayOptions, sendFollowResponse, validateFollowActivity, -} from "./relay.ts"; +} from "./follow.ts"; +import { RELAY_SERVER_ACTOR, type RelayOptions } from "./types.ts"; const logger = getLogger(["fedify", "relay", "mastodon"]); From 83c066e89abc19b25360dcd0d305138d99d790c5 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Dec 2025 23:55:21 +0900 Subject: [PATCH 40/45] Add factory function and update module exports - Add createRelay() factory for instantiating relay types - Update mod.ts to export new structure - Remove old relay.ts interface --- packages/relay/src/factory.ts | 37 +++++ packages/relay/src/mod.ts | 50 +----- packages/relay/src/relay.ts | 283 ---------------------------------- 3 files changed, 43 insertions(+), 327 deletions(-) create mode 100644 packages/relay/src/factory.ts delete mode 100644 packages/relay/src/relay.ts diff --git a/packages/relay/src/factory.ts b/packages/relay/src/factory.ts new file mode 100644 index 000000000..a513f57ab --- /dev/null +++ b/packages/relay/src/factory.ts @@ -0,0 +1,37 @@ +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"; + +/** + * Factory function to create a relay instance. + * + * @param type The type of relay to create ("mastodon" or "litepub") + * @param options Configuration options for the relay + * @returns A relay instance + * + * @example + * ```ts + * import { createRelay } from "@fedify/relay"; + * import { MemoryKvStore } from "@fedify/fedify"; + * + * const relay = createRelay("mastodon", { + * kv: new MemoryKvStore(), + * domain: "relay.example.com", + * }); + * ``` + * + * @since 2.0.0 + */ +export function createRelay( + type: RelayType, + options: RelayOptions, +): BaseRelay { + switch (type) { + case "mastodon": + return new MastodonRelay(options, relayBuilder); + case "litepub": + return new LitePubRelay(options, relayBuilder); + } +} diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index ba1843fd7..6092f0f34 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -7,53 +7,15 @@ * * @module */ - -import type { BaseRelay, RelayOptions, RelayType } from "./relay.ts"; -import { relayBuilder } from "./relay.ts"; -import { MastodonRelay } from "./mastodon.ts"; -import { LitePubRelay } from "./litepub.ts"; - +export { BaseRelay } from "./base.ts"; +export { relayBuilder } from "./builder.ts"; +export { createRelay } from "./factory.ts"; +export { LitePubRelay } from "./litepub.ts"; +export { MastodonRelay } from "./mastodon.ts"; export { - BaseRelay, RELAY_SERVER_ACTOR, - relayBuilder, type RelayFollower, type RelayOptions, type RelayType, type SubscriptionRequestHandler, -} from "./relay.ts"; - -export { MastodonRelay } from "./mastodon.ts"; -export { LitePubRelay } from "./litepub.ts"; - -/** - * Factory function to create a relay instance. - * - * @param type The type of relay to create ("mastodon" or "litepub") - * @param options Configuration options for the relay - * @returns A relay instance - * - * @example - * ```ts - * import { createRelay } from "@fedify/relay"; - * import { MemoryKvStore } from "@fedify/fedify"; - * - * const relay = createRelay("mastodon", { - * kv: new MemoryKvStore(), - * domain: "relay.example.com", - * }); - * ``` - * - * @since 2.0.0 - */ -export function createRelay( - type: RelayType, - options: RelayOptions, -): BaseRelay { - switch (type) { - case "mastodon": - return new MastodonRelay(options, relayBuilder); - case "litepub": - return new LitePubRelay(options, relayBuilder); - } -} +} from "./types.ts"; diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts deleted file mode 100644 index 199ea18a5..000000000 --- a/packages/relay/src/relay.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { - Accept, - type Context, - createFederationBuilder, - exportJwk, - type Federation, - type FederationBuilder, - Follow, - generateCryptoKeyPair, - importJwk, - type KvStore, - type MessageQueue, - Reject, - type Undo, -} from "@fedify/fedify"; -import { type Actor, Application, isActor, Object } from "@fedify/fedify/vocab"; -import type { - AuthenticatedDocumentLoaderFactory, - DocumentLoaderFactory, -} from "@fedify/vocab-runtime"; -import type { getLogger } from "@logtape/logtape"; - -export const RELAY_SERVER_ACTOR = "relay"; - -/** - * Supported relay types. - */ -export type RelayType = "mastodon" | "litepub"; - -/** - * Handler for subscription requests (Follow/Undo activities). - */ -export type SubscriptionRequestHandler = ( - ctx: Context, - clientActor: Actor, -) => Promise; - -/** - * Configuration options for the ActivityPub relay. - */ -export interface RelayOptions { - kv: KvStore; - domain?: string; - name?: string; - documentLoaderFactory?: DocumentLoaderFactory; - authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; - queue?: MessageQueue; - subscriptionHandler?: SubscriptionRequestHandler; -} - -export interface RelayFollower { - readonly actor: unknown; - readonly state: "pending" | "accepted"; -} - -/** - * Abstract base class for relay implementations. - * Provides common infrastructure for both Mastodon and LitePub relays. - * - * @since 2.0.0 - */ -export abstract class BaseRelay { - protected federationBuilder: FederationBuilder; - protected options: RelayOptions; - protected federation?: Federation; - - constructor( - options: RelayOptions, - relayBuilder: FederationBuilder, - ) { - this.options = options; - this.federationBuilder = relayBuilder; - } - - async fetch(request: Request): Promise { - if (this.federation == null) { - this.federation = await this.federationBuilder.build(this.options); - this.setupInboxListeners(); - } - - return await this.federation.fetch(request, { - contextData: this.options, - }); - } - - /** - * Set up inbox listeners for handling ActivityPub activities. - * Each relay type implements this method with protocol-specific logic. - */ - protected abstract setupInboxListeners(): void; -} - -export const relayBuilder: FederationBuilder = - createFederationBuilder(); - -relayBuilder.setActorDispatcher( - "/users/{identifier}", - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const keys = await ctx.getActorKeyPairs(identifier); - return new Application({ - id: ctx.getActorUri(identifier), - preferredUsername: identifier, - name: ctx.data.name ?? "ActivityPub Relay", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri - followers: ctx.getFollowersUri(identifier), - following: ctx.getFollowingUri(identifier), - url: ctx.getActorUri(identifier), - publicKey: keys[0].cryptographicKey, - - assertionMethods: keys.map((k) => k.multikey), - }); - }, -) - .setKeyPairsDispatcher( - async (ctx, identifier) => { - if (identifier !== RELAY_SERVER_ACTOR) return []; - - const rsaPairJson = await ctx.data.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "rsa", identifier]); - const ed25519PairJson = await ctx.data.kv.get< - { privateKey: JsonWebKey; publicKey: JsonWebKey } - >(["keypair", "ed25519", identifier]); - if (rsaPairJson == null || ed25519PairJson == null) { - const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); - const ed25519Pair = await generateCryptoKeyPair("Ed25519"); - await ctx.data.kv.set(["keypair", "rsa", identifier], { - privateKey: await exportJwk(rsaPair.privateKey), - publicKey: await exportJwk(rsaPair.publicKey), - }); - await ctx.data.kv.set(["keypair", "ed25519", identifier], { - privateKey: await exportJwk(ed25519Pair.privateKey), - publicKey: await exportJwk(ed25519Pair.publicKey), - }); - - return [rsaPair, ed25519Pair]; - } - - const rsaPair: CryptoKeyPair = { - privateKey: await importJwk(rsaPairJson.privateKey, "private"), - publicKey: await importJwk(rsaPairJson.publicKey, "public"), - }; - const ed25519Pair: CryptoKeyPair = { - privateKey: await importJwk(ed25519PairJson.privateKey, "private"), - publicKey: await importJwk(ed25519PairJson.publicKey, "public"), - }; - return [rsaPair, ed25519Pair]; - }, - ); - -async function getFollowerActors( - ctx: Context, -): Promise { - const followers = await ctx.data.kv.get(["followers"]) ?? []; - - const actors: Actor[] = []; - for (const followerId of followers) { - const follower = await ctx.data.kv.get([ - "follower", - followerId, - ]); - if (!follower) continue; - const actor = await Object.fromJsonLd(follower.actor); - if (!isActor(actor)) continue; - actors.push(actor); - } - return actors; -} - -/** - * Validate Follow activity and return follower actor if valid. - * This validation is common to both Mastodon and LitePub relay protocols. - * - * @param ctx The federation context - * @param follow The Follow activity to validate - * @returns The follower Actor if valid, null otherwise - */ -export async function validateFollowActivity( - ctx: Context, - follow: Follow, -): Promise { - if (follow.id == null || follow.objectId == null) return null; - - const parsed = ctx.parseUri(follow.objectId); - const isPublicFollow = follow.objectId.href === - "https://www.w3.org/ns/activitystreams#Public"; - if (!isPublicFollow && parsed?.type !== "actor") return null; - - const follower = await follow.getActor(ctx); - if ( - follower == null || follower.id == null || - follower.preferredUsername == null || - follower.inboxId == null - ) return null; - - return follower; -} - -/** - * Send Accept or Reject response for a Follow activity. - * This is common to both Mastodon and LitePub relay protocols. - * - * @param ctx The federation context - * @param follow The Follow activity being responded to - * @param follower The actor who sent the Follow - * @param approved Whether the follow was approved - */ -export async function sendFollowResponse( - ctx: Context, - follow: Follow, - follower: Actor, - approved: boolean, -): Promise { - const relayActorUri = ctx.getActorUri(RELAY_SERVER_ACTOR); - const Activity = approved ? Accept : Reject; - const action = approved ? "accepts" : "rejects"; - - await ctx.sendActivity( - { identifier: RELAY_SERVER_ACTOR }, - follower, - new Activity({ - id: new URL(`#${action}`, relayActorUri), - actor: relayActorUri, - object: follow, - }), - ); -} - -/** - * Handle Undo activity for Follow. - * This logic is identical for both Mastodon and LitePub relay protocols. - * - * @param ctx The federation context - * @param undo The Undo activity to handle - * @param logger The logger instance to use for warnings - */ -export async function handleUndoFollow( - ctx: Context, - undo: Undo, - logger: ReturnType, -): Promise { - const activity = await undo.getObject({ - crossOrigin: "trust", - ...ctx, - }); - - if (activity instanceof Follow) { - if (activity.id == null || activity.actorId == null) return; - - const followers = await ctx.data.kv.get(["followers"]) ?? []; - const updatedFollowers = followers.filter((id) => - id !== activity.actorId?.href - ); - - await ctx.data.kv.set(["followers"], updatedFollowers); - await ctx.data.kv.delete(["follower", activity.actorId?.href]); - } else { - logger.warn( - "Unsupported object type ({type}) for Undo activity: {object}", - { type: activity?.constructor.name, object: activity }, - ); - } -} - -async function dispatchRelayActors( - ctx: Context, - identifier: string, -) { - if (identifier !== RELAY_SERVER_ACTOR) return null; - const actors = await getFollowerActors(ctx); - return { items: actors }; -} - -relayBuilder.setFollowersDispatcher( - "/users/{identifier}/followers", - dispatchRelayActors, -); - -relayBuilder.setFollowingDispatcher( - "/users/{identifier}/following", - dispatchRelayActors, -); From 35656a5b07fe0131aa072e997659f9603a70287b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 25 Dec 2025 01:15:27 +0900 Subject: [PATCH 41/45] Update README.md and comments based on structure changes --- packages/relay/README.md | 117 ++++++++++++++++++++++----------- packages/relay/src/builder.ts | 2 +- packages/relay/src/mastodon.ts | 4 +- 3 files changed, 82 insertions(+), 41 deletions(-) diff --git a/packages/relay/README.md b/packages/relay/README.md index c06aada65..e0402fdc8 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -46,11 +46,16 @@ Key features: ### LitePub-style relay -*LitePub relay support is planned for a future release.* - The LitePub-style relay protocol uses bidirectional following relationships and wraps activities in `Announce` activities for distribution. +Key features: + + - Reciprocal following between relay and subscribers + - Activities wrapped in `Announce` for distribution + - Two-phase subscription (pending → accepted) + - Enhanced federation capabilities + Installation ------------ @@ -83,43 +88,56 @@ bun add @fedify/relay Usage ----- -### Creating a Mastodon-style relay +### Creating a relay -Here's a simple example of creating a Mastodon-compatible relay server: +Here's a simple example of creating a relay server using the factory function: ~~~~ typescript -import { MastodonRelay } from "@fedify/relay"; +import { createRelay } from "@fedify/relay"; import { MemoryKvStore } from "@fedify/fedify"; -const relay = new MastodonRelay({ +// Create a Mastodon-style relay +const relay = createRelay("mastodon", { kv: new MemoryKvStore(), domain: "relay.example.com", -}); - -// Optional: Set a custom subscription handler to approve/reject subscriptions -relay.setSubscriptionHandler(async (ctx, actor) => { - // Implement your approval logic here - // Return true to approve, false to reject - const domain = new URL(actor.id!).hostname; - const blockedDomains = ["spam.example", "blocked.example"]; - return !blockedDomains.includes(domain); + // Optional: Set a custom subscription handler to approve/reject subscriptions + subscriptionHandler: async (ctx, actor) => { + // Implement your approval logic here + // Return true to approve, false to reject + const domain = new URL(actor.id!).hostname; + const blockedDomains = ["spam.example", "blocked.example"]; + return !blockedDomains.includes(domain); + }, }); // Serve the relay Deno.serve((request) => relay.fetch(request)); ~~~~ +You can also create a LitePub-style relay by changing the type: + +~~~~ typescript +const relay = createRelay("litepub", { + kv: new MemoryKvStore(), + domain: "relay.example.com", +}); +~~~~ + ### Subscription handling By default, the relay automatically rejects all subscription requests. -You can customize this behavior by setting a subscription handler: +You can customize this behavior by providing a subscription handler in the options: ~~~~ typescript -relay.setSubscriptionHandler(async (ctx, actor) => { - // Example: Only allow subscriptions from specific domains - const domain = new URL(actor.id!).hostname; - const allowedDomains = ["mastodon.social", "fosstodon.org"]; - return allowedDomains.includes(domain); +const relay = createRelay("mastodon", { + kv: new MemoryKvStore(), + domain: "relay.example.com", + subscriptionHandler: async (ctx, actor) => { + // Example: Only allow subscriptions from specific domains + const domain = new URL(actor.id!).hostname; + const allowedDomains = ["mastodon.social", "fosstodon.org"]; + return allowedDomains.includes(domain); + }, }); ~~~~ @@ -131,11 +149,11 @@ example with Hono: ~~~~ typescript import { Hono } from "hono"; -import { MastodonRelay } from "@fedify/relay"; +import { createRelay } from "@fedify/relay"; import { MemoryKvStore } from "@fedify/fedify"; const app = new Hono(); -const relay = new MastodonRelay({ +const relay = createRelay("mastodon", { kv: new MemoryKvStore(), domain: "relay.example.com", }); @@ -191,25 +209,47 @@ details. API reference ------------- -### `MastodonRelay` - -A Mastodon-compatible ActivityPub relay implementation. +### `createRelay()` -#### Constructor +Factory function to create a relay instance. ~~~~ typescript -new MastodonRelay(options: RelayOptions) +function createRelay( + type: "mastodon" | "litepub", + options: RelayOptions +): BaseRelay ~~~~ -#### Properties +**Parameters:** + + - `type`: The type of relay to create (`"mastodon"` or `"litepub"`) + - `options`: Configuration options for the relay - - `domain`: The relay's domain name (read-only) +**Returns:** A relay instance (`MastodonRelay` or `LitePubRelay`) + +### `BaseRelay` + +Abstract base class for relay implementations. #### Methods - `fetch(request: Request): Promise`: Handle incoming HTTP requests - - `setSubscriptionHandler(handler: SubscriptionRequestHandler): this`: - Set a custom handler for subscription approval/rejection + +### `MastodonRelay` + +A Mastodon-compatible ActivityPub relay implementation that extends `BaseRelay`. + + - Uses direct activity forwarding + - Immediate subscription approval + - Compatible with standard ActivityPub implementations + +### `LitePubRelay` + +A LitePub-compatible ActivityPub relay implementation that extends `BaseRelay`. + + - Uses bidirectional following + - Activities wrapped in `Announce` + - Two-phase subscription (pending → accepted) ### `RelayOptions` @@ -217,12 +257,13 @@ 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 + subscription approval/rejection - `documentLoaderFactory?: DocumentLoaderFactory`: Custom document loader factory - `authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory`: Custom authenticated document loader factory - - `federation?: Federation`: Custom Federation instance (for advanced - use cases) - `queue?: MessageQueue`: Message queue for background activity processing ### `SubscriptionRequestHandler` @@ -231,17 +272,17 @@ A function that determines whether to approve a subscription request: ~~~~ typescript type SubscriptionRequestHandler = ( - ctx: Context, + ctx: Context, clientActor: Actor, ) => Promise ~~~~ -Parameters: +**Parameters:** - - `ctx`: The Fedify context object + - `ctx`: The Fedify context object with relay options - `clientActor`: The actor requesting to subscribe -Returns: +**Returns:** - `true` to approve the subscription - `false` to reject the subscription diff --git a/packages/relay/src/builder.ts b/packages/relay/src/builder.ts index 5abc058d7..9ab634ac0 100644 --- a/packages/relay/src/builder.ts +++ b/packages/relay/src/builder.ts @@ -26,7 +26,7 @@ relayBuilder.setActorDispatcher( id: ctx.getActorUri(identifier), preferredUsername: identifier, name: ctx.data.name ?? "ActivityPub Relay", - inbox: ctx.getInboxUri(), // This should be sharedInboxUri + inbox: ctx.getInboxUri(), // This should be shared inbox uri followers: ctx.getFollowersUri(identifier), following: ctx.getFollowingUri(identifier), url: ctx.getActorUri(identifier), diff --git a/packages/relay/src/mastodon.ts b/packages/relay/src/mastodon.ts index 7c2b99c41..a27a8060f 100644 --- a/packages/relay/src/mastodon.ts +++ b/packages/relay/src/mastodon.ts @@ -21,7 +21,7 @@ const logger = getLogger(["fedify", "relay", "mastodon"]); /** * A Mastodon-compatible ActivityPub relay implementation. - * This relay follows Mastodon's relay protocol for maximum compatibility + * This relay follows Mastodon's relay protocol for compatibility * with Mastodon instances. * * @since 2.0.0 @@ -58,7 +58,7 @@ export class MastodonRelay extends BaseRelay { } if (approved) { - // mastodon-specific: immediately add to followers list + // Mastodon-specific: immediately add to followers list const followers = await ctx.data.kv.get(["followers"]) ?? []; followers.push(follower.id.href); From ac2ada3b5da6d4492ea43c675dfe55e909ca9821 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 25 Dec 2025 01:26:29 +0900 Subject: [PATCH 42/45] Update email in pacakge.json --- packages/relay/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/relay/package.json b/packages/relay/package.json index a96af7f30..610690a13 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -10,7 +10,7 @@ ], "author": { "name": "Jiwon Kwon", - "email": "jiwonkwon@duck.com" + "email": "work@kwonjiwon.org" }, "homepage": "https://fedify.dev/", "repository": { From 2c53cbed302c30868013ac5641e76fa69e12381b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 25 Dec 2025 01:46:46 +0900 Subject: [PATCH 43/45] Fix deno.lock --- deno.lock | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/deno.lock b/deno.lock index 64aa0ba67..72f605c46 100644 --- a/deno.lock +++ b/deno.lock @@ -40,13 +40,13 @@ "npm:@jimp/wasm-webp@^1.6.0": "1.6.0", "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", "npm:@multiformats/base-x@^4.0.1": "4.0.1", - "npm:@nestjs/common@^11.0.1": "11.1.9_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@nestjs/common@^11.0.1": "11.1.10_reflect-metadata@0.2.2_rxjs@7.8.2", "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@opentelemetry/core@^1.30.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/sdk-trace-base@^1.30.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/semantic-conventions@^1.27.0": "1.28.0", - "npm:@optique/core@~0.6.1": "0.6.5", - "npm:@optique/run@~0.6.1": "0.6.5", + "npm:@optique/core@~0.6.1": "0.6.6", + "npm:@optique/run@~0.6.1": "0.6.6", "npm:@poppanator/http-constants@^1.1.1": "1.1.1", "npm:@sveltejs/kit@2": "2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.0___acorn@8.15.0__vite@7.3.0___@types+node@22.19.3___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.3__tsx@4.21.0__yaml@2.8.2_svelte@5.46.0__acorn@8.15.0_vite@7.3.0__@types+node@22.19.3__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.3_tsx@4.21.0_yaml@2.8.2", "npm:@types/amqplib@~0.10.7": "0.10.8", @@ -1292,10 +1292,10 @@ "@tybys/wasm-util" ] }, - "@nestjs/common@11.1.9_reflect-metadata@0.2.2_rxjs@7.8.2": { - "integrity": "sha512-zDntUTReRbAThIfSp3dQZ9kKqI+LjgLp5YZN5c1bgNRDuoeLySAoZg46Bg1a+uV8TMgIRziHocglKGNzr6l+bQ==", + "@nestjs/common@11.1.10_reflect-metadata@0.2.2_rxjs@7.8.2": { + "integrity": "sha512-NoBzJFtq1bzHGia5Q5NO1pJNpx530nupbEu/auCWOFCGL5y8Zo8kiG28EXTCDfIhQgregEtn1Cs6H8WSLUC8kg==", "dependencies": [ - "file-type@21.1.0", + "file-type@21.1.1", "iterare", "load-esm", "reflect-metadata", @@ -1337,11 +1337,11 @@ "@opentelemetry/semantic-conventions@1.28.0": { "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==" }, - "@optique/core@0.6.5": { - "integrity": "sha512-H3O//t/qxq7GT+25oLi4mXyxB/PccTcj+0P4HcboDcTnAN7gcTgoxvAugaHExw9s7WrVOlRQRZuYXseKtU/cyw==" + "@optique/core@0.6.6": { + "integrity": "sha512-rllp+mWPhkQcbpY1L8AQJCHPDUEJ/TVBEsAvx3HkPAnJXp4LNlLS25KF0S7qqsuy47cwmpW8Tkme27CzA5iazA==" }, - "@optique/run@0.6.5": { - "integrity": "sha512-dJTfcNXRWM+dmGxbeTFNDt/cf3v92wNYcJVZUu+FqwNXD5lX/koWeMIGwT0eoJegGPWvzpCVRb0CVjc+b/AUbQ==", + "@optique/run@0.6.6": { + "integrity": "sha512-VaEIAbTyer76ywpAKcoBzhrvhVUp/inRxEuesrYMlxRc/5uTJNUTGCngUBy/+ClYHtHpCW9BvhM4i53lmJ7gIw==", "dependencies": [ "@optique/core" ] @@ -1674,11 +1674,10 @@ "vitefu" ] }, - "@tokenizer/inflate@0.3.1": { - "integrity": "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA==", + "@tokenizer/inflate@0.4.1": { + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "dependencies": [ "debug@4.4.3", - "fflate", "token-types@6.1.1" ] }, @@ -2635,6 +2634,7 @@ "regexparam" ] }, +<<<<<<< HEAD "fflate@0.8.2": { "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" }, @@ -2644,6 +2644,8 @@ "flat-cache" ] }, +======= +>>>>>>> ac923ced (Fix deno.lock) "file-type@16.5.4": { "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", "dependencies": [ @@ -2661,8 +2663,8 @@ "uint8array-extras" ] }, - "file-type@21.1.0": { - "integrity": "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA==", + "file-type@21.1.1": { + "integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==", "dependencies": [ "@tokenizer/inflate", "strtok3@10.3.4", @@ -3782,8 +3784,13 @@ "rolldown" ] }, +<<<<<<< HEAD "rolldown@1.0.0-beta.55": { "integrity": "sha512-r8Ws43aYCnfO07ao0SvQRz4TBAtZJjGWNvScRBOHuiNHvjfECOJBIqJv0nUkL1GYcltjvvHswRilDF1ocsC0+g==", +======= + "rolldown@1.0.0-beta.56": { + "integrity": "sha512-9MHiUvRH2R8rb6ad6EaLxahS3RbQKdMMlrh9XKmbz2HiCGfK4IWKSNv4N6GhYr+7kHExg6oIc5EF1xA3iR4x1A==", +>>>>>>> ac923ced (Fix deno.lock) "dependencies": [ "@oxc-project/types", "@rolldown/pluginutils" From d6b127fd54c867071e758c1f576ae94d8068c65c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 25 Dec 2025 02:12:48 +0900 Subject: [PATCH 44/45] Fix deno.lock --- deno.lock | 238 +++++++++++++++++++++++++++--------------------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/deno.lock b/deno.lock index 72f605c46..69818ea07 100644 --- a/deno.lock +++ b/deno.lock @@ -8,14 +8,19 @@ "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@es-toolkit/es-toolkit@^1.39.5": "1.43.0", "jsr:@hongminhee/localtunnel@0.3": "0.3.0", + "jsr:@hono/hono@^4.7.1": "4.11.1", "jsr:@hono/hono@^4.8.3": "4.11.1", "jsr:@logtape/file@^1.2.2": "1.3.5", "jsr:@logtape/logtape@^1.0.4": "1.3.5", "jsr:@logtape/logtape@^1.2.2": "1.3.5", "jsr:@logtape/logtape@^1.3.5": "1.3.5", - "jsr:@optique/core@~0.6.1": "0.6.5", - "jsr:@optique/core@~0.6.5": "0.6.5", - "jsr:@optique/run@~0.6.1": "0.6.5", + "jsr:@optique/core@~0.6.1": "0.6.6", + "jsr:@optique/core@~0.6.6": "0.6.6", + "jsr:@optique/run@~0.6.1": "0.6.6", + "jsr:@std/assert@0.224": "0.224.0", + "jsr:@std/assert@0.226": "0.226.0", + "jsr:@std/assert@^1.0.13": "1.0.16", + "jsr:@std/async@^1.0.13": "1.0.15", "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/fmt@0.224": "0.224.0", "jsr:@std/fmt@1": "1.0.8", @@ -30,9 +35,11 @@ "jsr:@std/path@1": "1.1.3", "jsr:@std/path@^1.0.6": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/testing@0.224": "0.224.0", + "jsr:@std/yaml@^1.0.8": "1.0.10", "npm:@alinea/suite@~0.6.3": "0.6.3", "npm:@cfworker/json-schema@^4.1.1": "4.1.1", - "npm:@cloudflare/workers-types@^4.20250529.0": "4.20251219.0", + "npm:@cloudflare/workers-types@^4.20250529.0": "4.20251223.0", "npm:@fxts/core@^1.21.1": "1.21.1", "npm:@hongminhee/localtunnel@0.3": "0.3.0", "npm:@inquirer/prompts@^7.8.4": "7.10.1_@types+node@22.19.3", @@ -49,13 +56,14 @@ "npm:@optique/run@~0.6.1": "0.6.6", "npm:@poppanator/http-constants@^1.1.1": "1.1.1", "npm:@sveltejs/kit@2": "2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.0___acorn@8.15.0__vite@7.3.0___@types+node@22.19.3___tsx@4.21.0___yaml@2.8.2___picomatch@4.0.3__@types+node@22.19.3__tsx@4.21.0__yaml@2.8.2_svelte@5.46.0__acorn@8.15.0_vite@7.3.0__@types+node@22.19.3__tsx@4.21.0__yaml@2.8.2__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.3_tsx@4.21.0_yaml@2.8.2", + "npm:@types/amqplib@*": "0.10.8", "npm:@types/amqplib@~0.10.7": "0.10.8", "npm:@types/eslint@9": "9.6.1", "npm:@types/estree@^1.0.8": "1.0.8", "npm:@types/node@^22.16.0": "22.19.3", "npm:@types/node@^24.2.1": "24.10.4", - "npm:@typescript-eslint/parser@^8.49.0": "8.50.0_eslint@9.39.2_typescript@5.9.3", - "npm:@typescript-eslint/utils@8": "8.50.0_eslint@9.39.2_typescript@5.9.3", + "npm:@typescript-eslint/parser@^8.49.0": "8.50.1_eslint@9.39.2_typescript@5.9.3", + "npm:@typescript-eslint/utils@8": "8.50.1_eslint@9.39.2_typescript@5.9.3", "npm:amqplib@~0.10.8": "0.10.9", "npm:asn1js@^3.0.5": "3.0.7", "npm:asn1js@^3.0.6": "3.0.7", @@ -98,12 +106,12 @@ "npm:shiki@^1.6.4": "1.29.2", "npm:srvx@~0.8.7": "0.8.16", "npm:structured-field-values@^2.0.4": "2.0.4", - "npm:tsdown@~0.12.9": "0.12.9_rolldown@1.0.0-beta.55", + "npm:tsdown@~0.12.9": "0.12.9_rolldown@1.0.0-beta.56", "npm:tsx@^4.19.4": "4.21.0", "npm:uri-template-router@1": "1.0.0", "npm:url-template@^3.1.1": "3.1.1", "npm:urlpattern-polyfill@^10.1.0": "10.1.0", - "npm:wrangler@^4.17.0": "4.56.0_@cloudflare+workers-types@4.20251219.0_unenv@2.0.0-rc.24_workerd@1.20251217.0", + "npm:wrangler@^4.17.0": "4.56.0_@cloudflare+workers-types@4.20251223.0_unenv@2.0.0-rc.24_workerd@1.20251217.0", "npm:yaml@^2.8.1": "2.8.2" }, "jsr": { @@ -156,13 +164,13 @@ "@logtape/logtape@1.3.5": { "integrity": "a5cdb130daf1a9d384006b0f850cc4443bfc2e163dadc6fa667875e79770beb3" }, - "@optique/core@0.6.5": { - "integrity": "6568b8aef8b576e1b9ad8d57d5abdfe4dfeb960953205a1b9dced1426f7e0109" + "@optique/core@0.6.6": { + "integrity": "8043acec7e1a1732ac74c7159255a2935bc1de541f648e0021703863d4624501" }, - "@optique/run@0.6.5": { - "integrity": "1c6ab2606ea4c5d2f6b851a857e95a37634797223e82aa8bc14af42988fc8fa9", + "@optique/run@0.6.6": { + "integrity": "11fc826cecd8aab73c96ce9054eaf9139009e0b106b728686269b7fbe6aa0ffc", "dependencies": [ - "jsr:@optique/core@~0.6.5" + "jsr:@optique/core@~0.6.6" ] }, "@std/assert@0.224.0": { @@ -241,6 +249,9 @@ "jsr:@std/fs@0.224", "jsr:@std/path@0.224" ] + }, + "@std/yaml@1.0.10": { + "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" } }, "npm": { @@ -327,8 +338,8 @@ "os": ["win32"], "cpu": ["x64"] }, - "@cloudflare/workers-types@4.20251219.0": { - "integrity": "sha512-qwuvc3ZDdCfcK9dJrBSFHOsX8kL72sypfBilzEWbbb+slB2NiggjsHeGMV2ZQiQc1zyBMQPjIvsVeE7Apxp7hw==" + "@cloudflare/workers-types@4.20251223.0": { + "integrity": "sha512-r7oxkFjbMcmzhIrzjXaiJlGFDmmeu3+GlwkLlZbUxVWrXHTCkvqu+DrWnNmF6xZEf9j+2/PpuKIS21J522xhJA==" }, "@colors/colors@1.5.0": { "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" @@ -1381,183 +1392,183 @@ "quansync" ] }, - "@rolldown/binding-android-arm64@1.0.0-beta.55": { - "integrity": "sha512-5cPpHdO+zp+klznZnIHRO1bMHDq5hS9cqXodEKAaa/dQTPDjnE91OwAsy3o1gT2x4QaY8NzdBXAvutYdaw0WeA==", + "@rolldown/binding-android-arm64@1.0.0-beta.56": { + "integrity": "sha512-GFsly+vPnl1Sa61sC2LwK4Hrz48W+YBqBmLSxBEj9IJW6nHNsWof1wwh1gwnxMIm/yN5F9M0B/cRAwn6rTINyg==", "os": ["android"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-arm64@1.0.0-beta.55": { - "integrity": "sha512-l0887CGU2SXZr0UJmeEcXSvtDCOhDTTYXuoWbhrEJ58YQhQk24EVhDhHMTyjJb1PBRniUgNc1G0T51eF8z+TWw==", + "@rolldown/binding-darwin-arm64@1.0.0-beta.56": { + "integrity": "sha512-8fSkk5g5MVZpddrH8hOyc9O5t5Dqv2Vi3Qe628xe+2zJedJxucUc5DX/KY1OVBRp8XY09LJO+J1V56LsxeBVPA==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-x64@1.0.0-beta.55": { - "integrity": "sha512-d7qP2AVYzN0tYIP4vJ7nmr26xvmlwdkLD/jWIc9Z9dqh5y0UGPigO3m5eHoHq9BNazmwdD9WzDHbQZyXFZjgtA==", + "@rolldown/binding-darwin-x64@1.0.0-beta.56": { + "integrity": "sha512-R+Q5zd763MKvgYSkBfr2gr/3nZQENaK88qEqfRUUYrpq/W0okOpbOJaxn5FDIIS+yq3cjyktYm115I5RiI6G5A==", "os": ["darwin"], "cpu": ["x64"] }, - "@rolldown/binding-freebsd-x64@1.0.0-beta.55": { - "integrity": "sha512-j311E4NOB0VMmXHoDDZhrWidUf7L/Sa6bu/+i2cskvHKU40zcUNPSYeD2YiO2MX+hhDFa5bJwhliYfs+bTrSZw==", + "@rolldown/binding-freebsd-x64@1.0.0-beta.56": { + "integrity": "sha512-YEsv0rfJoHHRNaVx6AfW/o4bmwTY7BJnSQ45rRCyU6DWEgvFZMojh6qzMQmW5ZVdcikE3cU1ZnrQQ2yem9H9Yg==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55": { - "integrity": "sha512-lAsaYWhfNTW2A/9O7zCpb5eIJBrFeNEatOS/DDOZ5V/95NHy50g4b/5ViCqchfyFqRb7MKUR18/+xWkIcDkeIw==", + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.56": { + "integrity": "sha512-mpaV+NCKcHUOkcAThvz1KiXcNshLQRSBLNNKqum2dG7oLZKk+z+02Fxa8BSuFFqq/rmmO6Fq2TPAdZUgOrwiqw==", "os": ["linux"], "cpu": ["arm"] }, - "@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55": { - "integrity": "sha512-2x6ffiVLZrQv7Xii9+JdtyT1U3bQhKj59K3eRnYlrXsKyjkjfmiDUVx2n+zSyijisUqD62fcegmx2oLLfeTkCA==", + "@rolldown/binding-linux-arm64-gnu@1.0.0-beta.56": { + "integrity": "sha512-wj1uQRN4GEhYw5cs0dobGzZg3oKMLuQ3hY3fW7cLzvlwi9XRdzW7NmU58e6YUp6boOQLarSxdmAaqCMgaMZfcQ==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-arm64-musl@1.0.0-beta.55": { - "integrity": "sha512-QbNncvqAXziya5wleI+OJvmceEE15vE4yn4qfbI/hwT/+8ZcqxyfRZOOh62KjisXxp4D0h3JZspycXYejxAU3w==", + "@rolldown/binding-linux-arm64-musl@1.0.0-beta.56": { + "integrity": "sha512-Z2PWbAHjW2EUflb1/tPvouMqppwWF5Va1Y9b4GQpO6QlpGK0Wqmn90GO2VKiheDh/gSZlsxZ7uOZoXh2y8R7Kg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-x64-gnu@1.0.0-beta.55": { - "integrity": "sha512-YZCTZZM+rujxwVc6A+QZaNMJXVtmabmFYLG2VGQTKaBfYGvBKUgtbMEttnp/oZ88BMi2DzadBVhOmfQV8SuHhw==", + "@rolldown/binding-linux-x64-gnu@1.0.0-beta.56": { + "integrity": "sha512-Z/uv04/Tsf7oqhwjPUiDiSildhWmCpsklA0e5PEB+0eGGmm07B+M2SmqRe9Fd0ypfU2TPGhq+Hn7RVUGIfSMxg==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-linux-x64-musl@1.0.0-beta.55": { - "integrity": "sha512-28q9OQ/DDpFh2keS4BVAlc3N65/wiqKbk5K1pgLdu/uWbKa8hgUJofhXxqO+a+Ya2HVTUuYHneWsI2u+eu3N5Q==", + "@rolldown/binding-linux-x64-musl@1.0.0-beta.56": { + "integrity": "sha512-u+yP0Pt9ar3PkLGGiyGmQKVj9j20X0E831DY0OVmbKYHAAbTyLKYx+UIIorCm+SQnhGKfkD+0pmwfTc2t2Vt/g==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-openharmony-arm64@1.0.0-beta.55": { - "integrity": "sha512-LiCA4BjCnm49B+j1lFzUtlC+4ZphBv0d0g5VqrEJua/uyv9Ey1v9tiaMql1C8c0TVSNDUmrkfHQ71vuQC7YfpQ==", + "@rolldown/binding-openharmony-arm64@1.0.0-beta.56": { + "integrity": "sha512-Kuc6r5Uya+KxdJ7MUSok3K8zta/1bcsaSNxTvYujm2mWYuffadqgkkR3d0UCRbbCH5klZ+7VG6DR3VtPRlCntw==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rolldown/binding-wasm32-wasi@1.0.0-beta.55": { - "integrity": "sha512-nZ76tY7T0Oe8vamz5Cv5CBJvrqeQxwj1WaJ2GxX8Msqs0zsQMMcvoyxOf0glnJlxxgKjtoBxAOxaAU8ERbW6Tg==", + "@rolldown/binding-wasm32-wasi@1.0.0-beta.56": { + "integrity": "sha512-pejT5oLj8xlfn8tjC3bJKeuAsk/un6GKwjbsBQG0AchefdaHf2+S4QRn8XfEMB1l1ZTbe5yEiiV92mr7Jdjaeg==", "dependencies": [ "@napi-rs/wasm-runtime" ], "cpu": ["wasm32"] }, - "@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55": { - "integrity": "sha512-TFVVfLfhL1G+pWspYAgPK/FSqjiBtRKYX9hixfs508QVEZPQlubYAepHPA7kEa6lZXYj5ntzF87KC6RNhxo+ew==", + "@rolldown/binding-win32-arm64-msvc@1.0.0-beta.56": { + "integrity": "sha512-1NKkRLQR2ghmHMd+14nm1noOhoLei62pkdGlf1g4F+9lfFws66+9LBnP6Z+E+KK8Do9hzQ6FFRwtkC3EADAeyA==", "os": ["win32"], "cpu": ["arm64"] }, - "@rolldown/binding-win32-x64-msvc@1.0.0-beta.55": { - "integrity": "sha512-j1WBlk0p+ISgLzMIgl0xHp1aBGXenoK2+qWYc/wil2Vse7kVOdFq9aeQ8ahK6/oxX2teQ5+eDvgjdywqTL+daA==", + "@rolldown/binding-win32-x64-msvc@1.0.0-beta.56": { + "integrity": "sha512-BC3mObCr7/O+1jMJ/Hm3INikBk5D25RTxCha10Rq8b1gHlBfb9eA460+7xQfc8FxUsMCUgHtvrK3Vs5izgwBOQ==", "os": ["win32"], "cpu": ["x64"] }, - "@rolldown/pluginutils@1.0.0-beta.55": { - "integrity": "sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==" + "@rolldown/pluginutils@1.0.0-beta.56": { + "integrity": "sha512-cw9jwAgCs024Nic4OB8PeFDLBHLD1Athcv3bRvyYATIVD9B/gL5X5cJkezT94Y7m7Dk9HXaUMcvb7ypvSX46sA==" }, - "@rollup/rollup-android-arm-eabi@4.53.5": { - "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "@rollup/rollup-android-arm-eabi@4.54.0": { + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.53.5": { - "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "@rollup/rollup-android-arm64@4.54.0": { + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.53.5": { - "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "@rollup/rollup-darwin-arm64@4.54.0": { + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.53.5": { - "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "@rollup/rollup-darwin-x64@4.54.0": { + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.53.5": { - "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "@rollup/rollup-freebsd-arm64@4.54.0": { + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.53.5": { - "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "@rollup/rollup-freebsd-x64@4.54.0": { + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.53.5": { - "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "@rollup/rollup-linux-arm-gnueabihf@4.54.0": { + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.53.5": { - "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "@rollup/rollup-linux-arm-musleabihf@4.54.0": { + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.53.5": { - "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "@rollup/rollup-linux-arm64-gnu@4.54.0": { + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.53.5": { - "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "@rollup/rollup-linux-arm64-musl@4.54.0": { + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loong64-gnu@4.53.5": { - "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "@rollup/rollup-linux-loong64-gnu@4.54.0": { + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-ppc64-gnu@4.53.5": { - "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "@rollup/rollup-linux-ppc64-gnu@4.54.0": { + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.53.5": { - "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "@rollup/rollup-linux-riscv64-gnu@4.54.0": { + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.53.5": { - "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "@rollup/rollup-linux-riscv64-musl@4.54.0": { + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.53.5": { - "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "@rollup/rollup-linux-s390x-gnu@4.54.0": { + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.53.5": { - "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "@rollup/rollup-linux-x64-gnu@4.54.0": { + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.53.5": { - "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "@rollup/rollup-linux-x64-musl@4.54.0": { + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-openharmony-arm64@4.53.5": { - "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "@rollup/rollup-openharmony-arm64@4.54.0": { + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-arm64-msvc@4.53.5": { - "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "@rollup/rollup-win32-arm64-msvc@4.54.0": { + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.53.5": { - "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "@rollup/rollup-win32-ia32-msvc@4.54.0": { + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-gnu@4.53.5": { - "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "@rollup/rollup-win32-x64-gnu@4.54.0": { + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "os": ["win32"], "cpu": ["x64"] }, - "@rollup/rollup-win32-x64-msvc@4.53.5": { - "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "@rollup/rollup-win32-x64-msvc@4.54.0": { + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "os": ["win32"], "cpu": ["x64"] }, @@ -1766,8 +1777,8 @@ "@types/wrap-ansi@3.0.0": { "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==" }, - "@typescript-eslint/parser@8.50.0_eslint@9.39.2_typescript@5.9.3": { - "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "@typescript-eslint/parser@8.50.1_eslint@9.39.2_typescript@5.9.3": { + "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dependencies": [ "@typescript-eslint/scope-manager", "@typescript-eslint/types", @@ -1778,8 +1789,8 @@ "typescript" ] }, - "@typescript-eslint/project-service@8.50.0_typescript@5.9.3": { - "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "@typescript-eslint/project-service@8.50.1_typescript@5.9.3": { + "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", "dependencies": [ "@typescript-eslint/tsconfig-utils", "@typescript-eslint/types", @@ -1787,24 +1798,24 @@ "typescript" ] }, - "@typescript-eslint/scope-manager@8.50.0": { - "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "@typescript-eslint/scope-manager@8.50.1": { + "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/visitor-keys" ] }, - "@typescript-eslint/tsconfig-utils@8.50.0_typescript@5.9.3": { - "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "@typescript-eslint/tsconfig-utils@8.50.1_typescript@5.9.3": { + "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", "dependencies": [ "typescript" ] }, - "@typescript-eslint/types@8.50.0": { - "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==" + "@typescript-eslint/types@8.50.1": { + "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==" }, - "@typescript-eslint/typescript-estree@8.50.0_typescript@5.9.3": { - "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "@typescript-eslint/typescript-estree@8.50.1_typescript@5.9.3": { + "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", "dependencies": [ "@typescript-eslint/project-service", "@typescript-eslint/tsconfig-utils", @@ -1818,8 +1829,8 @@ "typescript" ] }, - "@typescript-eslint/utils@8.50.0_eslint@9.39.2_typescript@5.9.3": { - "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "@typescript-eslint/utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { + "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", "dependencies": [ "@eslint-community/eslint-utils", "@typescript-eslint/scope-manager", @@ -1829,8 +1840,8 @@ "typescript" ] }, - "@typescript-eslint/visitor-keys@8.50.0": { - "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "@typescript-eslint/visitor-keys@8.50.1": { + "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", "dependencies": [ "@typescript-eslint/types", "eslint-visitor-keys@4.2.1" @@ -2634,18 +2645,12 @@ "regexparam" ] }, -<<<<<<< HEAD - "fflate@0.8.2": { - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" - }, "file-entry-cache@8.0.0": { "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dependencies": [ "flat-cache" ] }, -======= ->>>>>>> ac923ced (Fix deno.lock) "file-type@16.5.4": { "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", "dependencies": [ @@ -3770,7 +3775,7 @@ "rfdc@1.4.1": { "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" }, - "rolldown-plugin-dts@0.13.14_rolldown@1.0.0-beta.55": { + "rolldown-plugin-dts@0.13.14_rolldown@1.0.0-beta.56": { "integrity": "sha512-wjNhHZz9dlN6PTIXyizB6u/mAg1wEFMW9yw7imEVe3CxHSRnNHVyycIX0yDEOVJfDNISLPbkCIPEpFpizy5+PQ==", "dependencies": [ "@babel/generator", @@ -3784,13 +3789,8 @@ "rolldown" ] }, -<<<<<<< HEAD - "rolldown@1.0.0-beta.55": { - "integrity": "sha512-r8Ws43aYCnfO07ao0SvQRz4TBAtZJjGWNvScRBOHuiNHvjfECOJBIqJv0nUkL1GYcltjvvHswRilDF1ocsC0+g==", -======= "rolldown@1.0.0-beta.56": { "integrity": "sha512-9MHiUvRH2R8rb6ad6EaLxahS3RbQKdMMlrh9XKmbz2HiCGfK4IWKSNv4N6GhYr+7kHExg6oIc5EF1xA3iR4x1A==", ->>>>>>> ac923ced (Fix deno.lock) "dependencies": [ "@oxc-project/types", "@rolldown/pluginutils" @@ -3812,8 +3812,8 @@ ], "bin": true }, - "rollup@4.53.5": { - "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "rollup@4.54.0": { + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dependencies": [ "@types/estree" ], @@ -4239,7 +4239,7 @@ "typescript" ] }, - "tsdown@0.12.9_rolldown@1.0.0-beta.55": { + "tsdown@0.12.9_rolldown@1.0.0-beta.56": { "integrity": "sha512-MfrXm9PIlT3saovtWKf/gCJJ/NQCdE0SiREkdNC+9Qy6UHhdeDPxnkFaBD7xttVUmgp0yUHtGirpoLB+OVLuLA==", "dependencies": [ "ansis", @@ -4511,7 +4511,7 @@ "scripts": true, "bin": true }, - "wrangler@4.56.0_@cloudflare+workers-types@4.20251219.0_unenv@2.0.0-rc.24_workerd@1.20251217.0": { + "wrangler@4.56.0_@cloudflare+workers-types@4.20251223.0_unenv@2.0.0-rc.24_workerd@1.20251217.0": { "integrity": "sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==", "dependencies": [ "@cloudflare/kv-asset-handler", From 46b317c682766558f8b0913d41ed2469b125a96d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 25 Dec 2025 14:53:30 +0900 Subject: [PATCH 45/45] Keep BaseRelay only for internal --- packages/relay/src/mod.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/relay/src/mod.ts b/packages/relay/src/mod.ts index 6092f0f34..dcdf5b9da 100644 --- a/packages/relay/src/mod.ts +++ b/packages/relay/src/mod.ts @@ -7,7 +7,6 @@ * * @module */ -export { BaseRelay } from "./base.ts"; export { relayBuilder } from "./builder.ts"; export { createRelay } from "./factory.ts"; export { LitePubRelay } from "./litepub.ts";