From 7cf788b612aad0d6a2357a88753f60a4d4307f5b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 16:59:42 +0900 Subject: [PATCH 01/64] Add client outbox listener hooks Add typed outbox listeners for POST requests to actor outboxes so Fedify applications can handle client-to-server activities with the same routing model as inbox listeners. Keep authorization application-defined, leave federation delivery explicit through ctx.sendActivity(), and mirror the new outbox context helpers in the testing packages. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/builder.test.ts | 53 +++++ packages/fedify/src/federation/builder.ts | 89 +++++++- packages/fedify/src/federation/callback.ts | 34 ++- packages/fedify/src/federation/context.ts | 22 ++ packages/fedify/src/federation/federation.ts | 73 +++++++ .../fedify/src/federation/handler.test.ts | 194 +++++++++++++++++- packages/fedify/src/federation/handler.ts | 177 +++++++++++++++- .../fedify/src/federation/middleware.test.ts | 123 +++++++++++ packages/fedify/src/federation/middleware.ts | 87 ++++++++ packages/fedify/src/federation/outbox.ts | 59 ++++++ packages/fedify/src/testing/context.ts | 16 ++ packages/fedify/src/testing/mod.ts | 6 +- packages/testing/src/context.ts | 38 +++- packages/testing/src/mock.test.ts | 31 ++- packages/testing/src/mock.ts | 31 ++- packages/testing/src/mod.ts | 2 + 16 files changed, 1018 insertions(+), 17 deletions(-) create mode 100644 packages/fedify/src/federation/outbox.ts diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index fbd9756c0..b5f5e86ea 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -8,10 +8,12 @@ import type { InboxListener, NodeInfoDispatcher, ObjectDispatcher, + OutboxListener, UnverifiedActivityReason, } from "./callback.ts"; import { MemoryKvStore } from "./kv.ts"; import type { FederationImpl } from "./middleware.ts"; +import { RouterError } from "./router.ts"; test("FederationBuilder", async (t) => { await t.step( @@ -34,6 +36,15 @@ test("FederationBuilder", async (t) => { const listeners = builder.setInboxListeners("/users/{identifier}/inbox"); listeners.on(Activity, inboxListener); + const outboxListener: OutboxListener = ( + _ctx, + _activity, + ) => { + // Do nothing + }; + builder.setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, outboxListener); + const objectDispatcher: ObjectDispatcher = ( _ctx, _values, @@ -73,6 +84,7 @@ test("FederationBuilder", async (t) => { ); assertEquals(impl.router.route("/users/test123")?.name, "actor"); assertEquals(impl.router.route("/users/test123/inbox")?.name, "inbox"); + assertEquals(impl.router.route("/users/test123/outbox")?.name, "outbox"); assertEquals( impl.router.route("/notes/456")?.name, `object:${Note.typeId.href}`, @@ -85,6 +97,9 @@ test("FederationBuilder", async (t) => { const inboxListeners = impl.inboxListeners; assertExists(inboxListeners); + const outboxListeners = impl.outboxListeners; + assertExists(outboxListeners); + assertExists(impl.objectCallbacks[Note.typeId.href]); assertExists(impl.nodeInfoDispatcher); @@ -117,6 +132,44 @@ test("FederationBuilder", async (t) => { assertEquals(impl.kv, kv); }); + await t.step("should validate outbox listener paths", () => { + const builder = createFederationBuilder(); + builder.setOutboxDispatcher( + "/users/{identifier}/outbox", + () => ({ items: [] }), + ); + + assertThrows( + () => builder.setOutboxListeners("/actors/{identifier}/outbox"), + RouterError, + ); + + assertThrows( + () => + builder.setOutboxListeners( + "/users/outbox" as `${string}{identifier}${string}`, + ), + RouterError, + ); + + assertThrows( + () => builder.setOutboxListeners("/users/{identifier}/outbox/{extra}"), + RouterError, + ); + + const builder2 = createFederationBuilder(); + builder2.setOutboxListeners("/users/{identifier}/outbox"); + + assertThrows( + () => + builder2.setOutboxDispatcher( + "/actors/{identifier}/outbox", + () => ({ items: [] }), + ), + RouterError, + ); + }); + await t.step("should pass build options correctly", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 12860f57b..727ff7274 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -28,6 +28,8 @@ import type { NodeInfoDispatcher, ObjectAuthorizePredicate, ObjectDispatcher, + OutboxListener, + OutboxListenerErrorHandler, OutboxPermanentFailureHandler, SharedInboxKeyDispatcher, UnverifiedActivityHandler, @@ -46,6 +48,7 @@ import type { IdempotencyStrategy, InboxListenerSetters, ObjectCallbackSetters, + OutboxListenerSetters, Rfc6570Expression, } from "./federation.ts"; import type { @@ -53,6 +56,7 @@ import type { CustomCollectionCallbacks, } from "./handler.ts"; import { InboxListenerSet } from "./inbox.ts"; +import { OutboxListenerSet } from "./outbox.ts"; import { Router, RouterError } from "./router.ts"; export class FederationBuilderImpl @@ -64,6 +68,7 @@ export class FederationBuilderImpl objectCallbacks: Record>; objectTypeIds: Record>; inboxPath?: string; + outboxPath?: string; inboxCallbacks?: CollectionCallbacks< Activity, RequestContext, @@ -107,7 +112,10 @@ export class FederationBuilderImpl void >; inboxListeners?: InboxListenerSet; + outboxListeners?: OutboxListenerSet; inboxErrorHandler?: InboxErrorHandler; + outboxListenerErrorHandler?: OutboxListenerErrorHandler; + outboxAuthorizePredicate?: AuthorizePredicate; sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher; unverifiedActivityHandler?: UnverifiedActivityHandler; outboxPermanentFailureHandler?: OutboxPermanentFailureHandler; @@ -166,6 +174,7 @@ export class FederationBuilderImpl f.objectCallbacks = { ...this.objectCallbacks }; f.objectTypeIds = { ...this.objectTypeIds }; f.inboxPath = this.inboxPath; + f.outboxPath = this.outboxPath; f.inboxCallbacks = this.inboxCallbacks == null ? undefined : { ...this.inboxCallbacks }; @@ -188,7 +197,10 @@ export class FederationBuilderImpl ? undefined : { ...this.featuredTagsCallbacks }; f.inboxListeners = this.inboxListeners?.clone(); + f.outboxListeners = this.outboxListeners?.clone(); f.inboxErrorHandler = this.inboxErrorHandler; + f.outboxListenerErrorHandler = this.outboxListenerErrorHandler; + f.outboxAuthorizePredicate = this.outboxAuthorizePredicate; f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher; f.unverifiedActivityHandler = this.unverifiedActivityHandler; f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler; @@ -711,17 +723,26 @@ export class FederationBuilderImpl TContextData, void > { - if (this.router.has("outbox")) { + if (this.outboxCallbacks != null) { throw new RouterError("Outbox dispatcher already set."); } - const variables = this.router.add(path, "outbox"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for outbox dispatcher must have one variable: {identifier}", - ); + if (this.router.has("outbox")) { + if (this.outboxPath !== path) { + throw new RouterError( + "Outbox listener path must match outbox dispatcher path.", + ); + } + } else { + const variables = this.router.add(path, "outbox"); + if ( + variables.size !== 1 || + !variables.has("identifier") + ) { + throw new RouterError( + "Path for outbox dispatcher must have one variable: {identifier}", + ); + } + this.outboxPath = path; } const callbacks: CollectionCallbacks< Activity, @@ -767,6 +788,56 @@ export class FederationBuilderImpl return setters; } + setOutboxListeners( + outboxPath: `${string}{identifier}${string}`, + ): OutboxListenerSetters { + if (this.outboxListeners != null) { + throw new RouterError("Outbox listeners already set."); + } + if (this.router.has("outbox")) { + if (this.outboxPath !== outboxPath) { + throw new RouterError( + "Outbox listener path must match outbox dispatcher path.", + ); + } + } else { + const variables = this.router.add(outboxPath, "outbox"); + if ( + variables.size !== 1 || + !variables.has("identifier") + ) { + throw new RouterError( + "Path for outbox must have one variable: {identifier}", + ); + } + this.outboxPath = outboxPath; + } + const listeners = this.outboxListeners = new OutboxListenerSet(); + const setters: OutboxListenerSetters = { + on( + // deno-lint-ignore no-explicit-any + type: new (...args: any[]) => TActivity, + listener: OutboxListener, + ): OutboxListenerSetters { + listeners.add(type, listener as OutboxListener); + return setters; + }, + onError: ( + handler: OutboxListenerErrorHandler, + ): OutboxListenerSetters => { + this.outboxListenerErrorHandler = handler; + return setters; + }, + authorize: ( + predicate: AuthorizePredicate, + ): OutboxListenerSetters => { + this.outboxAuthorizePredicate = predicate; + return setters; + }, + }; + return setters; + } + setFollowingDispatcher( path: `${string}{identifier}${string}`, dispatcher: CollectionDispatcher< diff --git a/packages/fedify/src/federation/callback.ts b/packages/fedify/src/federation/callback.ts index 3b74cf24e..1643532e5 100644 --- a/packages/fedify/src/federation/callback.ts +++ b/packages/fedify/src/federation/callback.ts @@ -3,7 +3,12 @@ import type { Link } from "@fedify/webfinger"; import type { VerifyRequestFailureReason } from "../sig/http.ts"; import type { NodeInfo } from "../nodeinfo/types.ts"; import type { PageItems } from "./collection.ts"; -import type { Context, InboxContext, RequestContext } from "./context.ts"; +import type { + Context, + InboxContext, + OutboxContext, + RequestContext, +} from "./context.ts"; import type { SendActivityError, SenderKeyPair } from "./send.ts"; /** @@ -181,6 +186,20 @@ export type InboxListener = ( activity: TActivity, ) => void | Promise; +/** + * A callback that listens for activities in an outbox. + * + * @template TContextData The context data to pass to the {@link Context}. + * @template TActivity The type of activity to listen for. + * @param context The outbox context. + * @param activity The activity that was received. + * @since 2.2.0 + */ +export type OutboxListener = ( + context: OutboxContext, + activity: TActivity, +) => void | Promise; + /** * The reason why an incoming activity could not be verified. * @@ -221,6 +240,19 @@ export type InboxErrorHandler = ( error: Error, ) => void | Promise; +/** + * A callback that handles errors in an outbox listener. + * + * @template TContextData The context data to pass to the {@link Context}. + * @param context The outbox context. + * @param error The error that occurred. + * @since 2.2.0 + */ +export type OutboxListenerErrorHandler = ( + context: OutboxContext, + error: Error, +) => void | Promise; + /** * A callback that dispatches the key pair for the authenticated document loader * of the {@link Context} passed to the shared inbox listener. diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 7d516f1ac..0b224b293 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -687,6 +687,28 @@ export interface InboxContext extends Context { ): Promise; } +/** + * A context for outbox listeners. + * @since 2.2.0 + */ +export interface OutboxContext extends Context { + /** + * The identifier of the actor whose outbox received the POST. + * @since 2.2.0 + */ + readonly identifier: string; + + /** + * Creates a new context with the same properties as this one, + * but with the given data. + * @param data The new data to associate with the context. + * @returns A new context with the same properties as this one, + * but with the given data. + * @since 2.2.0 + */ + clone(data: TContextData): OutboxContext; +} + /** * A result of parsing an URI. */ diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 979bf42cc..3d8480ad7 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -31,6 +31,8 @@ import type { ObjectAuthorizePredicate, ObjectDispatcher, OutboxErrorHandler, + OutboxListener, + OutboxListenerErrorHandler, OutboxPermanentFailureHandler, SharedInboxKeyDispatcher, UnverifiedActivityHandler, @@ -308,6 +310,34 @@ export interface Federatable { void >; + /** + * Assigns the URL path for the outbox and starts setting outbox listeners. + * + * @example + * ``` typescript + * federation + * .setOutboxListeners("/users/{identifier}/outbox") + * .on(Activity, async (ctx, activity) => { + * await ctx.sendActivity({ identifier: ctx.identifier }, "followers", activity); + * }) + * .authorize(async (ctx, identifier) => { + * return ctx.request.headers.get("authorization") === `Bearer ${identifier}`; + * }); + * ``` + * + * @param outboxPath The URI path pattern for the outbox. The syntax is based + * on URI Template + * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The + * path must have one variable: `{identifier}`, and must + * match the outbox dispatcher path. + * @returns An object to register outbox listeners. + * @throws {RouterError} Thrown if the path pattern is invalid. + * @since 2.2.0 + */ + setOutboxListeners( + outboxPath: `${string}${Rfc6570Expression<"identifier">}${string}`, + ): OutboxListenerSetters; + /** * Registers a following collection dispatcher. * @param path The URI path pattern for the following collection. The syntax @@ -1193,6 +1223,49 @@ export type IdempotencyKeyCallback = ( activity: Activity, ) => string | null | Promise; +/** + * Registry for outbox listeners for different activity types. + * @since 2.2.0 + */ +export interface OutboxListenerSetters { + /** + * Registers a listener for a specific incoming activity type. + * + * @param type A subclass of {@link Activity} to listen to. + * @param listener A callback to handle an incoming activity. + * @returns The setters object so that settings can be chained. + * @since 2.2.0 + */ + on( + // deno-lint-ignore no-explicit-any + type: new (...args: any[]) => TActivity, + listener: OutboxListener, + ): OutboxListenerSetters; + + /** + * Registers an error handler for outbox listeners. Any exceptions thrown + * from the listeners are caught and passed to this handler. + * + * @param handler A callback to handle an error. + * @returns The setters object so that settings can be chained. + * @since 2.2.0 + */ + onError( + handler: OutboxListenerErrorHandler, + ): OutboxListenerSetters; + + /** + * Registers a callback to authorize POST requests to the outbox. + * + * @param predicate A callback to authorize the request. + * @returns The setters object so that settings can be chained. + * @since 2.2.0 + */ + authorize( + predicate: AuthorizePredicate, + ): OutboxListenerSetters; +} + /** * Registry for inbox listeners for different activity types. */ diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 8cfe6b72c..add2deda1 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -4,7 +4,7 @@ import { test, } from "@fedify/fixture"; import { - type Activity, + Activity, Create, Note, type Object, @@ -17,6 +17,7 @@ import { parseAcceptSignature } from "../sig/accept.ts"; import { signRequest } from "../sig/http.ts"; import { createInboxContext, + createOutboxContext, createRequestContext, } from "../testing/context.ts"; import { @@ -43,12 +44,14 @@ import { handleCustomCollection, handleInbox, handleObject, + handleOutbox, respondWithObject, respondWithObjectIfAcceptable, } from "./handler.ts"; import { InboxListenerSet } from "./inbox.ts"; import { MemoryKvStore } from "./kv.ts"; import { createFederation } from "./middleware.ts"; +import { OutboxListenerSet } from "./outbox.ts"; const QUOTE_CONTEXT_TERMS = { QuoteAuthorization: "https://w3id.org/fep/044f#QuoteAuthorization", @@ -1332,6 +1335,195 @@ test("handleInbox()", async () => { assertEquals(response.status, 400); }); +test("handleOutbox()", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/1"), + attribution: new URL("https://example.com/person2"), + content: "Hello, world!", + }), + }); + const request = new Request("https://example.com/users/someone/outbox", { + method: "POST", + body: JSON.stringify(await activity.toJsonLd()), + }); + const federation = createFederation({ kv: new MemoryKvStore() }); + const context = createRequestContext({ + federation, + request, + url: new URL(request.url), + data: undefined, + }); + let onNotFoundCalled: Request | null = null; + const onNotFound = (request: Request) => { + onNotFoundCalled = request; + return new Response("Not found", { status: 404 }); + }; + let onUnauthorizedCalled: Request | null = null; + const onUnauthorized = (request: Request) => { + onUnauthorizedCalled = request; + return new Response("Unauthorized", { status: 401 }); + }; + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; + return new Person({ name: "Someone" }); + }; + const listeners = new OutboxListenerSet(); + const seen: string[] = []; + listeners.add(Activity, (ctx, activity) => { + seen.push(`${ctx.identifier}:${activity.id?.href}`); + }); + + let response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher: undefined, + outboxListeners: listeners, + onNotFound, + onUnauthorized, + }); + assertEquals(onNotFoundCalled, request); + assertEquals(response.status, 404); + + onNotFoundCalled = null; + response = await handleOutbox(request, { + identifier: "nobody", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + onNotFound, + onUnauthorized, + }); + assertEquals(onNotFoundCalled, request); + assertEquals(response.status, 404); + + onNotFoundCalled = null; + response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + authorizePredicate: () => false, + onNotFound, + onUnauthorized, + }); + assertEquals(onNotFoundCalled, null); + assertEquals(onUnauthorizedCalled, request); + assertEquals(response.status, 401); + assertEquals(seen, []); + + onUnauthorizedCalled = null; + response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + authorizePredicate: () => true, + onNotFound, + onUnauthorized, + }); + assertEquals(onUnauthorizedCalled, null); + assertEquals([response.status, await response.text()], [202, ""]); + assertEquals(seen, [ + `someone:${activity.id?.href}`, + ]); + + const invalidRequest = new Request( + "https://example.com/users/someone/outbox", + { + method: "POST", + body: JSON.stringify({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + true, + 23, + ], + type: "Create", + object: { type: "Note", content: "Hello, world!" }, + actor: "https://example.com/users/alice", + }), + }, + ); + const invalidContext = createRequestContext({ + federation, + request: invalidRequest, + url: new URL(invalidRequest.url), + data: undefined, + }); + response = await handleOutbox(invalidRequest, { + identifier: "someone", + context: invalidContext, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...invalidContext, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + onNotFound, + onUnauthorized, + }); + assertEquals(response.status, 400); + + const throwingListeners = new OutboxListenerSet(); + let onErrorCalled = false; + throwingListeners.add(Create, () => { + throw new Error("Boom"); + }); + response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: throwingListeners, + outboxErrorHandler: (_ctx, _error) => { + onErrorCalled = true; + }, + onNotFound, + onUnauthorized, + }); + assertEquals(response.status, 500); + assertEquals(onErrorCalled, true); +}); + test("respondWithObject()", async () => { const response = await respondWithObject( new Note({ diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 024035123..afaf8a192 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -43,10 +43,16 @@ import type { InboxErrorHandler, ObjectAuthorizePredicate, ObjectDispatcher, + OutboxListenerErrorHandler, UnverifiedActivityHandler, } from "./callback.ts"; import type { PageItems } from "./collection.ts"; -import type { Context, InboxContext, RequestContext } from "./context.ts"; +import type { + Context, + InboxContext, + OutboxContext, + RequestContext, +} from "./context.ts"; import type { ConstructorWithTypeId, IdempotencyKeyCallback, @@ -58,6 +64,7 @@ import { KvKeyCache } from "./keycache.ts"; import type { KvKey, KvStore } from "./kv.ts"; import type { MessageQueue } from "./mq.ts"; import { acceptsJsonLd } from "./negotiation.ts"; +import type { OutboxListenerSet } from "./outbox.ts"; /** * Parameters for handling an actor request. @@ -462,6 +469,174 @@ function filterCollectionItems( return result; } +/** + * Parameters for handling an outbox POST request. + * @template TContextData The context data to pass to the context. + */ +export interface OutboxHandlerParameters { + identifier: string; + context: RequestContext; + outboxContextFactory( + identifier: string, + activity: unknown, + activityId: string | undefined, + activityType: string, + ): OutboxContext; + actorDispatcher?: ActorDispatcher; + authorizePredicate?: AuthorizePredicate; + outboxListeners?: OutboxListenerSet; + outboxErrorHandler?: OutboxListenerErrorHandler; + onUnauthorized(request: Request): Response | Promise; + onNotFound(request: Request): Response | Promise; +} + +/** + * Handles an outbox POST request. + * @template TContextData The context data to pass to the context. + * @param request The HTTP request. + * @param parameters The parameters for handling the request. + * @returns A promise that resolves to an HTTP response. + * @since 2.2.0 + */ +export async function handleOutbox( + request: Request, + { + identifier, + context: ctx, + outboxContextFactory, + actorDispatcher, + authorizePredicate, + outboxListeners, + outboxErrorHandler, + onUnauthorized, + onNotFound, + }: OutboxHandlerParameters, +): Promise { + const logger = getLogger(["fedify", "federation", "outbox"]); + if (actorDispatcher == null) { + logger.error("Actor dispatcher is not set.", { identifier }); + return await onNotFound(request); + } + const actor = await actorDispatcher(ctx, identifier); + if (actor == null || actor instanceof Tombstone) { + logger.error("Actor {identifier} not found.", { identifier }); + return await onNotFound(request); + } + if (authorizePredicate != null) { + if (!await authorizePredicate(ctx, identifier)) { + return await onUnauthorized(request); + } + } + if (request.bodyUsed) { + logger.error("Request body has already been read.", { identifier }); + return new Response("Internal server error.", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } else if (request.body?.locked) { + logger.error("Request body is locked.", { identifier }); + return new Response("Internal server error.", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + let json: unknown; + try { + json = await request.clone().json(); + } catch (error) { + logger.error("Failed to parse JSON:\n{error}", { identifier, error }); + const outboxContext = outboxContextFactory(identifier, null, undefined, ""); + try { + await outboxErrorHandler?.(outboxContext, error as Error); + } catch (error) { + logger.error( + "An unexpected error occurred in outbox error handler:\n{error}", + { error, identifier }, + ); + } + return new Response("Invalid JSON.", { + status: 400, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + let activity: Activity; + try { + activity = await Activity.fromJsonLd(json, ctx); + } catch (error) { + logger.error("Failed to parse activity:\n{error}", { + identifier, + activity: json, + error, + }); + const outboxContext = outboxContextFactory(identifier, json, undefined, ""); + try { + await outboxErrorHandler?.(outboxContext, error as Error); + } catch (error) { + logger.error( + "An unexpected error occurred in outbox error handler:\n{error}", + { error, activity: json, identifier }, + ); + } + return new Response("Invalid activity.", { + status: 400, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + const dispatched = outboxListeners?.dispatchWithClass(activity); + if (dispatched == null) { + logger.debug("Unsupported activity type:\n{activity}", { + identifier, + activity: json, + }); + return new Response(null, { status: 202 }); + } + const outboxContext = outboxContextFactory( + identifier, + json, + activity.id?.href, + getTypeId(activity).href, + ); + try { + await dispatched.listener(outboxContext, activity); + } catch (error) { + try { + await outboxErrorHandler?.(outboxContext, error as Error); + } catch (error) { + logger.error( + "An unexpected error occurred in outbox error handler:\n{error}", + { + error, + activityId: activity.id?.href, + activity: json, + identifier, + }, + ); + } + logger.error( + "Failed to process the incoming activity {activityId}:\n{error}", + { + error, + activityId: activity.id?.href, + activity: json, + identifier, + }, + ); + return new Response("Internal server error.", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + logger.info( + "Activity {activityId} has been processed in outbox listener.", + { + activityId: activity.id?.href, + activity: json, + identifier, + }, + ); + return new Response(null, { status: 202 }); +} + /** * Parameters for handling an inbox request. * @template TContextData The context data to pass to the context. diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 1711514f9..3bc784499 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2062,6 +2062,129 @@ test("Federation.setInboxListeners()", async (t) => { fetchMock.hardReset(); }); +test("Federation.setOutboxListeners()", async (t) => { + const kv = new MemoryKvStore(); + + await t.step("path match", () => { + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation.setOutboxDispatcher( + "/users/{identifier}/outbox", + () => ({ items: [] }), + ); + assertThrows( + () => federation.setOutboxListeners("/users/{identifier}/outbox2"), + RouterError, + ); + }); + + await t.step("on() and authorize()", async () => { + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + const received: string[] = []; + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxDispatcher( + "/users/{identifier}/outbox", + () => ({ items: [] }), + ) + .authorize((_ctx, identifier) => identifier === "john"); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, (ctx, activity) => { + received.push(`${ctx.identifier}:${activity.id?.href}`); + }) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + let response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(createFixture), + headers: { + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 401); + assertEquals(received, []); + + response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(createFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + assertEquals([response.status, await response.text()], [202, ""]); + assertEquals(received, [ + `john:${createFixture.id}`, + ]); + + response = await federation.fetch( + new Request("https://example.com/users/no-one/outbox", { + method: "POST", + body: JSON.stringify(createFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 404); + }); + + await t.step("POST without listeners returns 405", async () => { + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher( + "/users/{identifier}", + () => new vocab.Person({}), + ); + federation.setOutboxDispatcher( + "/users/{identifier}/outbox", + () => ({ items: [] }), + ); + + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(createFixture), + headers: { + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 405); + }); +}); + test("Federation.setInboxDispatcher()", async (t) => { const kv = new MemoryKvStore(); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index e9050549e..b4f907b7c 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -74,6 +74,7 @@ import type { GetActorOptions, GetSignedKeyOptions, InboxContext, + OutboxContext, ParseUriResult, RequestContext, RouteActivityOptions, @@ -94,6 +95,7 @@ import { handleInbox, handleObject, handleOrderedCollection, + handleOutbox, } from "./handler.ts"; import { routeActivity } from "./inbox.ts"; import { KvKeyCache } from "./keycache.ts"; @@ -1453,6 +1455,28 @@ export class FederationImpl }); } case "outbox": + if (request.method === "POST") { + if (this.outboxListeners == null) { + return new Response("Method not allowed.", { + status: 405, + headers: { + Allow: "GET, HEAD", + "Content-Type": "text/plain; charset=utf-8", + }, + }); + } + return await handleOutbox(request, { + identifier: route.values.identifier, + context, + outboxContextFactory: context.toOutboxContext.bind(context), + actorDispatcher: this.actorCallbacks?.dispatcher, + authorizePredicate: this.outboxAuthorizePredicate, + outboxListeners: this.outboxListeners, + outboxErrorHandler: this.outboxListenerErrorHandler, + onUnauthorized, + onNotFound, + }); + } return await handleCollection(request, { name: "outbox", identifier: route.values.identifier, @@ -1701,6 +1725,29 @@ export class ContextImpl implements Context { }); } + toOutboxContext( + identifier: string, + activity: unknown, + activityId: string | undefined, + activityType: string, + ): OutboxContextImpl { + return new OutboxContextImpl( + identifier, + activity, + activityId, + activityType, + { + url: this.url, + federation: this.federation, + data: this.data, + documentLoader: this.documentLoader, + contextLoader: this.contextLoader, + invokedFromActorKeyPairsDispatcher: + this.invokedFromActorKeyPairsDispatcher, + }, + ); + } + get hostname(): string { return this.url.hostname; } @@ -3118,6 +3165,46 @@ export class InboxContextImpl extends ContextImpl } } +export class OutboxContextImpl extends ContextImpl + implements OutboxContext { + readonly identifier: string; + readonly activity: unknown; + readonly activityId?: string; + readonly activityType: string; + + constructor( + identifier: string, + activity: unknown, + activityId: string | undefined, + activityType: string, + options: ContextOptions, + ) { + super(options); + this.identifier = identifier; + this.activity = activity; + this.activityId = activityId; + this.activityType = activityType; + } + + override clone(data: TContextData): OutboxContext { + return new OutboxContextImpl( + this.identifier, + this.activity, + this.activityId, + this.activityType, + { + url: this.url, + federation: this.federation, + data, + documentLoader: this.documentLoader, + contextLoader: this.contextLoader, + invokedFromActorKeyPairsDispatcher: + this.invokedFromActorKeyPairsDispatcher, + }, + ); + } +} + interface SendActivityInternalOptions { readonly immediate?: boolean; readonly collectionSync?: string; diff --git a/packages/fedify/src/federation/outbox.ts b/packages/fedify/src/federation/outbox.ts new file mode 100644 index 000000000..c3d87f7e6 --- /dev/null +++ b/packages/fedify/src/federation/outbox.ts @@ -0,0 +1,59 @@ +import { Activity } from "@fedify/vocab"; +import type { OutboxListener } from "./callback.ts"; + +export class OutboxListenerSet { + #listeners: Map< + new (...args: unknown[]) => Activity, + OutboxListener + >; + + constructor() { + this.#listeners = new Map(); + } + + clone(): OutboxListenerSet { + const clone = new OutboxListenerSet(); + clone.#listeners = new Map(this.#listeners); + return clone; + } + + add( + // deno-lint-ignore no-explicit-any + type: new (...args: any[]) => TActivity, + listener: OutboxListener, + ): void { + if (this.#listeners.has(type)) { + throw new TypeError("Listener already set for this type."); + } + this.#listeners.set( + type, + listener as OutboxListener, + ); + } + + dispatchWithClass( + activity: TActivity, + ): { + // deno-lint-ignore no-explicit-any + class: new (...args: any[]) => Activity; + listener: OutboxListener; + } | null { + // deno-lint-ignore no-explicit-any + let cls: new (...args: any[]) => Activity = activity + // deno-lint-ignore no-explicit-any + .constructor as unknown as new (...args: any[]) => Activity; + while (true) { + if (this.#listeners.has(cls)) break; + if (cls === Activity) return null; + cls = globalThis.Object.getPrototypeOf(cls); + } + const listener = this.#listeners.get(cls)!; + return { class: cls, listener }; + } + + dispatch( + activity: TActivity, + ): OutboxListener | null { + return this.dispatchWithClass(activity)?.listener ?? null; + } +} diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index c5d5fac1d..ffc3313f4 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -8,6 +8,7 @@ import { trace } from "@opentelemetry/api"; import type { Context, InboxContext, + OutboxContext, RequestContext, } from "../federation/context.ts"; import type { Federation } from "../federation/federation.ts"; @@ -152,3 +153,18 @@ export function createInboxContext( }), }; } + +export function createOutboxContext( + args: Partial> & { + url?: URL; + data: TContextData; + identifier: string; + federation: Federation; + }, +): OutboxContext { + return { + ...createContext(args), + clone: args.clone ?? ((data) => createOutboxContext({ ...args, data })), + identifier: args.identifier, + }; +} diff --git a/packages/fedify/src/testing/mod.ts b/packages/fedify/src/testing/mod.ts index 91d655a70..4be4728e2 100644 --- a/packages/fedify/src/testing/mod.ts +++ b/packages/fedify/src/testing/mod.ts @@ -1,3 +1,7 @@ -export { createInboxContext, createRequestContext } from "./context.ts"; +export { + createInboxContext, + createOutboxContext, + createRequestContext, +} from "./context.ts"; // without bellows, `test:cfworkers` makes error export { testDefinitions } from "@fedify/fixture"; diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index 799874e32..6ce621f94 100644 --- a/packages/testing/src/context.ts +++ b/packages/testing/src/context.ts @@ -3,6 +3,7 @@ import type { Context, Federation, InboxContext, + OutboxContext, RequestContext, } from "@fedify/fedify/federation"; import { RouterError } from "@fedify/fedify/federation"; @@ -178,6 +179,13 @@ function createRequestContext( */ type TestInboxContext = InboxContext; +/** + * Test-specific OutboxContext type alias. + * This indirection helps avoid JSR type analyzer issues. + * @since 2.2.0 + */ +type TestOutboxContext = OutboxContext; + /** * Creates an InboxContext for testing purposes. * Not exported - used internally only. Public API is in mock.ts @@ -203,5 +211,33 @@ function createInboxContext( }; } +/** + * Creates an OutboxContext for testing purposes. + * Not exported - used internally only. Public API is in mock.ts + * @param args Partial OutboxContext properties + * @returns An OutboxContext instance + * @since 2.2.0 + */ +function createOutboxContext( + args: Partial> & { + url?: URL; + data: TContextData; + identifier: string; + federation: Federation; + }, +): TestOutboxContext { + return { + ...createContext(args), + clone: args.clone ?? + ((data: TContextData) => createOutboxContext({ ...args, data })), + identifier: args.identifier, + }; +} + // Export for internal use by mock.ts only -export { createContext, createInboxContext, createRequestContext }; +export { + createContext, + createInboxContext, + createOutboxContext, + createRequestContext, +}; diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 018cbc905..a20700e86 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,8 +1,8 @@ -import type { InboxContext } from "@fedify/fedify/federation"; +import type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; import { test } from "@fedify/fixture"; import { Create, Note, Person } from "@fedify/vocab"; import { assertEquals, assertRejects } from "@std/assert"; -import { createFederation } from "./mock.ts"; +import { createFederation, createOutboxContext } from "./mock.ts"; test("getSentActivities returns sent activities", async () => { const mockFederation = createFederation(); @@ -99,6 +99,18 @@ test("receiveActivity triggers inbox listeners", async () => { assertEquals(receivedActivity, activity); }); +test("createOutboxContext exposes identifier", () => { + const mockFederation = createFederation(); + const ctx = createOutboxContext({ + federation: mockFederation, + data: undefined, + identifier: "alice", + }); + + assertEquals((ctx as OutboxContext).identifier, "alice"); + assertEquals(ctx.clone(undefined).identifier, "alice"); +}); + test("MockContext tracks sent activities", async () => { const mockFederation = createFederation(); const mockContext = mockFederation.createContext( @@ -256,6 +268,21 @@ test("MockContext URI methods respect registered paths", () => { ); }); +test("MockContext getOutboxUri respects outbox listener path", () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/actors/{identifier}/outbox"); + + const context = mockFederation.createContext( + new URL("https://example.com"), + undefined, + ); + + assertEquals( + context.getOutboxUri("alice").href, + "https://example.com/actors/alice/outbox", + ); +}); + test("receiveActivity throws error when contextData not initialized", async () => { const mockFederation = createFederation(); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 63efec738..33db9f3a9 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -22,11 +22,17 @@ import type { DocumentLoader } from "@fedify/vocab-runtime"; import { createContext, createInboxContext, + createOutboxContext, createRequestContext, } from "./context.ts"; // Re-export for public API -export { createContext, createInboxContext, createRequestContext }; +export { + createContext, + createInboxContext, + createOutboxContext, + createRequestContext, +}; // Create a no-op tracer provider. // We use `any` type instead of importing TracerProvider from @opentelemetry/api @@ -201,6 +207,7 @@ class MockFederation implements Federation { private featuredDispatcher?: any; private featuredTagsDispatcher?: any; private inboxListeners: Map = new Map(); + private outboxListeners: Map = new Map(); private contextData?: TContextData; private receivedActivities: Activity[] = []; @@ -355,6 +362,28 @@ class MockFederation implements Federation { }; } + setOutboxListeners(outboxPath: any): any { + this.outboxPath = outboxPath; + // deno-lint-ignore no-this-alias + const self = this; + return { + on(type: any, listener: any): any { + const typeName = type.name; + if (!self.outboxListeners.has(typeName)) { + self.outboxListeners.set(typeName, []); + } + self.outboxListeners.get(typeName)!.push(listener); + return this; + }, + onError(): any { + return this; + }, + authorize(): any { + return this; + }, + }; + } + setOutboxPermanentFailureHandler(_handler: any): void { // Mock implementation - no-op } diff --git a/packages/testing/src/mod.ts b/packages/testing/src/mod.ts index 05941b730..7f16a16f5 100644 --- a/packages/testing/src/mod.ts +++ b/packages/testing/src/mod.ts @@ -15,6 +15,7 @@ * - {@link createContext} - Create a basic Context for testing * - {@link createRequestContext} - Create a RequestContext for testing * - {@link createInboxContext} - Create an InboxContext for testing + * - {@link createOutboxContext} - Create an OutboxContext for testing * * These functions provide the same testing capabilities while avoiding the * problematic type exports. @@ -27,6 +28,7 @@ export { createContext, createFederation, createInboxContext, + createOutboxContext, createRequestContext, } from "./mock.ts"; export { From 7391f001375bb4aecdbec4dacdb9ac14c0036f4c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 17:21:02 +0900 Subject: [PATCH 02/64] Warn on unfederated outbox listeners Add a runtime warning when an outbox listener returns without calling ctx.sendActivity(), since client-to-server posts are not federated automatically. Also add an @fedify/lint rule and integration tests so applications can catch the omission before it reaches runtime. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/handler.ts | 14 ++ .../fedify/src/federation/middleware.test.ts | 142 ++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 22 +++ packages/lint/src/index.ts | 5 + packages/lint/src/lib/const.ts | 3 + packages/lint/src/mod.ts | 5 + .../outbox-listener-send-activity-required.ts | 105 +++++++++++++ packages/lint/src/tests/integration.test.ts | 35 +++++ ...ox-listener-send-activity-required.test.ts | 95 ++++++++++++ 9 files changed, 426 insertions(+) create mode 100644 packages/lint/src/rules/outbox-listener-send-activity-required.ts create mode 100644 packages/lint/src/tests/outbox-listener-send-activity-required.test.ts diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index afaf8a192..111558ad0 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -626,6 +626,20 @@ export async function handleOutbox( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + if ( + "hasSentActivity" in outboxContext && + typeof outboxContext.hasSentActivity === "function" && + !outboxContext.hasSentActivity() + ) { + logger.warn( + "Outbox listener for {identifier} returned without calling ctx.sendActivity().", + { + identifier, + activityId: activity.id?.href, + activityType: getTypeId(activity).href, + }, + ); + } logger.info( "Activity {activityId} has been processed in outbox listener.", { diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 3bc784499..3a4737851 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1,4 +1,5 @@ import { mockDocumentLoader, test } from "@fedify/fixture"; +import { configure, type LogRecord, reset } from "@logtape/logtape"; import * as vocab from "@fedify/vocab"; import { getTypeId, lookupObject } from "@fedify/vocab"; import { @@ -2183,6 +2184,147 @@ test("Federation.setOutboxListeners()", async (t) => { ); assertEquals(response.status, 405); }); + + await t.step("warns when listener omits sendActivity()", async () => { + const records: LogRecord[] = []; + await reset(); + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); + + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, () => {}) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(createFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without calling ctx.sendActivity()." && + record.properties.identifier === "john" + ), + true, + ); + } finally { + await reset(); + } + }); + + await t.step("does not warn when listener calls sendActivity()", async () => { + const records: LogRecord[] = []; + await reset(); + fetchMock.spyGlobal(); + fetchMock.post("https://remote.example/inbox", { + status: 202, + body: "Accepted", + }); + + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); + + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new vocab.Person({ + id: new URL("https://remote.example/users/alice"), + inbox: new URL("https://remote.example/inbox"), + }), + activity, + ); + }) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(createFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without calling ctx.sendActivity()." + ), + false, + ); + } finally { + fetchMock.hardReset(); + await reset(); + } + }); }); test("Federation.setInboxDispatcher()", async (t) => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index b4f907b7c..c4e464834 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3167,6 +3167,7 @@ export class InboxContextImpl extends ContextImpl export class OutboxContextImpl extends ContextImpl implements OutboxContext { + readonly #sendActivityState: { sent: boolean }; readonly identifier: string; readonly activity: unknown; readonly activityId?: string; @@ -3178,14 +3179,34 @@ export class OutboxContextImpl extends ContextImpl activityId: string | undefined, activityType: string, options: ContextOptions, + sendActivityState: { sent: boolean } = { sent: false }, ) { super(options); + this.#sendActivityState = sendActivityState; this.identifier = identifier; this.activity = activity; this.activityId = activityId; this.activityType = activityType; } + hasSentActivity(): boolean { + return this.#sendActivityState.sent; + } + + override sendActivity( + sender: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[] | "followers", + activity: Activity, + options: SendActivityOptionsForCollection = {}, + ): Promise { + this.#sendActivityState.sent = true; + return super.sendActivity(sender, recipients, activity, options); + } + override clone(data: TContextData): OutboxContext { return new OutboxContextImpl( this.identifier, @@ -3201,6 +3222,7 @@ export class OutboxContextImpl extends ContextImpl invokedFromActorKeyPairsDispatcher: this.invokedFromActorKeyPairsDispatcher, }, + this.#sendActivityState, ); } } diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index a5d897cd2..8bf7706a7 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -66,6 +66,9 @@ import { import { eslint as collectionFiltering, } from "./rules/collection-filtering-not-implemented.ts"; +import { + eslint as outboxListenerSendActivityRequired, +} from "./rules/outbox-listener-send-activity-required.ts"; const rules: Record< typeof RULE_IDS[keyof typeof RULE_IDS], @@ -94,6 +97,8 @@ const rules: Record< [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, + [RULE_IDS.outboxListenerSendActivityRequired]: + outboxListenerSendActivityRequired, }; const recommendedRuleIds: (keyof typeof rules)[] = [ diff --git a/packages/lint/src/lib/const.ts b/packages/lint/src/lib/const.ts index 793e21520..6900f122c 100644 --- a/packages/lint/src/lib/const.ts +++ b/packages/lint/src/lib/const.ts @@ -135,4 +135,7 @@ export const RULE_IDS = { // Collection rules collectionFilteringNotImplemented: "collection-filtering-not-implemented", + + // Listener rules + outboxListenerSendActivityRequired: "outbox-listener-send-activity-required", } as const; diff --git a/packages/lint/src/mod.ts b/packages/lint/src/mod.ts index b745c2aa8..c4dc5e637 100644 --- a/packages/lint/src/mod.ts +++ b/packages/lint/src/mod.ts @@ -58,6 +58,9 @@ import { import { deno as collectionFiltering, } from "./rules/collection-filtering-not-implemented.ts"; +import { + deno as outboxListenerSendActivityRequired, +} from "./rules/outbox-listener-send-activity-required.ts"; const plugin: Deno.lint.Plugin = { name: "fedify-lint", @@ -87,6 +90,8 @@ const plugin: Deno.lint.Plugin = { [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, + [RULE_IDS.outboxListenerSendActivityRequired]: + outboxListenerSendActivityRequired, }, }; diff --git a/packages/lint/src/rules/outbox-listener-send-activity-required.ts b/packages/lint/src/rules/outbox-listener-send-activity-required.ts new file mode 100644 index 000000000..1368db338 --- /dev/null +++ b/packages/lint/src/rules/outbox-listener-send-activity-required.ts @@ -0,0 +1,105 @@ +import type { Rule } from "eslint"; +import { + hasIdentifierProperty, + hasMemberExpressionCallee, + hasMethodName, + isFunction, +} from "../lib/pred.ts"; +import { trackFederationVariables } from "../lib/tracker.ts"; +import type { CallExpression, Expression, FunctionNode } from "../lib/types.ts"; + +const MESSAGE = "Outbox listeners should call ctx.sendActivity() explicitly."; + +const isChainedFromOutboxListeners = ( + expr: Expression, + federationTracker: ReturnType, +): boolean => { + if (expr.type !== "CallExpression") return false; + if (!hasMemberExpressionCallee(expr) || !hasIdentifierProperty(expr)) { + return false; + } + const methodName = expr.callee.property.name; + if (methodName === "setOutboxListeners") { + return federationTracker.isFederationObject(expr.callee.object); + } + if ( + methodName === "authorize" || methodName === "onError" || + methodName === "on" + ) { + return isChainedFromOutboxListeners(expr.callee.object, federationTracker); + } + return false; +}; + +const listenerCallsSendActivity = ( + sourceCode: { getText(node: unknown): string }, + listener: FunctionNode, +): boolean => sourceCode.getText(listener).includes(".sendActivity("); + +function createRule( + buildReport: Context extends Deno.lint.RuleContext ? { + message: string; + } + : { + messageId: string; + data: { message: string }; + }, +) { + return (context: Context) => { + const federationTracker = trackFederationVariables(); + const sourceCode = + (context as { sourceCode: { getText(node: unknown): string } }) + .sourceCode; + + return { + VariableDeclarator: federationTracker.VariableDeclarator, + + CallExpression(node: CallExpression): void { + if ( + !hasMemberExpressionCallee(node) || + !hasIdentifierProperty(node) || + !hasMethodName("on")(node) || + node.arguments.length < 2 + ) { + return; + } + if ( + !isChainedFromOutboxListeners(node.callee.object, federationTracker) + ) { + return; + } + + const listener = node.arguments[1]; + if (!isFunction(listener)) return; + + if (listenerCallsSendActivity(sourceCode, listener)) return; + + (context as { report: (arg: unknown) => void }).report({ + node: listener, + ...buildReport, + }); + }, + }; + }; +} + +export const deno: Deno.lint.Rule = { + create: createRule({ message: MESSAGE }), +}; + +export const eslint: Rule.RuleModule = { + meta: { + type: "suggestion", + docs: { + description: "Warn when an outbox listener omits ctx.sendActivity()", + }, + schema: [], + messages: { + required: "{{ message }}", + }, + }, + create: createRule({ + messageId: "required", + data: { message: MESSAGE }, + }), +}; diff --git a/packages/lint/src/tests/integration.test.ts b/packages/lint/src/tests/integration.test.ts index 3675d5e63..642daf636 100644 --- a/packages/lint/src/tests/integration.test.ts +++ b/packages/lint/src/tests/integration.test.ts @@ -179,6 +179,41 @@ test("Integration: ✅ Complete valid code passes all rules", () => { assertNoErrors(COMPLETE_VALID_CODE); }); +test( + "Integration: ✅ outbox-listener-send-activity-required - explicit sendActivity", + () => + assertNoErrors(`${COMPLETE_VALID_CODE} + +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); + });`), +); + +test( + "Integration: ❌ outbox-listener-send-activity-required - missing sendActivity", + () => + pipe( + `${COMPLETE_VALID_CODE} + +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + console.log(ctx.identifier, activity.id?.href); + });`, + assertHasError("outbox-listener-send-activity-required"), + ), +); + test("Integration: ❌ actor-id-required - missing id property", () => pipe( COMPLETE_VALID_CODE, diff --git a/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts b/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts new file mode 100644 index 000000000..14940d0ff --- /dev/null +++ b/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import { RULE_IDS } from "../lib/const.ts"; +import lintTest from "../lib/test.ts"; +import * as rule from "../rules/outbox-listener-send-activity-required.ts"; + +const ruleName = RULE_IDS.outboxListenerSendActivityRequired; + +test( + `${ruleName}: ✅ Good - direct sendActivity call`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); + }); +`, + rule, + ruleName, + }), +); + +test( + `${ruleName}: ✅ Good - non-federation object`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +const fakeFederation = { + setOutboxListeners() { + return { + on() { + return this; + }, + }; + }, +}; + +fakeFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + activity; + ctx.identifier; + }); +`, + rule, + ruleName, + federationSetup: "", + }), +); + +test( + `${ruleName}: ❌ Bad - missing sendActivity call`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + console.log(ctx.identifier, activity.id?.href); + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should call ctx.sendActivity() explicitly.", + }), +); + +test( + `${ruleName}: ❌ Bad - chained authorize without sendActivity`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .authorize((_ctx, _identifier) => true) + .on(Activity, async (ctx, activity) => { + console.log(ctx.identifier, activity.id?.href); + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should call ctx.sendActivity() explicitly.", + }), +); From 9954d00a079baa220a635c89c1d20841495fd27e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 17:33:27 +0900 Subject: [PATCH 03/64] Document client outbox posting Document how to handle POST requests to actor outboxes with setOutboxListeners(), including explicit federation through ctx.sendActivity(), authorization hooks, testing helpers, and the new lint warning. Also add changelog entries for the new APIs and predict this branch's pull request number for the release notes. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- CHANGES.md | 23 +++++ docs/.vitepress/config.mts | 1 + docs/manual/access-control.md | 18 ++++ docs/manual/collections.md | 4 + docs/manual/context.md | 1 + docs/manual/lint.md | 38 ++++++++ docs/manual/outbox.md | 164 ++++++++++++++++++++++++++++++++++ docs/manual/test.md | 16 ++++ 8 files changed, 265 insertions(+) create mode 100644 docs/manual/outbox.md diff --git a/CHANGES.md b/CHANGES.md index 0dae660bc..e80b69623 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,14 @@ To be released. ### @fedify/fedify + - Added `setOutboxListeners()` and `OutboxContext` for handling + client-to-server `POST` requests to actor outboxes. Outbox listeners use + application-defined authorization through `.authorize()`, catch activity + types with `.on()`, and require explicit `ctx.sendActivity()` calls for any + federation delivery. Fedify now also logs a runtime warning when an + outbox listener returns without calling `ctx.sendActivity()`. + [[#430], [#682]] + - Allowed actor dispatchers to return `Tombstone` for deleted accounts. Fedify now serves those actor URIs as `410 Gone` with the serialized tombstone body, and the corresponding WebFinger lookups also return @@ -24,8 +32,23 @@ To be released. `getAuthenticatedDocumentLoader()` now also respects `GetAuthenticatedDocumentLoaderOptions.maxRedirection`. +[#430]: https://github.com/fedify-dev/fedify/issues/430 [#644]: https://github.com/fedify-dev/fedify/issues/644 [#680]: https://github.com/fedify-dev/fedify/pull/680 +[#682]: https://github.com/fedify-dev/fedify/pull/682 + +### @fedify/lint + + - Added the `outbox-listener-send-activity-required` rule. It warns when an + outbox listener registered through `setOutboxListeners()` returns without an + explicit `ctx.sendActivity()` call, which would otherwise leave a posted + client activity unfederated. [[#430], [#682]] + +### @fedify/testing + + - Added `createOutboxContext()` and mock `setOutboxListeners()` support so + outbox listeners can be tested without spinning up a live federation + server. [[#430], [#682]] ### @fedify/vocab-runtime diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ab4c3410f..6be86cb7e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -108,6 +108,7 @@ const MANUAL = { { text: "Vocabulary", link: "/manual/vocab.md" }, { text: "Actor dispatcher", link: "/manual/actor.md" }, { text: "Inbox listeners", link: "/manual/inbox.md" }, + { text: "Outbox listeners", link: "/manual/outbox.md" }, { text: "Sending activities", link: "/manual/send.md" }, { text: "Collections", link: "/manual/collections.md" }, { text: "Object dispatcher", link: "/manual/object.md" }, diff --git a/docs/manual/access-control.md b/docs/manual/access-control.md index d5004f7f0..dea5100f3 100644 --- a/docs/manual/access-control.md +++ b/docs/manual/access-control.md @@ -102,6 +102,24 @@ federation If the predicate returns `false`, the request is rejected with a `401 Unauthorized` response. +Outbox listeners can use a similar hook for client-to-server `POST /outbox` +requests: + +~~~~ typescript twoslash +import { type Federation } from "@fedify/fedify"; +const federation = null as unknown as Federation; +// ---cut-before--- +federation + .setOutboxListeners("/users/{identifier}/outbox") + .authorize(async (ctx, identifier) => { + const token = ctx.request.headers.get("authorization"); + return token === `Bearer ${identifier}`; + }); +~~~~ + +Unlike authorized fetch, this hook is purely local application logic for +incoming client requests. It does not verify HTTP Signatures by itself. + Fine-grained access control --------------------------- diff --git a/docs/manual/collections.md b/docs/manual/collections.md index 6fabf6233..364a86d7e 100644 --- a/docs/manual/collections.md +++ b/docs/manual/collections.md @@ -31,6 +31,10 @@ own URI, the outbox collection has its own URI, too. The URI of the outbox collection is determined by the first parameter of the `~Federatable.setOutboxDispatcher()` method: +> [!TIP] +> Use `~Federatable.setOutboxListeners()` to handle `POST` requests to the same +> outbox path. See the [*Outbox listeners*](./outbox.md) guide. + ~~~~ typescript twoslash // @noErrors: 2345 import type { Federation } from "@fedify/fedify"; diff --git a/docs/manual/context.md b/docs/manual/context.md index 23b1166fd..eeee6c361 100644 --- a/docs/manual/context.md +++ b/docs/manual/context.md @@ -38,6 +38,7 @@ callbacks that take a `Context` object as the first parameter: - [Actor dispatcher](./actor.md) - [Inbox listeners](./inbox.md) + - [Outbox listeners](./outbox.md) - [Outbox collection dispatcher](./collections.md#outbox) - [Inbox collection dispatcher](./collections.md#inbox) - [Following collection dispatcher](./collections.md#following) diff --git a/docs/manual/lint.md b/docs/manual/lint.md index a818a3529..7c2413b64 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -597,6 +597,44 @@ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => { }); ~~~~ +### `outbox-listener-send-activity-required` + +Warns when an outbox listener body does not call `ctx.sendActivity()`. + +**When this rule applies:** +You've registered an outbox listener with `setOutboxListeners()`, but the +listener body never calls `ctx.sendActivity()`. + +**Why it matters:** +Fedify does not federate client-to-server outbox posts automatically. If your +application intends to deliver a posted activity, the listener must call +`ctx.sendActivity()` explicitly. + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation } from "@fedify/fedify"; +import { Activity } from "@fedify/vocab"; +const federation = createFederation({ kv: null as any }); +// ---cut-before--- +// ❌ Bad: Listener stores the activity locally but never federates it +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + console.log(ctx.identifier, activity.id?.href); + }); + +// ✅ Good: Listener federates explicitly +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + [], + activity, + ); + }); +~~~~ + ### `actor-followers-property-required` Ensures `followers` is defined when `setFollowersDispatcher()` is configured. diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md new file mode 100644 index 000000000..839acb370 --- /dev/null +++ b/docs/manual/outbox.md @@ -0,0 +1,164 @@ +--- +description: >- + Fedify provides a way to register outbox listeners so that you can handle + client-to-server `POST` requests to actor outboxes. This section explains + how to register an outbox listener and how to federate posted activities. +--- + +Outbox listeners +================ + +Fedify can route `POST` requests to an actor's outbox through typed listeners. +This is useful when you want to accept ActivityPub client-to-server activities +from your own clients without exposing a separate non-standard API. + +This guide covers `POST /outbox`. To serve `GET /outbox`, use the +[*Collections*](./collections.md#outbox) guide. + + +Registering an outbox listener +------------------------------ + +With Fedify, you can register outbox listeners per activity type, just like +inbox listeners. The following shows how to register a listener for `Create` +activities and a catch-all for every other activity type: + +~~~~ typescript twoslash +import { type Federation } from "@fedify/fedify"; +import { Activity, Create, Person } from "@fedify/vocab"; +const federation = null as unknown as Federation; +const myKnownRecipients: Person[] = []; +async function savePostedActivity( + identifier: string, + activity: Activity, +): Promise { + identifier; + activity; +} +// ---cut-before--- +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, async (ctx, activity) => { + await savePostedActivity(ctx.identifier, activity); + await ctx.sendActivity( + { identifier: ctx.identifier }, + myKnownRecipients, + activity, + ); + }) + .on(Activity, async (_ctx, _activity) => { + // Catch any other activity type. + }) + .authorize(async (ctx, identifier) => { + const token = ctx.request.headers.get("authorization"); + return token === `Bearer ${identifier}`; + }); +~~~~ + +The `~Federatable.setOutboxListeners()` method registers the outbox path, and +the `~OutboxListenerSetters.on()` method registers a listener for a specific +activity type. The `~OutboxListenerSetters.authorize()` hook runs before the +listener and can reject unauthorized requests with `401 Unauthorized`. + +> [!TIP] +> If you want to catch every activity type, register a listener for the +> `Activity` class. + +> [!NOTE] +> The URI Template syntax supports different expansion types like +> `{identifier}` (simple expansion) and `{+identifier}` (reserved expansion). +> If your identifiers contain URIs or special characters, you may need to use +> `{+identifier}` to avoid double-encoding issues. See the +> [*URI Template* guide](./uri-template.md) for details. + + +Looking at `OutboxContext.identifier` +------------------------------------- + +The `~OutboxContext.identifier` property contains the identifier from the +matched outbox route. Fedify does not infer anything more specific than that. + +~~~~ typescript twoslash +import { type OutboxListenerSetters } from "@fedify/fedify"; +import { Create } from "@fedify/vocab"; +(0 as unknown as OutboxListenerSetters) +// ---cut-before--- +.on(Create, async (ctx, activity) => { + console.log(ctx.identifier); + console.log(activity.id?.href); +}); +~~~~ + + +Federating posted activities +---------------------------- + +Fedify does not federate client-posted activities automatically. If you want +to deliver a posted activity, call `~Context.sendActivity()` explicitly inside +your outbox listener. + +~~~~ typescript twoslash +import { type Federation } from "@fedify/fedify"; +import { Create, Person } from "@fedify/vocab"; +const federation = null as unknown as Federation; +const recipients: Person[] = []; +// ---cut-before--- +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + recipients, + activity, + ); + }); +~~~~ + +If a listener returns without calling `~Context.sendActivity()`, Fedify logs a +runtime warning. The `@fedify/lint` package also provides a lint rule for the +same mistake; see [*Linting*](./lint.md) for details. + +> [!TIP] +> Explicit delivery keeps outbox listeners symmetric with inbox listeners: +> Fedify never guesses the recipient list for you, so applications can reuse +> their own caches and delivery policies. + + +Handling errors +--------------- + +You can attach an error handler to outbox listeners. It receives the outbox +context along with the thrown error: + +~~~~ typescript twoslash +import { type Federation } from "@fedify/fedify"; +import { Activity } from "@fedify/vocab"; +const federation = null as unknown as Federation; +// ---cut-before--- +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async () => { + throw new Error("Something went wrong."); + }) + .onError(async (ctx, error) => { + console.error(ctx.identifier, error); + }); +~~~~ + + +Current scope +------------- + +Outbox listeners currently provide the routing and authorization surface for +client-to-server posting, but the rest of the server-side behavior remains +application-defined. + +In particular, Fedify does not currently do the following for you: + + - Persist the posted activity in your outbox collection + - Generate IDs or `Location` headers for newly posted activities + - Wrap non-`Activity` objects in `Create` automatically + - Federate anything unless your listener calls `ctx.sendActivity()` + +If you need full `GET /outbox` support as well, combine this guide with the +[*Collections*](./collections.md#outbox) guide. diff --git a/docs/manual/test.md b/docs/manual/test.md index 22d0ef06e..27a3c2cda 100644 --- a/docs/manual/test.md +++ b/docs/manual/test.md @@ -281,6 +281,22 @@ console.log("Context sent activities:", sentActivities); console.log("Federation sent activities:", federation.sentActivities); ~~~~ +If you want to test an outbox listener directly, you can also create an +`OutboxContext` with the `createOutboxContext()` helper: + +~~~~ typescript twoslash +import { createFederation, createOutboxContext } from "@fedify/testing"; + +const federation = createFederation<{ userId: string }>(); +const context = createOutboxContext({ + federation, + data: { userId: "test-user" }, + identifier: "alice", +}); + +console.log(context.identifier); // alice +~~~~ + ### Testing URI generation Mock contexts created with the `createContext()` method provide mock From bb53bd43c1e2512b67f252e851751c6ed6e386ab Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 17:59:19 +0900 Subject: [PATCH 04/64] Harden outbox ownership checks Reject client-posted activities whose actor does not match the local outbox owner before any listener runs, and add regression tests for the mismatch. Also make the testing mocks able to execute registered outbox listeners through postOutboxActivity(), and update the docs and changelog to match the stronger behavior. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- CHANGES.md | 6 +- docs/manual/outbox.md | 3 + docs/manual/test.md | 19 +++++++ .../fedify/src/federation/handler.test.ts | 57 +++++++++++++++++-- packages/fedify/src/federation/handler.ts | 19 +++++++ packages/testing/src/mock.test.ts | 57 +++++++++++++++++++ packages/testing/src/mock.ts | 45 +++++++++++++++ 7 files changed, 199 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e80b69623..b420233b5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -46,9 +46,9 @@ To be released. ### @fedify/testing - - Added `createOutboxContext()` and mock `setOutboxListeners()` support so - outbox listeners can be tested without spinning up a live federation - server. [[#430], [#682]] + - Added `createOutboxContext()` plus `postOutboxActivity()` and mock + `setOutboxListeners()` support so outbox listeners can be tested without + spinning up a live federation server. [[#430], [#682]] ### @fedify/vocab-runtime diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md index 839acb370..13f8ac2f0 100644 --- a/docs/manual/outbox.md +++ b/docs/manual/outbox.md @@ -60,6 +60,9 @@ the `~OutboxListenerSetters.on()` method registers a listener for a specific activity type. The `~OutboxListenerSetters.authorize()` hook runs before the listener and can reject unauthorized requests with `401 Unauthorized`. +Fedify also rejects a posted activity if its `actor` does not match the local +actor who owns the addressed outbox. + > [!TIP] > If you want to catch every activity type, register a listener for the > `Activity` class. diff --git a/docs/manual/test.md b/docs/manual/test.md index 27a3c2cda..42d6713b2 100644 --- a/docs/manual/test.md +++ b/docs/manual/test.md @@ -297,6 +297,25 @@ const context = createOutboxContext({ console.log(context.identifier); // alice ~~~~ +If you prefer to exercise registered outbox listeners through the mock +federation, use `postOutboxActivity()`: + +~~~~ typescript twoslash +import { createFederation } from "@fedify/testing"; +import { Create } from "@fedify/vocab"; + +const federation = createFederation<{ userId: string }>({ + contextData: { userId: "test-user" }, +}); + +const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), +}); + +await federation.postOutboxActivity("alice", activity); +~~~~ + ### Testing URI generation Mock contexts created with the `createContext()` method provide mock diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index add2deda1..0a3c75c57 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1338,10 +1338,10 @@ test("handleInbox()", async () => { test("handleOutbox()", async () => { const activity = new Create({ id: new URL("https://example.com/activities/1"), - actor: new URL("https://example.com/person2"), + actor: new URL("https://example.com/users/someone"), object: new Note({ id: new URL("https://example.com/notes/1"), - attribution: new URL("https://example.com/person2"), + attribution: new URL("https://example.com/users/someone"), content: "Hello, world!", }), }); @@ -1355,6 +1355,9 @@ test("handleOutbox()", async () => { request, url: new URL(request.url), data: undefined, + getActorUri(identifier: string) { + return new URL(`https://example.com/users/${identifier}`); + }, }); let onNotFoundCalled: Request | null = null; const onNotFound = (request: Request) => { @@ -1366,9 +1369,9 @@ test("handleOutbox()", async () => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; - const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + const actorDispatcher: ActorDispatcher = (ctx, identifier) => { if (identifier !== "someone") return null; - return new Person({ name: "Someone" }); + return new Person({ id: ctx.getActorUri(identifier), name: "Someone" }); }; const listeners = new OutboxListenerSet(); const seen: string[] = []; @@ -1479,6 +1482,9 @@ test("handleOutbox()", async () => { request: invalidRequest, url: new URL(invalidRequest.url), data: undefined, + getActorUri(identifier: string) { + return new URL(`https://example.com/users/${identifier}`); + }, }); response = await handleOutbox(invalidRequest, { identifier: "someone", @@ -1497,6 +1503,49 @@ test("handleOutbox()", async () => { }); assertEquals(response.status, 400); + const mismatchedActorJson = (await activity.toJsonLd()) as Record< + string, + unknown + >; + const mismatchedActorRequest = new Request( + "https://example.com/users/someone/outbox", + { + method: "POST", + body: JSON.stringify({ + ...mismatchedActorJson, + actor: "https://example.com/users/somebody-else", + }), + }, + ); + const mismatchedActorContext = createRequestContext({ + federation, + request: mismatchedActorRequest, + url: new URL(mismatchedActorRequest.url), + data: undefined, + getActorUri(identifier: string) { + return new URL(`https://example.com/users/${identifier}`); + }, + }); + response = await handleOutbox(mismatchedActorRequest, { + identifier: "someone", + context: mismatchedActorContext, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...mismatchedActorContext, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + onNotFound, + onUnauthorized, + }); + assertEquals( + [response.status, await response.text()], + [400, "The activity actor does not match the outbox owner."], + ); + const throwingListeners = new OutboxListenerSet(); let onErrorCalled = false; throwingListeners.add(Create, () => { diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 111558ad0..df31af57b 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -582,6 +582,25 @@ export async function handleOutbox( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + const expectedActorId = actor.id ?? ctx.getActorUri(identifier); + if ( + activity.actorIds.length < 1 || + !activity.actorIds.every((actorId) => actorId.href === expectedActorId.href) + ) { + logger.error( + "The posted activity actor does not match outbox owner {identifier}.", + { + identifier, + activityId: activity.id?.href, + expectedActorId: expectedActorId.href, + actorIds: activity.actorIds.map((actorId) => actorId.href), + }, + ); + return new Response("The activity actor does not match the outbox owner.", { + status: 400, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } const dispatched = outboxListeners?.dispatchWithClass(activity); if (dispatched == null) { logger.debug("Unsupported activity type:\n{activity}", { diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index a20700e86..647641a3b 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -99,6 +99,42 @@ test("receiveActivity triggers inbox listeners", async () => { assertEquals(receivedActivity, activity); }); +test("postOutboxActivity triggers outbox listeners", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let receivedIdentifier: string | null = null; + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>, activity: Create) => { + receivedIdentifier = ctx.identifier; + await ctx.sendActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + activity, + ); + }, + ); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + object: new Note({ + id: new URL("https://example.com/notes/1"), + content: "Test note", + }), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(receivedIdentifier, "alice"); + assertEquals(mockFederation.sentActivities.length, 1); + assertEquals(mockFederation.sentActivities[0].activity, activity); +}); + test("createOutboxContext exposes identifier", () => { const mockFederation = createFederation(); const ctx = createOutboxContext({ @@ -307,6 +343,27 @@ test("receiveActivity throws error when contextData not initialized", async () = ); }); +test("postOutboxActivity throws error when contextData not initialized", async () => { + const mockFederation = createFederation(); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, (_ctx: OutboxContext, _activity: Create) => { + return Promise.resolve(); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "MockFederation.postOutboxActivity(): contextData is not initialized. Please provide contextData through the constructor or call startQueue() before posting activities.", + ); +}); + test("MockFederation distinguishes between immediate and queued activities", async () => { const mockFederation = createFederation(); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 33db9f3a9..b9afce515 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -132,6 +132,7 @@ interface TestFederation // Test-specific methods receiveActivity(activity: Activity): Promise; + postOutboxActivity(identifier: string, activity: Activity): Promise; reset(): void; // Override createContext to return TestContext @@ -478,6 +479,50 @@ class MockFederation implements Federation { } } + /** + * Simulates posting an activity to a local actor outbox. + * This method is specific to the mock implementation and is used for + * testing purposes. + * + * @param identifier The identifier of the outbox owner. + * @param activity The activity to post. + * @returns A promise that resolves when the activity has been processed. + * @since 2.2.0 + */ + async postOutboxActivity( + identifier: string, + activity: Activity, + ): Promise { + const typeName = activity.constructor.name; + const listeners = [ + ...(this.outboxListeners.get(typeName) || []), + ...(this.outboxListeners.get("Activity") || []), + ]; + + if (listeners.length > 0 && this.contextData === undefined) { + throw new Error( + "MockFederation.postOutboxActivity(): contextData is not initialized. " + + "Please provide contextData through the constructor or call startQueue() before posting activities.", + ); + } + + const baseContext = this.createContext( + new URL(this.options.origin ?? "https://example.com"), + this.contextData as TContextData, + ); + + for (const listener of listeners) { + const context = createOutboxContext({ + ...baseContext, + clone: undefined, + federation: this as any, + identifier, + sendActivity: baseContext.sendActivity.bind(baseContext), + }); + await listener(context, activity); + } + } + /** * Clears all sent activities from the mock federation. * This method is specific to the mock implementation and is used for From 56de5cc341b12abff9340887021ccf6934583f80 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 18:08:52 +0900 Subject: [PATCH 05/64] Align outbox middleware tests with owner checks Update the outbox middleware tests to use posted activities whose actor matches the addressed local outbox owner, so the new ownership guard is exercised intentionally instead of tripping the happy-path fixtures. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/middleware.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 3a4737851..b96544ab1 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2082,6 +2082,10 @@ test("Federation.setOutboxListeners()", async (t) => { }); await t.step("on() and authorize()", async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; const federation = createFederation({ kv, documentLoaderFactory: () => mockDocumentLoader, @@ -2118,7 +2122,7 @@ test("Federation.setOutboxListeners()", async (t) => { let response = await federation.fetch( new Request("https://example.com/users/john/outbox", { method: "POST", - body: JSON.stringify(createFixture), + body: JSON.stringify(postedFixture), headers: { "content-type": "application/activity+json", }, @@ -2131,7 +2135,7 @@ test("Federation.setOutboxListeners()", async (t) => { response = await federation.fetch( new Request("https://example.com/users/john/outbox", { method: "POST", - body: JSON.stringify(createFixture), + body: JSON.stringify(postedFixture), headers: { authorization: "Bearer token", "content-type": "application/activity+json", @@ -2186,6 +2190,10 @@ test("Federation.setOutboxListeners()", async (t) => { }); await t.step("warns when listener omits sendActivity()", async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; const records: LogRecord[] = []; await reset(); try { @@ -2225,7 +2233,7 @@ test("Federation.setOutboxListeners()", async (t) => { const response = await federation.fetch( new Request("https://example.com/users/john/outbox", { method: "POST", - body: JSON.stringify(createFixture), + body: JSON.stringify(postedFixture), headers: { authorization: "Bearer token", "content-type": "application/activity+json", @@ -2249,6 +2257,10 @@ test("Federation.setOutboxListeners()", async (t) => { }); await t.step("does not warn when listener calls sendActivity()", async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; const records: LogRecord[] = []; await reset(); fetchMock.spyGlobal(); @@ -2303,7 +2315,7 @@ test("Federation.setOutboxListeners()", async (t) => { const response = await federation.fetch( new Request("https://example.com/users/john/outbox", { method: "POST", - body: JSON.stringify(createFixture), + body: JSON.stringify(postedFixture), headers: { authorization: "Bearer token", "content-type": "application/activity+json", From 4de226b7c45ea755d11ff2e48281d77f518c7e06 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 20:19:26 +0900 Subject: [PATCH 06/64] Harden outbox listener follow-up checks Tighten the outbox listener lint rule and testing helpers so the follow-up self-review fixes behave the same way in docs, mocks, and static analysis. This avoids false positives from comments or strings, rejects duplicate mock listeners, prefers the most specific mock listener, and removes a misleading no-op catch-all example. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- docs/manual/outbox.md | 11 +++-- .../outbox-listener-send-activity-required.ts | 11 ++++- ...ox-listener-send-activity-required.test.ts | 39 +++++++++++++++++ packages/testing/src/mock.test.ts | 43 ++++++++++++++++++- packages/testing/src/mock.ts | 18 ++++---- 5 files changed, 103 insertions(+), 19 deletions(-) diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md index 13f8ac2f0..94734bacd 100644 --- a/docs/manual/outbox.md +++ b/docs/manual/outbox.md @@ -21,7 +21,7 @@ Registering an outbox listener With Fedify, you can register outbox listeners per activity type, just like inbox listeners. The following shows how to register a listener for `Create` -activities and a catch-all for every other activity type: +activities: ~~~~ typescript twoslash import { type Federation } from "@fedify/fedify"; @@ -46,9 +46,6 @@ federation activity, ); }) - .on(Activity, async (_ctx, _activity) => { - // Catch any other activity type. - }) .authorize(async (ctx, identifier) => { const token = ctx.request.headers.get("authorization"); return token === `Bearer ${identifier}`; @@ -64,8 +61,10 @@ Fedify also rejects a posted activity if its `actor` does not match the local actor who owns the addressed outbox. > [!TIP] -> If you want to catch every activity type, register a listener for the -> `Activity` class. +> If you need to handle every activity type, register a listener for the +> `Activity` class. Unsupported activity types can also be left unhandled, +> in which case Fedify responds with `202 Accepted` without dispatching a +> listener. > [!NOTE] > The URI Template syntax supports different expansion types like diff --git a/packages/lint/src/rules/outbox-listener-send-activity-required.ts b/packages/lint/src/rules/outbox-listener-send-activity-required.ts index 1368db338..8106a0e2a 100644 --- a/packages/lint/src/rules/outbox-listener-send-activity-required.ts +++ b/packages/lint/src/rules/outbox-listener-send-activity-required.ts @@ -31,10 +31,19 @@ const isChainedFromOutboxListeners = ( return false; }; +const stripCommentsAndStrings = (code: string): string => + code + .replaceAll(/\/\*[\s\S]*?\*\//g, "") + .replaceAll(/\/\/.*$/gm, "") + .replaceAll(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, '""'); + const listenerCallsSendActivity = ( sourceCode: { getText(node: unknown): string }, listener: FunctionNode, -): boolean => sourceCode.getText(listener).includes(".sendActivity("); +): boolean => + stripCommentsAndStrings(sourceCode.getText(listener)).includes( + ".sendActivity(", + ); function createRule( buildReport: Context extends Deno.lint.RuleContext ? { diff --git a/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts b/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts index 14940d0ff..6cb0bbf48 100644 --- a/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts @@ -93,3 +93,42 @@ federation "Outbox listeners should call ctx.sendActivity() explicitly.", }), ); + +test( + `${ruleName}: ❌ Bad - comment mentioning sendActivity`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + // ctx.sendActivity(...) + console.log(ctx.identifier, activity.id?.href); + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should call ctx.sendActivity() explicitly.", + }), +); + +test( + `${ruleName}: ❌ Bad - string mentioning sendActivity`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async () => { + return ".sendActivity("; + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should call ctx.sendActivity() explicitly.", + }), +); diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 647641a3b..706083a38 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,7 +1,7 @@ import type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; import { test } from "@fedify/fixture"; -import { Create, Note, Person } from "@fedify/vocab"; -import { assertEquals, assertRejects } from "@std/assert"; +import { Activity, Create, Note, Person } from "@fedify/vocab"; +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { createFederation, createOutboxContext } from "./mock.ts"; test("getSentActivities returns sent activities", async () => { @@ -135,6 +135,45 @@ test("postOutboxActivity triggers outbox listeners", async () => { assertEquals(mockFederation.sentActivities[0].activity, activity); }); +test("postOutboxActivity prefers the most specific listener", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + const calls: string[] = []; + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, () => { + calls.push("Activity"); + }) + .on(Create, () => { + calls.push("Create"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(calls, ["Create"]); +}); + +test("setOutboxListeners rejects duplicate listeners for the same type", () => { + const mockFederation = createFederation(); + const listeners = mockFederation.setOutboxListeners( + "/users/{identifier}/outbox", + ); + + listeners.on(Create, () => {}); + + assertThrows( + () => listeners.on(Create, () => {}), + TypeError, + ); +}); + test("createOutboxContext exposes identifier", () => { const mockFederation = createFederation(); const ctx = createOutboxContext({ diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index b9afce515..410dc04ca 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -208,7 +208,7 @@ class MockFederation implements Federation { private featuredDispatcher?: any; private featuredTagsDispatcher?: any; private inboxListeners: Map = new Map(); - private outboxListeners: Map = new Map(); + private outboxListeners: Map = new Map(); private contextData?: TContextData; private receivedActivities: Activity[] = []; @@ -370,10 +370,10 @@ class MockFederation implements Federation { return { on(type: any, listener: any): any { const typeName = type.name; - if (!self.outboxListeners.has(typeName)) { - self.outboxListeners.set(typeName, []); + if (self.outboxListeners.has(typeName)) { + throw new TypeError("Listener already set for this type."); } - self.outboxListeners.get(typeName)!.push(listener); + self.outboxListeners.set(typeName, listener); return this; }, onError(): any { @@ -494,12 +494,10 @@ class MockFederation implements Federation { activity: Activity, ): Promise { const typeName = activity.constructor.name; - const listeners = [ - ...(this.outboxListeners.get(typeName) || []), - ...(this.outboxListeners.get("Activity") || []), - ]; + const listener = this.outboxListeners.get(typeName) ?? + this.outboxListeners.get("Activity"); - if (listeners.length > 0 && this.contextData === undefined) { + if (listener != null && this.contextData === undefined) { throw new Error( "MockFederation.postOutboxActivity(): contextData is not initialized. " + "Please provide contextData through the constructor or call startQueue() before posting activities.", @@ -511,7 +509,7 @@ class MockFederation implements Federation { this.contextData as TContextData, ); - for (const listener of listeners) { + if (listener != null) { const context = createOutboxContext({ ...baseContext, clone: undefined, From 730a7f7ccf6aa3389f5f8ef2e3118bd8b4e74770 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 20:19:42 +0900 Subject: [PATCH 07/64] Refresh the outbox changelog PR reference Update the unreleased outbox listener changelog entries to point at the current next pull request number. The earlier placeholder became stale after newer issues and pull requests were created. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- CHANGES.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b420233b5..9615b8641 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,7 +16,7 @@ To be released. types with `.on()`, and require explicit `ctx.sendActivity()` calls for any federation delivery. Fedify now also logs a runtime warning when an outbox listener returns without calling `ctx.sendActivity()`. - [[#430], [#682]] + [[#430], [#688]] - Allowed actor dispatchers to return `Tombstone` for deleted accounts. Fedify now serves those actor URIs as `410 Gone` with the serialized @@ -35,20 +35,20 @@ To be released. [#430]: https://github.com/fedify-dev/fedify/issues/430 [#644]: https://github.com/fedify-dev/fedify/issues/644 [#680]: https://github.com/fedify-dev/fedify/pull/680 -[#682]: https://github.com/fedify-dev/fedify/pull/682 +[#688]: https://github.com/fedify-dev/fedify/pull/688 ### @fedify/lint - Added the `outbox-listener-send-activity-required` rule. It warns when an outbox listener registered through `setOutboxListeners()` returns without an explicit `ctx.sendActivity()` call, which would otherwise leave a posted - client activity unfederated. [[#430], [#682]] + client activity unfederated. [[#430], [#688]] ### @fedify/testing - Added `createOutboxContext()` plus `postOutboxActivity()` and mock `setOutboxListeners()` support so outbox listeners can be tested without - spinning up a live federation server. [[#430], [#682]] + spinning up a live federation server. [[#430], [#688]] ### @fedify/vocab-runtime From a623a94247dbb88872a38aa73b3a59439224d1d6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 21:15:18 +0900 Subject: [PATCH 08/64] Preserve signed outbox payloads in forwarding Outbox listeners need the same escape hatch that inbox listeners have when they must relay a signed activity without round-tripping it through Fedify's vocabulary objects. This adds OutboxContext.forwardActivity(), reuses the existing forwarding path for raw posted JSON-LD, and treats explicit forwarding as delivery so outbox warnings only fire when nothing was actually sent. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/context.ts | 45 ++ packages/fedify/src/federation/handler.ts | 8 +- .../fedify/src/federation/middleware.test.ts | 105 +++- packages/fedify/src/federation/middleware.ts | 588 ++++++++++-------- packages/fedify/src/testing/context.ts | 12 +- 5 files changed, 488 insertions(+), 270 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 0b224b293..19b385f45 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -698,6 +698,51 @@ export interface OutboxContext extends Context { */ readonly identifier: string; + /** + * Forwards a posted activity to the recipients' inboxes without + * re-serializing the original payload. The forwarded activity will be + * signed in HTTP Signatures by the forwarder, but its payload will not be + * modified, i.e., Linked Data Signatures and Object Integrity Proofs will + * not be added. Therefore, if the posted activity is not signed (i.e., it + * has neither Linked Data Signatures nor Object Integrity Proofs), the + * recipient probably will not trust the activity. + * @param forwarder The forwarder's identifier or the forwarder's username + * or the forwarder's key pair(s). + * @param recipients The recipients of the activity. + * @param options Options for forwarding the activity. + * @since 2.2.0 + */ + forwardActivity( + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[], + options?: ForwardActivityOptions, + ): Promise; + + /** + * Forwards a posted activity to the recipients' inboxes without + * re-serializing the original payload. The forwarded activity will be + * signed in HTTP Signatures by the forwarder, but its payload will not be + * modified, i.e., Linked Data Signatures and Object Integrity Proofs will + * not be added. Therefore, if the posted activity is not signed (i.e., it + * has neither Linked Data Signatures nor Object Integrity Proofs), the + * recipient probably will not trust the activity. + * @param forwarder The forwarder's identifier or the forwarder's username. + * @param recipients In this case, it must be `"followers"`. + * @param options Options for forwarding the activity. + * @since 2.2.0 + */ + forwardActivity( + forwarder: + | { identifier: string } + | { username: string }, + recipients: "followers", + options?: ForwardActivityOptions, + ): Promise; + /** * Creates a new context with the same properties as this one, * but with the given data. diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index df31af57b..fef73d19b 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -646,12 +646,12 @@ export async function handleOutbox( }); } if ( - "hasSentActivity" in outboxContext && - typeof outboxContext.hasSentActivity === "function" && - !outboxContext.hasSentActivity() + "hasDeliveredActivity" in outboxContext && + typeof outboxContext.hasDeliveredActivity === "function" && + !outboxContext.hasDeliveredActivity() ) { logger.warn( - "Outbox listener for {identifier} returned without calling ctx.sendActivity().", + "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity().", { identifier, activityId: activity.id?.href, diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index b96544ab1..c8a73c435 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2189,7 +2189,7 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals(response.status, 405); }); - await t.step("warns when listener omits sendActivity()", async () => { + await t.step("warns when listener omits delivery", async () => { const postedFixture = { ...createFixture, actor: "https://example.com/users/john", @@ -2246,7 +2246,7 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals( records.some((record) => record.rawMessage === - "Outbox listener for {identifier} returned without calling ctx.sendActivity()." && + "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." && record.properties.identifier === "john" ), true, @@ -2328,7 +2328,7 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals( records.some((record) => record.rawMessage === - "Outbox listener for {identifier} returned without calling ctx.sendActivity()." + "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." ), false, ); @@ -2337,6 +2337,105 @@ test("Federation.setOutboxListeners()", async (t) => { await reset(); } }); + + await t.step( + "does not warn when listener calls forwardActivity()", + async () => { + const postedFixture = await signJsonLd( + { + ...createFixture, + actor: "https://example.com/person2", + }, + rsaPrivateKey3, + rsaPublicKey3.id!, + { contextLoader: mockDocumentLoader }, + ); + const records: LogRecord[] = []; + let ldsVerified = false; + await reset(); + fetchMock.spyGlobal(); + fetchMock.post("https://remote.example/inbox", async (cl) => { + const verifyOptions = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }; + ldsVerified = await verifyJsonLd( + await cl.request!.json(), + verifyOptions, + ); + return new Response(null, { status: ldsVerified ? 202 : 401 }); + }); + + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); + + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation + .setActorDispatcher( + "/{identifier}", + (_ctx, identifier) => + identifier === "person2" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, async (ctx) => { + await ctx.forwardActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://remote.example/users/alice"), + inboxId: new URL("https://remote.example/inbox"), + }, + { skipIfUnsigned: true }, + ); + }) + .authorize((ctx, identifier) => { + return identifier === "person2" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/person2/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals(ldsVerified, true); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." + ), + false, + ); + } finally { + fetchMock.hardReset(); + await reset(); + } + }, + ); }); test("Federation.setInboxDispatcher()", async (t) => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index c4e464834..fc7b46b2e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2846,6 +2846,290 @@ class RequestContextImpl extends ContextImpl } } +type ForwardActivityContext = ContextImpl & { + readonly activity: unknown; + readonly activityId?: string; + readonly activityType: string; +}; + +function forwardActivity( + ctx: ForwardActivityContext, + loggerCategory: "inbox" | "outbox", + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[] | "followers", + options?: ForwardActivityOptions, +): Promise { + const tracer = ctx.tracerProvider.getTracer( + metadata.name, + metadata.version, + ); + return new Promise((resolve, reject) => { + tracer.startActiveSpan( + "activitypub.outbox", + { + kind: ctx.federation.outboxQueue == null || options?.immediate + ? SpanKind.CLIENT + : SpanKind.PRODUCER, + attributes: { "activitypub.activity.type": ctx.activityType }, + }, + async (span) => { + try { + if (ctx.activityId != null) { + span.setAttribute("activitypub.activity.id", ctx.activityId); + } + resolve( + await forwardActivityInternal( + ctx, + loggerCategory, + forwarder, + recipients, + options, + ), + ); + } catch (e) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); + reject(e); + } finally { + span.end(); + } + }, + ); + }); +} + +async function forwardActivityInternal( + ctx: ForwardActivityContext, + loggerCategory: "inbox" | "outbox", + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[] | "followers", + options?: ForwardActivityOptions, +): Promise { + const logger = getLogger(["fedify", "federation", loggerCategory]); + let keys: SenderKeyPair[]; + let identifier: string | null = null; + if ( + "identifier" in forwarder || "username" in forwarder + ) { + if ("identifier" in forwarder) { + identifier = forwarder.identifier; + } else { + const username = forwarder.username; + if (ctx.federation.actorCallbacks?.handleMapper == null) { + identifier = username; + } else { + const mapped = await ctx.federation.actorCallbacks.handleMapper( + ctx, + username, + ); + if (mapped == null) { + throw new Error( + `No actor found for the given username ${ + JSON.stringify(username) + }.`, + ); + } + identifier = mapped; + } + } + const actorKeyPairs = await ctx.getActorKeyPairs(identifier); + if (actorKeyPairs.length < 1) { + throw new Error( + `No key pair found for actor ${JSON.stringify(identifier)}.`, + ); + } + keys = actorKeyPairs.map((kp) => ({ + keyId: kp.keyId, + privateKey: kp.privateKey, + })); + } else if (Array.isArray(forwarder)) { + if (forwarder.length < 1) { + throw new Error("The forwarder's key pairs are empty."); + } + keys = forwarder; + } else { + keys = [forwarder]; + } + if (!hasSignature(ctx.activity)) { + let hasProof: boolean; + try { + const activity = await Activity.fromJsonLd(ctx.activity, ctx); + hasProof = await activity.getProof() != null; + } catch { + hasProof = false; + } + if (!hasProof) { + if (options?.skipIfUnsigned) return false; + logger.warn( + "The received activity {activityId} is not signed; even if it is " + + "forwarded to other servers as is, it may not be accepted by " + + "them due to the lack of a signature/proof.", + ); + } + } + if (recipients === "followers") { + if (identifier == null) { + throw new Error( + 'If recipients is "followers", ' + + "forwarder must be an actor identifier or username.", + ); + } + const followers: Recipient[] = []; + for await (const recipient of ctx.getFollowers(identifier)) { + followers.push(recipient); + } + recipients = followers; + } + const inboxes = extractInboxes({ + recipients: Array.isArray(recipients) ? recipients : [recipients], + preferSharedInbox: options?.preferSharedInbox, + excludeBaseUris: options?.excludeBaseUris, + }); + logger.debug("Forwarding activity {activityId} to inboxes:\n{inboxes}", { + inboxes: globalThis.Object.keys(inboxes), + activityId: ctx.activityId, + activity: ctx.activity, + }); + if (options?.immediate || ctx.federation.outboxQueue == null) { + if (options?.immediate) { + logger.debug( + "Forwarding activity immediately without queue since immediate " + + "option is set.", + ); + } else { + logger.debug( + "Forwarding activity immediately without queue since queue is not " + + "set.", + ); + } + const promises: Promise[] = []; + for (const inbox in inboxes) { + promises.push( + sendActivity({ + keys, + activity: ctx.activity, + activityId: ctx.activityId, + activityType: ctx.activityType, + inbox: new URL(inbox), + sharedInbox: inboxes[inbox].sharedInbox, + tracerProvider: ctx.tracerProvider, + specDeterminer: new KvSpecDeterminer( + ctx.federation.kv, + ctx.federation.kvPrefixes.httpMessageSignaturesSpec, + ctx.federation.firstKnock, + ), + }), + ); + } + await Promise.all(promises); + return true; + } + logger.debug( + "Enqueuing activity {activityId} to forward later.", + { activityId: ctx.activityId, activity: ctx.activity }, + ); + const keyJwkPairs: SenderKeyJwkPair[] = []; + for (const { keyId, privateKey } of keys) { + const privateKeyJwk = await exportJwk(privateKey); + keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk }); + } + const carrier: Record = {}; + propagation.inject(context.active(), carrier); + const orderingKey = options?.orderingKey; + const messages: { message: OutboxMessage; orderingKey?: string }[] = []; + for (const inbox in inboxes) { + const inboxUrl = new URL(inbox); + const message: OutboxMessage = { + type: "outbox", + id: crypto.randomUUID(), + baseUrl: ctx.origin, + keys: keyJwkPairs, + activity: ctx.activity, + activityId: ctx.activityId, + activityType: ctx.activityType, + inbox, + sharedInbox: inboxes[inbox].sharedInbox, + started: new Date().toISOString(), + attempt: 0, + headers: {}, + orderingKey: orderingKey == null + ? undefined + : `${orderingKey}\n${inboxUrl.origin}`, + traceContext: carrier, + }; + messages.push({ + message, + orderingKey: message.orderingKey, + }); + } + const { outboxQueue } = ctx.federation; + if (outboxQueue.enqueueMany == null) { + const promises: Promise[] = messages.map((m) => + outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }) + ); + const results = await Promise.allSettled(promises); + const errors: unknown[] = results + .filter((r) => r.status === "rejected") + .map((r) => (r as PromiseRejectedResult).reason); + if (errors.length > 0) { + logger.error( + "Failed to enqueue activity {activityId} to forward later:\n{errors}", + { activityId: ctx.activityId, errors }, + ); + if (errors.length > 1) { + throw new AggregateError( + errors, + `Failed to enqueue activity ${ctx.activityId} to forward later.`, + ); + } + throw errors[0]; + } + } else { + // Note: enqueueMany does not support per-message orderingKey, + // so we fall back to individual enqueues when orderingKey is specified + if (orderingKey != null) { + const promises: Promise[] = messages.map((m) => + outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }) + ); + const results = await Promise.allSettled(promises); + const errors = results + .filter((r) => r.status === "rejected") + .map((r) => (r as PromiseRejectedResult).reason); + if (errors.length > 0) { + logger.error( + "Failed to enqueue activity {activityId} to forward later:\n{errors}", + { activityId: ctx.activityId, errors }, + ); + if (errors.length > 1) { + throw new AggregateError( + errors, + `Failed to enqueue activity ${ctx.activityId} to forward later.`, + ); + } + throw errors[0]; + } + } else { + try { + await outboxQueue.enqueueMany(messages.map((m) => m.message)); + } catch (error) { + logger.error( + "Failed to enqueue activity {activityId} to forward later:\n{error}", + { activityId: ctx.activityId, error }, + ); + throw error; + } + } + } + return true; +} + export class InboxContextImpl extends ContextImpl implements InboxContext { readonly recipient: string | null; @@ -2910,264 +3194,14 @@ export class InboxContextImpl extends ContextImpl recipients: Recipient | Recipient[] | "followers", options?: ForwardActivityOptions, ): Promise { - const tracer = this.tracerProvider.getTracer( - metadata.name, - metadata.version, - ); - return tracer.startActiveSpan( - "activitypub.outbox", - { - kind: this.federation.outboxQueue == null || options?.immediate - ? SpanKind.CLIENT - : SpanKind.PRODUCER, - attributes: { "activitypub.activity.type": this.activityType }, - }, - async (span) => { - try { - if (this.activityId != null) { - span.setAttribute("activitypub.activity.id", this.activityId); - } - await this.forwardActivityInternal(forwarder, recipients, options); - } catch (e) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); - throw e; - } finally { - span.end(); - } - }, - ); - } - - private async forwardActivityInternal( - forwarder: - | SenderKeyPair - | SenderKeyPair[] - | { identifier: string } - | { username: string }, - recipients: Recipient | Recipient[] | "followers", - options?: ForwardActivityOptions, - ): Promise { - const logger = getLogger(["fedify", "federation", "inbox"]); - let keys: SenderKeyPair[]; - let identifier: string | null = null; - if ( - "identifier" in forwarder || "username" in forwarder - ) { - if ("identifier" in forwarder) { - identifier = forwarder.identifier; - } else { - const username = forwarder.username; - if (this.federation.actorCallbacks?.handleMapper == null) { - identifier = username; - } else { - const mapped = await this.federation.actorCallbacks.handleMapper( - this, - username, - ); - if (mapped == null) { - throw new Error( - `No actor found for the given username ${ - JSON.stringify(username) - }.`, - ); - } - identifier = mapped; - } - } - const actorKeyPairs = await this.getActorKeyPairs(identifier); - if (actorKeyPairs.length < 1) { - throw new Error( - `No key pair found for actor ${JSON.stringify(identifier)}.`, - ); - } - keys = actorKeyPairs.map((kp) => ({ - keyId: kp.keyId, - privateKey: kp.privateKey, - })); - } else if (Array.isArray(forwarder)) { - if (forwarder.length < 1) { - throw new Error("The forwarder's key pairs are empty."); - } - keys = forwarder; - } else { - keys = [forwarder]; - } - if (!hasSignature(this.activity)) { - let hasProof: boolean; - try { - const activity = await Activity.fromJsonLd(this.activity, this); - hasProof = await activity.getProof() != null; - } catch { - hasProof = false; - } - if (!hasProof) { - if (options?.skipIfUnsigned) return; - logger.warn( - "The received activity {activityId} is not signed; even if it is " + - "forwarded to other servers as is, it may not be accepted by " + - "them due to the lack of a signature/proof.", - ); - } - } - if (recipients === "followers") { - if (identifier == null) { - throw new Error( - 'If recipients is "followers", ' + - "forwarder must be an actor identifier or username.", - ); - } - const followers: Recipient[] = []; - for await (const recipient of this.getFollowers(identifier)) { - followers.push(recipient); - } - recipients = followers; - } - const inboxes = extractInboxes({ - recipients: Array.isArray(recipients) ? recipients : [recipients], - preferSharedInbox: options?.preferSharedInbox, - excludeBaseUris: options?.excludeBaseUris, - }); - logger.debug("Forwarding activity {activityId} to inboxes:\n{inboxes}", { - inboxes: globalThis.Object.keys(inboxes), - activityId: this.activityId, - activity: this.activity, - }); - if (options?.immediate || this.federation.outboxQueue == null) { - if (options?.immediate) { - logger.debug( - "Forwarding activity immediately without queue since immediate " + - "option is set.", - ); - } else { - logger.debug( - "Forwarding activity immediately without queue since queue is not " + - "set.", - ); - } - const promises: Promise[] = []; - for (const inbox in inboxes) { - promises.push( - sendActivity({ - keys, - activity: this.activity, - activityId: this.activityId, - activityType: this.activityType, - inbox: new URL(inbox), - sharedInbox: inboxes[inbox].sharedInbox, - tracerProvider: this.tracerProvider, - specDeterminer: new KvSpecDeterminer( - this.federation.kv, - this.federation.kvPrefixes.httpMessageSignaturesSpec, - this.federation.firstKnock, - ), - }), - ); - } - await Promise.all(promises); - return; - } - logger.debug( - "Enqueuing activity {activityId} to forward later.", - { activityId: this.activityId, activity: this.activity }, - ); - const keyJwkPairs: SenderKeyJwkPair[] = []; - for (const { keyId, privateKey } of keys) { - const privateKeyJwk = await exportJwk(privateKey); - keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk }); - } - const carrier: Record = {}; - propagation.inject(context.active(), carrier); - const orderingKey = options?.orderingKey; - const messages: { message: OutboxMessage; orderingKey?: string }[] = []; - for (const inbox in inboxes) { - const inboxUrl = new URL(inbox); - const message: OutboxMessage = { - type: "outbox", - id: crypto.randomUUID(), - baseUrl: this.origin, - keys: keyJwkPairs, - activity: this.activity, - activityId: this.activityId, - activityType: this.activityType, - inbox, - sharedInbox: inboxes[inbox].sharedInbox, - started: new Date().toISOString(), - attempt: 0, - headers: {}, - orderingKey: orderingKey == null - ? undefined - : `${orderingKey}\n${inboxUrl.origin}`, - traceContext: carrier, - }; - messages.push({ - message, - orderingKey: message.orderingKey, - }); - } - const { outboxQueue } = this.federation; - if (outboxQueue.enqueueMany == null) { - const promises: Promise[] = messages.map((m) => - outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }) - ); - const results = await Promise.allSettled(promises); - const errors: unknown[] = results - .filter((r) => r.status === "rejected") - .map((r) => (r as PromiseRejectedResult).reason); - if (errors.length > 0) { - logger.error( - "Failed to enqueue activity {activityId} to forward later:\n{errors}", - { activityId: this.activityId, errors }, - ); - if (errors.length > 1) { - throw new AggregateError( - errors, - `Failed to enqueue activity ${this.activityId} to forward later.`, - ); - } - throw errors[0]; - } - } else { - // Note: enqueueMany does not support per-message orderingKey, - // so we fall back to individual enqueues when orderingKey is specified - if (orderingKey != null) { - const promises: Promise[] = messages.map((m) => - outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }) - ); - const results = await Promise.allSettled(promises); - const errors = results - .filter((r) => r.status === "rejected") - .map((r) => (r as PromiseRejectedResult).reason); - if (errors.length > 0) { - logger.error( - "Failed to enqueue activity {activityId} to forward later:\n{errors}", - { activityId: this.activityId, errors }, - ); - if (errors.length > 1) { - throw new AggregateError( - errors, - `Failed to enqueue activity ${this.activityId} to forward later.`, - ); - } - throw errors[0]; - } - } else { - try { - await outboxQueue.enqueueMany(messages.map((m) => m.message)); - } catch (error) { - logger.error( - "Failed to enqueue activity {activityId} to forward later:\n{error}", - { activityId: this.activityId, error }, - ); - throw error; - } - } - } + return forwardActivity(this, "inbox", forwarder, recipients, options) + .then(() => undefined); } } export class OutboxContextImpl extends ContextImpl implements OutboxContext { - readonly #sendActivityState: { sent: boolean }; + readonly #deliveryState: { delivered: boolean }; readonly identifier: string; readonly activity: unknown; readonly activityId?: string; @@ -3179,18 +3213,18 @@ export class OutboxContextImpl extends ContextImpl activityId: string | undefined, activityType: string, options: ContextOptions, - sendActivityState: { sent: boolean } = { sent: false }, + deliveryState: { delivered: boolean } = { delivered: false }, ) { super(options); - this.#sendActivityState = sendActivityState; + this.#deliveryState = deliveryState; this.identifier = identifier; this.activity = activity; this.activityId = activityId; this.activityType = activityType; } - hasSentActivity(): boolean { - return this.#sendActivityState.sent; + hasDeliveredActivity(): boolean { + return this.#deliveryState.delivered; } override sendActivity( @@ -3203,8 +3237,42 @@ export class OutboxContextImpl extends ContextImpl activity: Activity, options: SendActivityOptionsForCollection = {}, ): Promise { - this.#sendActivityState.sent = true; - return super.sendActivity(sender, recipients, activity, options); + return super.sendActivity(sender, recipients, activity, options).then( + () => { + this.#deliveryState.delivered = true; + }, + ); + } + + forwardActivity( + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[], + options?: ForwardActivityOptions, + ): Promise; + forwardActivity( + forwarder: + | { identifier: string } + | { username: string }, + recipients: "followers", + options?: ForwardActivityOptions, + ): Promise; + forwardActivity( + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[] | "followers", + options?: ForwardActivityOptions, + ): Promise { + return forwardActivity(this, "outbox", forwarder, recipients, options) + .then((delivered) => { + if (delivered) this.#deliveryState.delivered = true; + }); } override clone(data: TContextData): OutboxContext { @@ -3222,7 +3290,7 @@ export class OutboxContextImpl extends ContextImpl invokedFromActorKeyPairsDispatcher: this.invokedFromActorKeyPairsDispatcher, }, - this.#sendActivityState, + this.#deliveryState, ); } } diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index ffc3313f4..19d1f862f 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -148,9 +148,10 @@ export function createInboxContext( ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, - forwardActivity: args.forwardActivity ?? ((_params) => { - throw new Error("Not implemented"); - }), + forwardActivity: args.forwardActivity ?? + ((_forwarder, _recipients, _options) => { + throw new Error("Not implemented"); + }), }; } @@ -162,9 +163,14 @@ export function createOutboxContext( federation: Federation; }, ): OutboxContext { + const forwardActivity = args.forwardActivity ?? + (((_forwarder: unknown, _recipients: unknown, _options?: unknown) => { + throw new Error("Not implemented"); + }) as OutboxContext["forwardActivity"]); return { ...createContext(args), clone: args.clone ?? ((data) => createOutboxContext({ ...args, data })), identifier: args.identifier, + forwardActivity, }; } From 32f515a1318522d22acd123ddd8dea036f465333 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 21:27:18 +0900 Subject: [PATCH 09/64] Clarify outbox delivery checks and guidance Now that outbox listeners can either send a new activity or forward the posted payload as-is, the surrounding rule names, docs, and testing helpers need to say "delivery" rather than "sendActivity". This renames the lint rule, updates the manuals and changelog, and teaches @fedify/testing's outbox mocks about forwardActivity and skipIfUnsigned. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- CHANGES.md | 18 +++--- docs/manual/lint.md | 22 ++++++-- docs/manual/outbox.md | 35 ++++++++++-- packages/lint/src/index.ts | 7 +-- packages/lint/src/lib/const.ts | 2 +- packages/lint/src/mod.ts | 7 +-- ...s => outbox-listener-delivery-required.ts} | 18 +++--- packages/lint/src/tests/integration.test.ts | 24 +++++++- ...outbox-listener-delivery-required.test.ts} | 44 +++++++++++---- packages/testing/src/context.ts | 13 ++++- packages/testing/src/mock.test.ts | 56 +++++++++++++++++++ packages/testing/src/mock.ts | 15 +++++ 12 files changed, 209 insertions(+), 52 deletions(-) rename packages/lint/src/rules/{outbox-listener-send-activity-required.ts => outbox-listener-delivery-required.ts} (84%) rename packages/lint/src/tests/{outbox-listener-send-activity-required.test.ts => outbox-listener-delivery-required.test.ts} (62%) diff --git a/CHANGES.md b/CHANGES.md index 9615b8641..ed3812d9f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,9 +13,10 @@ To be released. - Added `setOutboxListeners()` and `OutboxContext` for handling client-to-server `POST` requests to actor outboxes. Outbox listeners use application-defined authorization through `.authorize()`, catch activity - types with `.on()`, and require explicit `ctx.sendActivity()` calls for any - federation delivery. Fedify now also logs a runtime warning when an - outbox listener returns without calling `ctx.sendActivity()`. + types with `.on()`, and require explicit delivery through + `ctx.sendActivity()` or `ctx.forwardActivity()`. Fedify now also logs a + runtime warning when an outbox listener returns without delivering the + posted activity. [[#430], [#688]] - Allowed actor dispatchers to return `Tombstone` for deleted accounts. @@ -39,16 +40,17 @@ To be released. ### @fedify/lint - - Added the `outbox-listener-send-activity-required` rule. It warns when an + - Added the `outbox-listener-delivery-required` rule. It warns when an outbox listener registered through `setOutboxListeners()` returns without an - explicit `ctx.sendActivity()` call, which would otherwise leave a posted - client activity unfederated. [[#430], [#688]] + explicit delivery call, which would otherwise leave a posted client + activity unfederated. [[#430], [#688]] ### @fedify/testing - Added `createOutboxContext()` plus `postOutboxActivity()` and mock - `setOutboxListeners()` support so outbox listeners can be tested without - spinning up a live federation server. [[#430], [#688]] + `setOutboxListeners()` support so outbox listeners using either + `sendActivity()` or `forwardActivity()` can be tested without spinning up + a live federation server. [[#430], [#688]] ### @fedify/vocab-runtime diff --git a/docs/manual/lint.md b/docs/manual/lint.md index 7c2413b64..9227a9295 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -597,18 +597,19 @@ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => { }); ~~~~ -### `outbox-listener-send-activity-required` +### `outbox-listener-delivery-required` -Warns when an outbox listener body does not call `ctx.sendActivity()`. +Warns when an outbox listener body does not deliver the posted activity with +`ctx.sendActivity()` or `ctx.forwardActivity()`. **When this rule applies:** You've registered an outbox listener with `setOutboxListeners()`, but the -listener body never calls `ctx.sendActivity()`. +listener body never calls either delivery method. **Why it matters:** Fedify does not federate client-to-server outbox posts automatically. If your -application intends to deliver a posted activity, the listener must call -`ctx.sendActivity()` explicitly. +application intends to deliver a posted activity, the listener must choose an +explicit delivery path. ~~~~ typescript twoslash // @noErrors: 2345 @@ -633,6 +634,17 @@ federation activity, ); }); + +// ✅ Good: Listener forwards the original posted payload explicitly +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + [], + { skipIfUnsigned: true }, + ); + }); ~~~~ ### `actor-followers-property-required` diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md index 94734bacd..f190a3b3d 100644 --- a/docs/manual/outbox.md +++ b/docs/manual/outbox.md @@ -96,8 +96,8 @@ Federating posted activities ---------------------------- Fedify does not federate client-posted activities automatically. If you want -to deliver a posted activity, call `~Context.sendActivity()` explicitly inside -your outbox listener. +to deliver a posted activity, call `~Context.sendActivity()` or +`~OutboxContext.forwardActivity()` explicitly inside your outbox listener. ~~~~ typescript twoslash import { type Federation } from "@fedify/fedify"; @@ -116,9 +116,31 @@ federation }); ~~~~ -If a listener returns without calling `~Context.sendActivity()`, Fedify logs a -runtime warning. The `@fedify/lint` package also provides a lint rule for the -same mistake; see [*Linting*](./lint.md) for details. +If the client already signed the posted JSON-LD with Linked Data Signatures or +Object Integrity Proofs and you want to preserve that payload verbatim, use +`~OutboxContext.forwardActivity()` instead of round-tripping through Fedify's +vocabulary objects: + +~~~~ typescript twoslash +import { type Federation } from "@fedify/fedify"; +import { Activity, Person } from "@fedify/vocab"; +const federation = null as unknown as Federation; +const recipients: Person[] = []; +// ---cut-before--- +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + recipients, + { skipIfUnsigned: true }, + ); + }); +~~~~ + +If a listener returns without calling one of these delivery methods, Fedify +logs a runtime warning. The `@fedify/lint` package also provides a lint rule +for the same mistake; see [*Linting*](./lint.md) for details. > [!TIP] > Explicit delivery keeps outbox listeners symmetric with inbox listeners: @@ -160,7 +182,8 @@ In particular, Fedify does not currently do the following for you: - Persist the posted activity in your outbox collection - Generate IDs or `Location` headers for newly posted activities - Wrap non-`Activity` objects in `Create` automatically - - Federate anything unless your listener calls `ctx.sendActivity()` + - Federate anything unless your listener calls `ctx.sendActivity()` or + `ctx.forwardActivity()` If you need full `GET /outbox` support as well, combine this guide with the [*Collections*](./collections.md#outbox) guide. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 8bf7706a7..c6ae11ae8 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -67,8 +67,8 @@ import { eslint as collectionFiltering, } from "./rules/collection-filtering-not-implemented.ts"; import { - eslint as outboxListenerSendActivityRequired, -} from "./rules/outbox-listener-send-activity-required.ts"; + eslint as outboxListenerDeliveryRequired, +} from "./rules/outbox-listener-delivery-required.ts"; const rules: Record< typeof RULE_IDS[keyof typeof RULE_IDS], @@ -97,8 +97,7 @@ const rules: Record< [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, - [RULE_IDS.outboxListenerSendActivityRequired]: - outboxListenerSendActivityRequired, + [RULE_IDS.outboxListenerDeliveryRequired]: outboxListenerDeliveryRequired, }; const recommendedRuleIds: (keyof typeof rules)[] = [ diff --git a/packages/lint/src/lib/const.ts b/packages/lint/src/lib/const.ts index 6900f122c..d94e1871f 100644 --- a/packages/lint/src/lib/const.ts +++ b/packages/lint/src/lib/const.ts @@ -137,5 +137,5 @@ export const RULE_IDS = { collectionFilteringNotImplemented: "collection-filtering-not-implemented", // Listener rules - outboxListenerSendActivityRequired: "outbox-listener-send-activity-required", + outboxListenerDeliveryRequired: "outbox-listener-delivery-required", } as const; diff --git a/packages/lint/src/mod.ts b/packages/lint/src/mod.ts index c4dc5e637..6cfc7c501 100644 --- a/packages/lint/src/mod.ts +++ b/packages/lint/src/mod.ts @@ -59,8 +59,8 @@ import { deno as collectionFiltering, } from "./rules/collection-filtering-not-implemented.ts"; import { - deno as outboxListenerSendActivityRequired, -} from "./rules/outbox-listener-send-activity-required.ts"; + deno as outboxListenerDeliveryRequired, +} from "./rules/outbox-listener-delivery-required.ts"; const plugin: Deno.lint.Plugin = { name: "fedify-lint", @@ -90,8 +90,7 @@ const plugin: Deno.lint.Plugin = { [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, - [RULE_IDS.outboxListenerSendActivityRequired]: - outboxListenerSendActivityRequired, + [RULE_IDS.outboxListenerDeliveryRequired]: outboxListenerDeliveryRequired, }, }; diff --git a/packages/lint/src/rules/outbox-listener-send-activity-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts similarity index 84% rename from packages/lint/src/rules/outbox-listener-send-activity-required.ts rename to packages/lint/src/rules/outbox-listener-delivery-required.ts index 8106a0e2a..fa4c84d92 100644 --- a/packages/lint/src/rules/outbox-listener-send-activity-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -8,7 +8,8 @@ import { import { trackFederationVariables } from "../lib/tracker.ts"; import type { CallExpression, Expression, FunctionNode } from "../lib/types.ts"; -const MESSAGE = "Outbox listeners should call ctx.sendActivity() explicitly."; +const MESSAGE = + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity()."; const isChainedFromOutboxListeners = ( expr: Expression, @@ -37,13 +38,15 @@ const stripCommentsAndStrings = (code: string): string => .replaceAll(/\/\/.*$/gm, "") .replaceAll(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, '""'); -const listenerCallsSendActivity = ( +const listenerCallsDeliveryMethod = ( sourceCode: { getText(node: unknown): string }, listener: FunctionNode, ): boolean => - stripCommentsAndStrings(sourceCode.getText(listener)).includes( - ".sendActivity(", - ); + [".sendActivity(", ".forwardActivity("] + .some((method) => + stripCommentsAndStrings(sourceCode.getText(listener)) + .includes(method) + ); function createRule( buildReport: Context extends Deno.lint.RuleContext ? { @@ -81,7 +84,7 @@ function createRule( const listener = node.arguments[1]; if (!isFunction(listener)) return; - if (listenerCallsSendActivity(sourceCode, listener)) return; + if (listenerCallsDeliveryMethod(sourceCode, listener)) return; (context as { report: (arg: unknown) => void }).report({ node: listener, @@ -100,7 +103,8 @@ export const eslint: Rule.RuleModule = { meta: { type: "suggestion", docs: { - description: "Warn when an outbox listener omits ctx.sendActivity()", + description: + "Warn when an outbox listener omits explicit delivery methods", }, schema: [], messages: { diff --git a/packages/lint/src/tests/integration.test.ts b/packages/lint/src/tests/integration.test.ts index 642daf636..8361790a9 100644 --- a/packages/lint/src/tests/integration.test.ts +++ b/packages/lint/src/tests/integration.test.ts @@ -180,7 +180,7 @@ test("Integration: ✅ Complete valid code passes all rules", () => { }); test( - "Integration: ✅ outbox-listener-send-activity-required - explicit sendActivity", + "Integration: ✅ outbox-listener-delivery-required - explicit sendActivity", () => assertNoErrors(`${COMPLETE_VALID_CODE} @@ -198,7 +198,25 @@ federation ); test( - "Integration: ❌ outbox-listener-send-activity-required - missing sendActivity", + "Integration: ✅ outbox-listener-delivery-required - explicit forwardActivity", + () => + assertNoErrors(`${COMPLETE_VALID_CODE} + +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + [], + { skipIfUnsigned: true }, + ); + });`), +); + +test( + "Integration: ❌ outbox-listener-delivery-required - missing delivery", () => pipe( `${COMPLETE_VALID_CODE} @@ -210,7 +228,7 @@ federation .on(Activity, async (ctx, activity) => { console.log(ctx.identifier, activity.id?.href); });`, - assertHasError("outbox-listener-send-activity-required"), + assertHasError("outbox-listener-delivery-required"), ), ); diff --git a/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts similarity index 62% rename from packages/lint/src/tests/outbox-listener-send-activity-required.test.ts rename to packages/lint/src/tests/outbox-listener-delivery-required.test.ts index 6cb0bbf48..f4057519b 100644 --- a/packages/lint/src/tests/outbox-listener-send-activity-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -1,9 +1,9 @@ import { test } from "node:test"; import { RULE_IDS } from "../lib/const.ts"; import lintTest from "../lib/test.ts"; -import * as rule from "../rules/outbox-listener-send-activity-required.ts"; +import * as rule from "../rules/outbox-listener-delivery-required.ts"; -const ruleName = RULE_IDS.outboxListenerSendActivityRequired; +const ruleName = RULE_IDS.outboxListenerDeliveryRequired; test( `${ruleName}: ✅ Good - direct sendActivity call`, @@ -26,6 +26,27 @@ federation }), ); +test( + `${ruleName}: ✅ Good - direct forwardActivity call`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + [], + { skipIfUnsigned: true }, + ); + }); +`, + rule, + ruleName, + }), +); + test( `${ruleName}: ✅ Good - non-federation object`, lintTest({ @@ -56,7 +77,7 @@ fakeFederation ); test( - `${ruleName}: ❌ Bad - missing sendActivity call`, + `${ruleName}: ❌ Bad - missing delivery call`, lintTest({ code: ` import { Activity } from "@fedify/vocab"; @@ -70,12 +91,12 @@ federation rule, ruleName, expectedError: - "Outbox listeners should call ctx.sendActivity() explicitly.", + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); test( - `${ruleName}: ❌ Bad - chained authorize without sendActivity`, + `${ruleName}: ❌ Bad - chained authorize without delivery`, lintTest({ code: ` import { Activity } from "@fedify/vocab"; @@ -90,12 +111,12 @@ federation rule, ruleName, expectedError: - "Outbox listeners should call ctx.sendActivity() explicitly.", + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); test( - `${ruleName}: ❌ Bad - comment mentioning sendActivity`, + `${ruleName}: ❌ Bad - comment mentioning delivery methods`, lintTest({ code: ` import { Activity } from "@fedify/vocab"; @@ -104,18 +125,19 @@ federation .setOutboxListeners("/users/{identifier}/outbox") .on(Activity, async (ctx, activity) => { // ctx.sendActivity(...) + // ctx.forwardActivity(...) console.log(ctx.identifier, activity.id?.href); }); `, rule, ruleName, expectedError: - "Outbox listeners should call ctx.sendActivity() explicitly.", + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); test( - `${ruleName}: ❌ Bad - string mentioning sendActivity`, + `${ruleName}: ❌ Bad - string mentioning delivery methods`, lintTest({ code: ` import { Activity } from "@fedify/vocab"; @@ -123,12 +145,12 @@ import { Activity } from "@fedify/vocab"; federation .setOutboxListeners("/users/{identifier}/outbox") .on(Activity, async () => { - return ".sendActivity("; + return ".sendActivity(.forwardActivity("; }); `, rule, ruleName, expectedError: - "Outbox listeners should call ctx.sendActivity() explicitly.", + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index 6ce621f94..c203b2241 100644 --- a/packages/testing/src/context.ts +++ b/packages/testing/src/context.ts @@ -201,13 +201,15 @@ function createInboxContext( federation: Federation; }, ): TestInboxContext { + const forwardActivity = args.forwardActivity ?? + (((_forwarder: unknown, _recipients: unknown, _options?: unknown) => { + throw new Error("Not implemented"); + }) as TestInboxContext["forwardActivity"]); return { ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, - forwardActivity: args.forwardActivity ?? ((_params) => { - throw new Error("Not implemented"); - }), + forwardActivity, }; } @@ -226,11 +228,16 @@ function createOutboxContext( federation: Federation; }, ): TestOutboxContext { + const forwardActivity = args.forwardActivity ?? + (((_forwarder: unknown, _recipients: unknown, _options?: unknown) => { + throw new Error("Not implemented"); + }) as TestOutboxContext["forwardActivity"]); return { ...createContext(args), clone: args.clone ?? ((data: TContextData) => createOutboxContext({ ...args, data })), identifier: args.identifier, + forwardActivity, }; } diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 706083a38..c704776ca 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -135,6 +135,62 @@ test("postOutboxActivity triggers outbox listeners", async () => { assertEquals(mockFederation.sentActivities[0].activity, activity); }); +test("postOutboxActivity supports forwardActivity", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + ); + }, + ); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 1); + assertEquals(mockFederation.sentActivities[0].activity, activity); +}); + +test("postOutboxActivity forwardActivity respects skipIfUnsigned", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + { skipIfUnsigned: true }, + ); + }, + ); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 0); +}); + test("postOutboxActivity prefers the most specific listener", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 410dc04ca..e5ca184be 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -516,6 +516,21 @@ class MockFederation implements Federation { federation: this as any, identifier, sendActivity: baseContext.sendActivity.bind(baseContext), + forwardActivity: async ( + forwarder: any, + recipients: any, + options?: any, + ) => { + if (options?.skipIfUnsigned && await activity.getProof() == null) { + return; + } + return baseContext.sendActivity( + forwarder, + recipients, + activity, + options, + ); + }, }); await listener(context, activity); } From 41fb3b98b52ad8b5033a662c122f35035bc9eade Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 21:57:51 +0900 Subject: [PATCH 10/64] Tighten outbox listener request handling Outbox listener POST handling had a few edge cases that diverged from the existing outbox dispatcher flow. This reuses dispatcher authorization when listener-specific authorization is unset, routes actor-mismatch rejections through the outbox error hook for consistent observability, removes an unnecessary request clone while parsing JSON, and clarifies mismatch wording in the builder and public JSDoc. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/builder.ts | 2 +- packages/fedify/src/federation/federation.ts | 5 +- .../fedify/src/federation/handler.test.ts | 44 ++++++++---- packages/fedify/src/federation/handler.ts | 32 ++++++--- .../fedify/src/federation/middleware.test.ts | 68 +++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 3 +- 6 files changed, 129 insertions(+), 25 deletions(-) diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 727ff7274..017b533ec 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -729,7 +729,7 @@ export class FederationBuilderImpl if (this.router.has("outbox")) { if (this.outboxPath !== path) { throw new RouterError( - "Outbox listener path must match outbox dispatcher path.", + "Outbox dispatcher path must match outbox listener path.", ); } } else { diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 3d8480ad7..6dad929aa 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -328,8 +328,9 @@ export interface Federatable { * @param outboxPath The URI path pattern for the outbox. The syntax is based * on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The - * path must have one variable: `{identifier}`, and must - * match the outbox dispatcher path. + * path must have one variable: `{identifier}`. If an + * outbox dispatcher is configured, this path must match + * the outbox dispatcher path. * @returns An object to register outbox listeners. * @throws {RouterError} Thrown if the path pattern is invalid. * @since 2.2.0 diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 0a3c75c57..f763e63aa 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1345,20 +1345,25 @@ test("handleOutbox()", async () => { content: "Hello, world!", }), }); - const request = new Request("https://example.com/users/someone/outbox", { - method: "POST", - body: JSON.stringify(await activity.toJsonLd()), - }); + const requestUrl = "https://example.com/users/someone/outbox"; + const requestBody = JSON.stringify(await activity.toJsonLd()); const federation = createFederation({ kv: new MemoryKvStore() }); - const context = createRequestContext({ - federation, - request, - url: new URL(request.url), - data: undefined, - getActorUri(identifier: string) { - return new URL(`https://example.com/users/${identifier}`); - }, - }); + const createRequestContextPair = (body = requestBody) => { + const request = new Request(requestUrl, { + method: "POST", + body, + }); + const context = createRequestContext({ + federation, + request, + url: new URL(request.url), + data: undefined, + getActorUri(identifier: string) { + return new URL(`https://example.com/users/${identifier}`); + }, + }); + return { request, context }; + }; let onNotFoundCalled: Request | null = null; const onNotFound = (request: Request) => { onNotFoundCalled = request; @@ -1379,6 +1384,7 @@ test("handleOutbox()", async () => { seen.push(`${ctx.identifier}:${activity.id?.href}`); }); + let { request, context } = createRequestContextPair(); let response = await handleOutbox(request, { identifier: "someone", context, @@ -1398,6 +1404,7 @@ test("handleOutbox()", async () => { assertEquals(response.status, 404); onNotFoundCalled = null; + ({ request, context } = createRequestContextPair()); response = await handleOutbox(request, { identifier: "nobody", context, @@ -1417,6 +1424,7 @@ test("handleOutbox()", async () => { assertEquals(response.status, 404); onNotFoundCalled = null; + ({ request, context } = createRequestContextPair()); response = await handleOutbox(request, { identifier: "someone", context, @@ -1439,6 +1447,7 @@ test("handleOutbox()", async () => { assertEquals(seen, []); onUnauthorizedCalled = null; + ({ request, context } = createRequestContextPair()); response = await handleOutbox(request, { identifier: "someone", context, @@ -1526,6 +1535,7 @@ test("handleOutbox()", async () => { return new URL(`https://example.com/users/${identifier}`); }, }); + let mismatchedActorErrorMessage: string | null = null; response = await handleOutbox(mismatchedActorRequest, { identifier: "someone", context: mismatchedActorContext, @@ -1538,6 +1548,9 @@ test("handleOutbox()", async () => { }, actorDispatcher, outboxListeners: listeners, + outboxErrorHandler: (_ctx, error) => { + mismatchedActorErrorMessage = error.message; + }, onNotFound, onUnauthorized, }); @@ -1545,12 +1558,17 @@ test("handleOutbox()", async () => { [response.status, await response.text()], [400, "The activity actor does not match the outbox owner."], ); + assertEquals( + mismatchedActorErrorMessage, + "The activity actor does not match the outbox owner.", + ); const throwingListeners = new OutboxListenerSet(); let onErrorCalled = false; throwingListeners.add(Create, () => { throw new Error("Boom"); }); + ({ request, context } = createRequestContextPair()); response = await handleOutbox(request, { identifier: "someone", context, diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index fef73d19b..27f4195db 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -542,7 +542,7 @@ export async function handleOutbox( } let json: unknown; try { - json = await request.clone().json(); + json = await request.json(); } catch (error) { logger.error("Failed to parse JSON:\n{error}", { identifier, error }); const outboxContext = outboxContextFactory(identifier, null, undefined, ""); @@ -582,11 +582,20 @@ export async function handleOutbox( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } + const outboxContext = outboxContextFactory( + identifier, + json, + activity.id?.href, + getTypeId(activity).href, + ); const expectedActorId = actor.id ?? ctx.getActorUri(identifier); if ( activity.actorIds.length < 1 || !activity.actorIds.every((actorId) => actorId.href === expectedActorId.href) ) { + const error = new Error( + "The activity actor does not match the outbox owner.", + ); logger.error( "The posted activity actor does not match outbox owner {identifier}.", { @@ -596,7 +605,20 @@ export async function handleOutbox( actorIds: activity.actorIds.map((actorId) => actorId.href), }, ); - return new Response("The activity actor does not match the outbox owner.", { + try { + await outboxErrorHandler?.(outboxContext, error); + } catch (error) { + logger.error( + "An unexpected error occurred in outbox error handler:\n{error}", + { + error, + activityId: activity.id?.href, + activity: json, + identifier, + }, + ); + } + return new Response(error.message, { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); @@ -609,12 +631,6 @@ export async function handleOutbox( }); return new Response(null, { status: 202 }); } - const outboxContext = outboxContextFactory( - identifier, - json, - activity.id?.href, - getTypeId(activity).href, - ); try { await dispatched.listener(outboxContext, activity); } catch (error) { diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index c8a73c435..605b47373 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2189,6 +2189,74 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals(response.status, 405); }); + await t.step( + "falls back to outbox dispatcher authorize when listener authorize is unset", + async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + const received: string[] = []; + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxDispatcher( + "/users/{identifier}/outbox", + () => ({ items: [] }), + ) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, (ctx, activity) => { + received.push(`${ctx.identifier}:${activity.id?.href}`); + }); + + let response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 401); + assertEquals(received, []); + + response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + assertEquals([response.status, await response.text()], [202, ""]); + assertEquals(received, [`john:${createFixture.id}`]); + }, + ); + await t.step("warns when listener omits delivery", async () => { const postedFixture = { ...createFixture, diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index fc7b46b2e..2f9ee867e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1470,7 +1470,8 @@ export class FederationImpl context, outboxContextFactory: context.toOutboxContext.bind(context), actorDispatcher: this.actorCallbacks?.dispatcher, - authorizePredicate: this.outboxAuthorizePredicate, + authorizePredicate: this.outboxAuthorizePredicate ?? + this.outboxCallbacks?.authorizePredicate, outboxListeners: this.outboxListeners, outboxErrorHandler: this.outboxListenerErrorHandler, onUnauthorized, From 47f08a68f3ba9afe63b8dfd5e5e5ee15d6fb0821 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 21:58:14 +0900 Subject: [PATCH 11/64] Match outbox lint and mock behavior to runtime Review feedback exposed two places where helper behavior still drifted from production semantics. This teaches the lint rule to follow named callbacks and actual context-bound delivery calls, and it updates @fedify/testing's outbox mock to mirror constructor-based dispatch, prototype lookup, ownership checks, and skipIfUnsigned handling. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.ts | 188 ++++++++++++++++-- packages/lint/src/tests/integration.test.ts | 39 ++++ .../outbox-listener-delivery-required.test.ts | 83 ++++++++ packages/testing/src/mock.test.ts | 59 +++++- packages/testing/src/mock.ts | 41 ++-- 5 files changed, 383 insertions(+), 27 deletions(-) diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts index fa4c84d92..c686a6d2a 100644 --- a/packages/lint/src/rules/outbox-listener-delivery-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -4,9 +4,17 @@ import { hasMemberExpressionCallee, hasMethodName, isFunction, + isNode, } from "../lib/pred.ts"; import { trackFederationVariables } from "../lib/tracker.ts"; -import type { CallExpression, Expression, FunctionNode } from "../lib/types.ts"; +import type { + CallExpression, + Expression, + FunctionNode, + Identifier, + Node, + VariableDeclarator, +} from "../lib/types.ts"; const MESSAGE = "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity()."; @@ -32,21 +40,156 @@ const isChainedFromOutboxListeners = ( return false; }; +const DELIVERY_METHOD_NAMES = new Set(["sendActivity", "forwardActivity"]); + +type FunctionLikeNode = + | FunctionNode + | (Node & { + type: "FunctionDeclaration"; + id: Identifier | null; + params: unknown[]; + body: unknown; + }); + +const getMemberPropertyName = (expr: Expression): string | null => { + if (expr.type !== "MemberExpression") return null; + const property = expr.property as Node; + if (property.type === "Identifier") return property.name; + if (property.type === "Literal" && typeof property.value === "string") { + return property.value; + } + return null; +}; + const stripCommentsAndStrings = (code: string): string => code .replaceAll(/\/\*[\s\S]*?\*\//g, "") .replaceAll(/\/\/.*$/gm, "") .replaceAll(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, '""'); +const resolveListenerReference = ( + expr: Expression, + bindings: Map, + seen = new Set(), +): FunctionLikeNode | null => { + if (isFunction(expr)) return expr; + if (expr.type === "Identifier") { + if (seen.has(expr.name)) return null; + seen.add(expr.name); + const binding = bindings.get(expr.name); + if (binding == null || !isNode(binding)) return null; + if ( + isFunction(binding as Expression) || + (binding as { type?: string }).type === "FunctionDeclaration" + ) { + return binding as FunctionLikeNode; + } + if (binding.type === "Identifier") { + return resolveListenerReference(binding, bindings, seen); + } + return null; + } + if ( + expr.type === "MemberExpression" && expr.object.type === "Identifier" && + !expr.computed + ) { + const binding = bindings.get(expr.object.name); + if ( + binding == null || !isNode(binding) || binding.type !== "ObjectExpression" + ) { + return null; + } + const propertyName = getMemberPropertyName(expr); + if (propertyName == null) return null; + for (const prop of binding.properties) { + if (!isNode(prop) || prop.type !== "Property") continue; + const keyName = prop.key.type === "Identifier" + ? prop.key.name + : prop.key.type === "Literal" && typeof prop.key.value === "string" + ? prop.key.value + : null; + if (keyName !== propertyName || !isNode(prop.value)) continue; + const value = prop.value as unknown; + if ( + isFunction(value as Expression) || + (value as { type?: string }).type === "FunctionDeclaration" + ) { + return value as FunctionLikeNode; + } + } + } + return null; +}; + const listenerCallsDeliveryMethod = ( sourceCode: { getText(node: unknown): string }, - listener: FunctionNode, -): boolean => - [".sendActivity(", ".forwardActivity("] - .some((method) => - stripCommentsAndStrings(sourceCode.getText(listener)) - .includes(method) + listener: FunctionLikeNode, +): boolean => { + const code = stripCommentsAndStrings(sourceCode.getText(listener)); + const aliases = new Set(); + const contextParam = listener.params[0] as Node | undefined; + const contextName = contextParam?.type === "Identifier" + ? (contextParam as Identifier).name + : null; + + if (contextParam?.type === "ObjectPattern") { + for (const prop of contextParam.properties) { + if ((prop as Node).type !== "Property") continue; + const property = prop as { + key: Node; + value: Node; + }; + const keyName = property.key.type === "Identifier" + ? property.key.name + : property.key.type === "Literal" && + typeof property.key.value === "string" + ? property.key.value + : null; + if (keyName == null || !DELIVERY_METHOD_NAMES.has(keyName)) continue; + if (property.value.type === "Identifier") { + aliases.add(property.value.name); + } + } + } + + if (contextName != null) { + const memberPattern = new RegExp( + String + .raw`\b${contextName}\s*(?:\.\s*(?:sendActivity|forwardActivity)|\[\s*["'](?:sendActivity|forwardActivity)["']\s*\])\s*\(`, ); + if (memberPattern.test(code)) return true; + + const destructuringPattern = new RegExp( + String.raw`(?:const|let|var)\s*{([^}]*)}\s*=\s*${contextName}\b`, + "g", + ); + for (const match of code.matchAll(destructuringPattern)) { + const fields = match[1].split(",").map((field) => field.trim()).filter( + Boolean, + ); + for (const field of fields) { + const [sourceName, aliasName] = field.split(":").map((part) => + part.trim() + ); + if (!DELIVERY_METHOD_NAMES.has(sourceName)) continue; + aliases.add(aliasName ?? sourceName); + } + } + + const aliasPattern = new RegExp( + String + .raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${contextName}\s*(?:\.\s*(sendActivity|forwardActivity)|\[\s*["'](sendActivity|forwardActivity)["']\s*\])`, + "g", + ); + for (const match of code.matchAll(aliasPattern)) { + aliases.add(match[1]); + } + } + + return globalThis.Array.from(aliases).some((alias) => + new RegExp(String.raw`\b${alias}\s*\(`).test(code) + ); +}; function createRule( buildReport: Context extends Deno.lint.RuleContext ? { @@ -59,12 +202,27 @@ function createRule( ) { return (context: Context) => { const federationTracker = trackFederationVariables(); + const bindings = new Map(); const sourceCode = (context as { sourceCode: { getText(node: unknown): string } }) .sourceCode; return { - VariableDeclarator: federationTracker.VariableDeclarator, + VariableDeclarator(node: VariableDeclarator): void { + federationTracker.VariableDeclarator(node); + if (node.id.type === "Identifier" && node.init != null) { + bindings.set(node.id.name, node.init); + } + }, + + FunctionDeclaration( + node: Node & { + type: "FunctionDeclaration"; + id: Identifier | null; + }, + ): void { + if (node.id != null) bindings.set(node.id.name, node); + }, CallExpression(node: CallExpression): void { if ( @@ -81,13 +239,19 @@ function createRule( return; } - const listener = node.arguments[1]; - if (!isFunction(listener)) return; + const listener = node.arguments[1] as unknown; + const resolvedListener = + isNode(listener) && isFunction(listener as Expression) + ? listener as FunctionLikeNode + : isNode(listener) + ? resolveListenerReference(listener as Expression, bindings) + : null; + if (resolvedListener == null) return; - if (listenerCallsDeliveryMethod(sourceCode, listener)) return; + if (listenerCallsDeliveryMethod(sourceCode, resolvedListener)) return; (context as { report: (arg: unknown) => void }).report({ - node: listener, + node: resolvedListener, ...buildReport, }); }, diff --git a/packages/lint/src/tests/integration.test.ts b/packages/lint/src/tests/integration.test.ts index 8361790a9..6136eca56 100644 --- a/packages/lint/src/tests/integration.test.ts +++ b/packages/lint/src/tests/integration.test.ts @@ -232,6 +232,45 @@ federation ), ); +test( + "Integration: ✅ outbox-listener-delivery-required - chained authorize/onError", + () => + assertNoErrors(`${COMPLETE_VALID_CODE} + +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .authorize(async (_ctx, _identifier) => true) + .onError(async (_ctx, _error) => {}) + .on(Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); + });`), +); + +test( + "Integration: ❌ outbox-listener-delivery-required - chained authorize/onError missing delivery", + () => + pipe( + `${COMPLETE_VALID_CODE} + +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .authorize(async (_ctx, _identifier) => true) + .onError(async (_ctx, _error) => {}) + .on(Activity, async (ctx, activity) => { + console.log(ctx.identifier, activity.id?.href); + });`, + assertHasError("outbox-listener-delivery-required"), + ), +); + test("Integration: ❌ actor-id-required - missing id property", () => pipe( COMPLETE_VALID_CODE, diff --git a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts index f4057519b..fa5c2fc63 100644 --- a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -47,6 +47,47 @@ federation }), ); +test( + `${ruleName}: ✅ Good - named listener callback`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +const handler = async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); +}; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, handler); +`, + rule, + ruleName, + }), +); + +test( + `${ruleName}: ✅ Good - destructured ctx delivery alias`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx) => { + const { forwardActivity: deliver } = ctx; + await deliver({ identifier: ctx.identifier }, [], { skipIfUnsigned: true }); + }); +`, + rule, + ruleName, + }), +); + test( `${ruleName}: ✅ Good - non-federation object`, lintTest({ @@ -115,6 +156,27 @@ federation }), ); +test( + `${ruleName}: ❌ Bad - named listener without delivery`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +const handler = async (ctx, activity) => { + console.log(ctx.identifier, activity.id?.href); +}; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, handler); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); + test( `${ruleName}: ❌ Bad - comment mentioning delivery methods`, lintTest({ @@ -154,3 +216,24 @@ federation "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); + +test( + `${ruleName}: ❌ Bad - other object sendActivity false positive`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + const other = { sendActivity: async () => {} }; + await other.sendActivity(activity); + console.log(ctx.identifier, activity.id?.href); + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index c704776ca..bb575711e 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,6 +1,13 @@ import type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; import { test } from "@fedify/fixture"; -import { Activity, Create, Note, Person } from "@fedify/vocab"; +import { + Activity, + Arrive, + Create, + IntransitiveActivity, + Note, + Person, +} from "@fedify/vocab"; import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { createFederation, createOutboxContext } from "./mock.ts"; @@ -216,6 +223,56 @@ test("postOutboxActivity prefers the most specific listener", async () => { assertEquals(calls, ["Create"]); }); +test( + "postOutboxActivity matches listeners through the prototype chain", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + const calls: string[] = []; + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(IntransitiveActivity, () => { + calls.push("IntransitiveActivity"); + }); + + const activity = new Arrive({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(calls, ["IntransitiveActivity"]); + }, +); + +test("postOutboxActivity rejects actor mismatch before dispatch", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let called = false; + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, () => { + called = true; + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/bob"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "The activity actor does not match the outbox owner.", + ); + assertEquals(called, false); +}); + test("setOutboxListeners rejects duplicate listeners for the same type", () => { const mockFederation = createFederation(); const listeners = mockFederation.setOutboxListeners( diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index e5ca184be..acda9b359 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -10,9 +10,8 @@ import type { RequestContext, RouteActivityOptions, } from "@fedify/fedify/federation"; -import { CryptographicKey, Multikey } from "@fedify/vocab"; +import { Activity, CryptographicKey, Multikey } from "@fedify/vocab"; import type { - Activity, Collection, LookupObjectOptions, Object, @@ -142,6 +141,8 @@ interface TestFederation ): TestContext; } +type ActivityConstructor = new (...args: any[]) => Activity; + /** * A mock implementation of the {@link Federation} interface for unit testing. * This class provides a way to test Fedify applications without needing @@ -208,7 +209,7 @@ class MockFederation implements Federation { private featuredDispatcher?: any; private featuredTagsDispatcher?: any; private inboxListeners: Map = new Map(); - private outboxListeners: Map = new Map(); + private outboxListeners: Map = new Map(); private contextData?: TContextData; private receivedActivities: Activity[] = []; @@ -369,11 +370,10 @@ class MockFederation implements Federation { const self = this; return { on(type: any, listener: any): any { - const typeName = type.name; - if (self.outboxListeners.has(typeName)) { + if (self.outboxListeners.has(type)) { throw new TypeError("Listener already set for this type."); } - self.outboxListeners.set(typeName, listener); + self.outboxListeners.set(type, listener); return this; }, onError(): any { @@ -493,9 +493,27 @@ class MockFederation implements Federation { identifier: string, activity: Activity, ): Promise { - const typeName = activity.constructor.name; - const listener = this.outboxListeners.get(typeName) ?? - this.outboxListeners.get("Activity"); + const baseContext = this.createContext( + new URL(this.options.origin ?? "https://example.com"), + this.contextData as TContextData, + ); + + const expectedActorId = baseContext.getActorUri(identifier); + if ( + activity.actorIds.length < 1 || + !activity.actorIds.every((actorId) => + actorId.href === expectedActorId.href + ) + ) { + throw new Error("The activity actor does not match the outbox owner."); + } + + let ctor = activity.constructor as ActivityConstructor; + let listener = this.outboxListeners.get(ctor); + while (listener == null && ctor !== Activity) { + ctor = globalThis.Object.getPrototypeOf(ctor); + listener = this.outboxListeners.get(ctor); + } if (listener != null && this.contextData === undefined) { throw new Error( @@ -504,11 +522,6 @@ class MockFederation implements Federation { ); } - const baseContext = this.createContext( - new URL(this.options.origin ?? "https://example.com"), - this.contextData as TContextData, - ); - if (listener != null) { const context = createOutboxContext({ ...baseContext, From d388244d557255f085fda4d8505ac125a611d897 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 21:58:52 +0900 Subject: [PATCH 12/64] Clarify outbox listener examples in the manual The new outbox listener docs were still a little rough in places: the POST examples used a predictable bearer token placeholder, one lint example delivered to nobody, the collections guide interrupted its own code example, and the testing guide never showed the listener that postOutboxActivity exercises. This tightens those examples and converts outbox guide links to the reference style used elsewhere in the manual. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- docs/manual/access-control.md | 12 ++++++++++-- docs/manual/collections.md | 8 ++++---- docs/manual/lint.md | 5 ++--- docs/manual/outbox.md | 24 +++++++++++++++++++----- docs/manual/test.md | 10 ++++++++++ 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/docs/manual/access-control.md b/docs/manual/access-control.md index dea5100f3..3fcb06407 100644 --- a/docs/manual/access-control.md +++ b/docs/manual/access-control.md @@ -108,12 +108,20 @@ requests: ~~~~ typescript twoslash import { type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; +async function verifyAccessToken( + authorization: string | null, +): Promise<{ identifier: string } | null> { + authorization; + return null; +} // ---cut-before--- federation .setOutboxListeners("/users/{identifier}/outbox") .authorize(async (ctx, identifier) => { - const token = ctx.request.headers.get("authorization"); - return token === `Bearer ${identifier}`; + const session = await verifyAccessToken( + ctx.request.headers.get("authorization"), + ); + return session?.identifier === identifier; }); ~~~~ diff --git a/docs/manual/collections.md b/docs/manual/collections.md index 364a86d7e..abad8d0e1 100644 --- a/docs/manual/collections.md +++ b/docs/manual/collections.md @@ -31,10 +31,6 @@ own URI, the outbox collection has its own URI, too. The URI of the outbox collection is determined by the first parameter of the `~Federatable.setOutboxDispatcher()` method: -> [!TIP] -> Use `~Federatable.setOutboxListeners()` to handle `POST` requests to the same -> outbox path. See the [*Outbox listeners*](./outbox.md) guide. - ~~~~ typescript twoslash // @noErrors: 2345 import type { Federation } from "@fedify/fedify"; @@ -47,6 +43,10 @@ federation }); ~~~~ +> [!TIP] +> Use `~Federatable.setOutboxListeners()` to handle `POST` requests to the same +> outbox path. See the [*Outbox listeners*](./outbox.md) guide. + Each actor has its own outbox collection, so the URI pattern of the outbox dispatcher should include the actor's `{identifier}`. The URI pattern syntax follows the [URI Template] specification. diff --git a/docs/manual/lint.md b/docs/manual/lint.md index 9227a9295..2772ebb98 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -630,7 +630,7 @@ federation .on(Activity, async (ctx, activity) => { await ctx.sendActivity( { identifier: ctx.identifier }, - [], + "followers", activity, ); }); @@ -641,8 +641,7 @@ federation .on(Activity, async (ctx) => { await ctx.forwardActivity( { identifier: ctx.identifier }, - [], - { skipIfUnsigned: true }, + "followers", ); }); ~~~~ diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md index f190a3b3d..5413e71c3 100644 --- a/docs/manual/outbox.md +++ b/docs/manual/outbox.md @@ -13,7 +13,9 @@ This is useful when you want to accept ActivityPub client-to-server activities from your own clients without exposing a separate non-standard API. This guide covers `POST /outbox`. To serve `GET /outbox`, use the -[*Collections*](./collections.md#outbox) guide. +[*Collections*][collections-outbox] guide. + +[collections-outbox]: ./collections.md#outbox Registering an outbox listener @@ -28,6 +30,12 @@ import { type Federation } from "@fedify/fedify"; import { Activity, Create, Person } from "@fedify/vocab"; const federation = null as unknown as Federation; const myKnownRecipients: Person[] = []; +async function verifyAccessToken( + authorization: string | null, +): Promise<{ identifier: string } | null> { + authorization; + return null; +} async function savePostedActivity( identifier: string, activity: Activity, @@ -47,8 +55,10 @@ federation ); }) .authorize(async (ctx, identifier) => { - const token = ctx.request.headers.get("authorization"); - return token === `Bearer ${identifier}`; + const session = await verifyAccessToken( + ctx.request.headers.get("authorization"), + ); + return session?.identifier === identifier; }); ~~~~ @@ -71,7 +81,9 @@ actor who owns the addressed outbox. > `{identifier}` (simple expansion) and `{+identifier}` (reserved expansion). > If your identifiers contain URIs or special characters, you may need to use > `{+identifier}` to avoid double-encoding issues. See the -> [*URI Template* guide](./uri-template.md) for details. +> [*URI Template* guide][uri-template-guide] for details. + +[uri-template-guide]: ./uri-template.md Looking at `OutboxContext.identifier` @@ -140,13 +152,15 @@ federation If a listener returns without calling one of these delivery methods, Fedify logs a runtime warning. The `@fedify/lint` package also provides a lint rule -for the same mistake; see [*Linting*](./lint.md) for details. +for the same mistake; see [*Linting*][linting-guide] for details. > [!TIP] > Explicit delivery keeps outbox listeners symmetric with inbox listeners: > Fedify never guesses the recipient list for you, so applications can reuse > their own caches and delivery policies. +[linting-guide]: ./lint.md + Handling errors --------------- diff --git a/docs/manual/test.md b/docs/manual/test.md index 42d6713b2..5a6fbb32f 100644 --- a/docs/manual/test.md +++ b/docs/manual/test.md @@ -308,12 +308,22 @@ const federation = createFederation<{ userId: string }>({ contextData: { userId: "test-user" }, }); +let receivedActivityId = ""; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, (_ctx, activity) => { + receivedActivityId = activity.id?.href ?? ""; + }); + const activity = new Create({ id: new URL("https://example.com/activities/1"), actor: new URL("https://example.com/users/alice"), }); await federation.postOutboxActivity("alice", activity); + +console.log(receivedActivityId); // https://example.com/activities/1 ~~~~ ### Testing URI generation From 7c0f54126a62b75302864102f47451e902008324 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 22:46:36 +0900 Subject: [PATCH 13/64] Tighten outbox forwarding runtime behavior Reviewing the forwardActivity follow-up exposed a few runtime gaps in the new outbox path. This starts the outbox worker automatically before queued forwarding, accepts reserved-expansion outbox listener paths in the builder like the public API does, and avoids logging the full posted activity payload when handler errors or unsupported activity types are reported. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/builder.ts | 2 +- packages/fedify/src/federation/handler.ts | 28 ++++++-- .../fedify/src/federation/middleware.test.ts | 69 +++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 3 + 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 017b533ec..f68d5e33f 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -789,7 +789,7 @@ export class FederationBuilderImpl } setOutboxListeners( - outboxPath: `${string}{identifier}${string}`, + outboxPath: `${string}${Rfc6570Expression<"identifier">}${string}`, ): OutboxListenerSetters { if (this.outboxListeners != null) { throw new RouterError("Outbox listeners already set."); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 27f4195db..a463f0203 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -490,6 +490,18 @@ export interface OutboxHandlerParameters { onNotFound(request: Request): Response | Promise; } +function summarizeJsonActivity(json: unknown): { + activityId?: string; + activityType?: string; +} { + if (json == null || typeof json !== "object") return {}; + const id = "id" in json && typeof json.id === "string" ? json.id : undefined; + const type = "type" in json && typeof json.type === "string" + ? json.type + : undefined; + return { activityId: id, activityType: type }; +} + /** * Handles an outbox POST request. * @template TContextData The context data to pass to the context. @@ -563,9 +575,10 @@ export async function handleOutbox( try { activity = await Activity.fromJsonLd(json, ctx); } catch (error) { + const summary = summarizeJsonActivity(json); logger.error("Failed to parse activity:\n{error}", { identifier, - activity: json, + ...summary, error, }); const outboxContext = outboxContextFactory(identifier, json, undefined, ""); @@ -574,7 +587,7 @@ export async function handleOutbox( } catch (error) { logger.error( "An unexpected error occurred in outbox error handler:\n{error}", - { error, activity: json, identifier }, + { error, identifier, ...summary }, ); } return new Response("Invalid activity.", { @@ -625,9 +638,10 @@ export async function handleOutbox( } const dispatched = outboxListeners?.dispatchWithClass(activity); if (dispatched == null) { - logger.debug("Unsupported activity type:\n{activity}", { + logger.debug("Unsupported activity type {activityType}.", { identifier, - activity: json, + activityId: activity.id?.href, + activityType: getTypeId(activity).href, }); return new Response(null, { status: 202 }); } @@ -642,7 +656,7 @@ export async function handleOutbox( { error, activityId: activity.id?.href, - activity: json, + activityType: getTypeId(activity).href, identifier, }, ); @@ -652,7 +666,7 @@ export async function handleOutbox( { error, activityId: activity.id?.href, - activity: json, + activityType: getTypeId(activity).href, identifier, }, ); @@ -679,7 +693,7 @@ export async function handleOutbox( "Activity {activityId} has been processed in outbox listener.", { activityId: activity.id?.href, - activity: json, + activityType: getTypeId(activity).href, identifier, }, ); diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 605b47373..c5a37e862 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2504,6 +2504,75 @@ test("Federation.setOutboxListeners()", async (t) => { } }, ); + + await t.step( + "forwardActivity starts the outbox queue automatically", + async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; + let listenCalled = false; + const enqueued: Message[] = []; + const queue: MessageQueue = { + enqueue(message: Message): Promise { + enqueued.push(message); + return Promise.resolve(); + }, + async listen(): Promise { + listenCalled = true; + }, + }; + const federation = new FederationImpl({ + kv, + contextLoaderFactory: () => mockDocumentLoader, + queue, + }); + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + { + id: new URL("https://remote.example/users/alice"), + inboxId: new URL("https://remote.example/inbox"), + }, + ); + }) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals(listenCalled, true); + assertEquals(enqueued.length, 1); + assertEquals(enqueued[0].type, "outbox"); + }, + ); }); test("Federation.setInboxDispatcher()", async (t) => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 2f9ee867e..a5fbfa5c7 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3036,6 +3036,9 @@ async function forwardActivityInternal( "Enqueuing activity {activityId} to forward later.", { activityId: ctx.activityId, activity: ctx.activity }, ); + if (!ctx.federation.manuallyStartQueue) { + ctx.federation._startQueueInternal(ctx.data); + } const keyJwkPairs: SenderKeyJwkPair[] = []; for (const { keyId, privateKey } of keys) { const privateKeyJwk = await exportJwk(privateKey); From f70f73e6e786cfbf14d4821296659e2472bd6091 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 22:49:35 +0900 Subject: [PATCH 14/64] Match outbox lint and mock edge cases to runtime The review thread turned up a few smaller cases where the new helper surfaces still drifted from runtime behavior. This teaches the delivery lint rule about defaulted context parameters, optional chaining, type-asserted context access, and template interpolation, and it brings @fedify/testing's outbox mock closer to runtime semantics for signed forwarding and actor ownership checks. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.ts | 64 +++++++++----- .../outbox-listener-delivery-required.test.ts | 64 ++++++++++++++ packages/testing/src/mock.test.ts | 85 ++++++++++++++++++- packages/testing/src/mock.ts | 37 ++++++-- 4 files changed, 221 insertions(+), 29 deletions(-) diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts index c686a6d2a..c73b9ebb7 100644 --- a/packages/lint/src/rules/outbox-listener-delivery-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -8,6 +8,7 @@ import { } from "../lib/pred.ts"; import { trackFederationVariables } from "../lib/tracker.ts"; import type { + AssignmentPattern, CallExpression, Expression, FunctionNode, @@ -61,11 +62,36 @@ const getMemberPropertyName = (expr: Expression): string | null => { return null; }; +function unwrapContextParam(node: Node | undefined): Node | null { + let current: Node | null = node ?? null; + while (current?.type === "AssignmentPattern") { + current = (current as AssignmentPattern).left as Node; + } + return current; +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + const stripCommentsAndStrings = (code: string): string => code .replaceAll(/\/\*[\s\S]*?\*\//g, "") .replaceAll(/\/\/.*$/gm, "") - .replaceAll(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, '""'); + .replaceAll(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, '""'); + +function getDeliveryAliasName(node: Node): string | null { + if (node.type === "Identifier") return node.name; + if (node.type === "AssignmentPattern" && node.left.type === "Identifier") { + return node.left.name; + } + return null; +} + +function buildContextExpressionPattern(contextName: string): string { + const name = escapeRegExp(contextName); + return String.raw`(?:${name}|\(\s*${name}(?:\s+as\s+[^)]+)?\s*\))`; +} const resolveListenerReference = ( expr: Expression, @@ -127,40 +153,37 @@ const listenerCallsDeliveryMethod = ( ): boolean => { const code = stripCommentsAndStrings(sourceCode.getText(listener)); const aliases = new Set(); - const contextParam = listener.params[0] as Node | undefined; + const contextParam = unwrapContextParam( + listener.params[0] as Node | undefined, + ); const contextName = contextParam?.type === "Identifier" - ? (contextParam as Identifier).name + ? contextParam.name : null; if (contextParam?.type === "ObjectPattern") { for (const prop of contextParam.properties) { - if ((prop as Node).type !== "Property") continue; - const property = prop as { - key: Node; - value: Node; - }; - const keyName = property.key.type === "Identifier" - ? property.key.name - : property.key.type === "Literal" && - typeof property.key.value === "string" - ? property.key.value + if (!isNode(prop) || prop.type !== "Property") continue; + const keyName = prop.key.type === "Identifier" + ? prop.key.name + : prop.key.type === "Literal" && typeof prop.key.value === "string" + ? prop.key.value : null; if (keyName == null || !DELIVERY_METHOD_NAMES.has(keyName)) continue; - if (property.value.type === "Identifier") { - aliases.add(property.value.name); - } + const alias = getDeliveryAliasName(prop.value as Node); + if (alias != null) aliases.add(alias); } } if (contextName != null) { + const contextExpr = buildContextExpressionPattern(contextName); const memberPattern = new RegExp( String - .raw`\b${contextName}\s*(?:\.\s*(?:sendActivity|forwardActivity)|\[\s*["'](?:sendActivity|forwardActivity)["']\s*\])\s*\(`, + .raw`${contextExpr}\s*(?:\?\s*\.\s*(?:sendActivity|forwardActivity)|\.\s*(?:sendActivity|forwardActivity)|\?\s*\.\s*\[\s*["'](?:sendActivity|forwardActivity)["']\s*\]|\[\s*["'](?:sendActivity|forwardActivity)["']\s*\])\s*\(`, ); if (memberPattern.test(code)) return true; const destructuringPattern = new RegExp( - String.raw`(?:const|let|var)\s*{([^}]*)}\s*=\s*${contextName}\b`, + String.raw`(?:const|let|var)\s*{([^}]*)}\s*=\s*${contextExpr}`, "g", ); for (const match of code.matchAll(destructuringPattern)) { @@ -178,7 +201,7 @@ const listenerCallsDeliveryMethod = ( const aliasPattern = new RegExp( String - .raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${contextName}\s*(?:\.\s*(sendActivity|forwardActivity)|\[\s*["'](sendActivity|forwardActivity)["']\s*\])`, + .raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${contextExpr}\s*(?:\?\s*\.\s*(sendActivity|forwardActivity)|\.\s*(sendActivity|forwardActivity)|\?\s*\.\s*\[\s*["'](sendActivity|forwardActivity)["']\s*\]|\[\s*["'](sendActivity|forwardActivity)["']\s*\])`, "g", ); for (const match of code.matchAll(aliasPattern)) { @@ -187,7 +210,7 @@ const listenerCallsDeliveryMethod = ( } return globalThis.Array.from(aliases).some((alias) => - new RegExp(String.raw`\b${alias}\s*\(`).test(code) + new RegExp(String.raw`\b${escapeRegExp(alias)}\s*\(`).test(code) ); }; @@ -206,7 +229,6 @@ function createRule( const sourceCode = (context as { sourceCode: { getText(node: unknown): string } }) .sourceCode; - return { VariableDeclarator(node: VariableDeclarator): void { federationTracker.VariableDeclarator(node); diff --git a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts index fa5c2fc63..18c5a37ff 100644 --- a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -88,6 +88,70 @@ federation }), ); +test( + `${ruleName}: ✅ Good - assignment pattern context parameter`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx = globalThis.ctx) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + new Activity({}), + ); + }); +`, + rule, + ruleName, + }), +); + +test( + `${ruleName}: ✅ Good - optional chaining and type assertion`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + await (ctx as typeof ctx)?.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); + }); +`, + rule, + ruleName, + }), +); + +test( + `${ruleName}: ✅ Good - template literal delivery expression`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + const rendered = \`\${await ctx.sendActivity( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + )}\`; + console.log(rendered); + }); +`, + rule, + ruleName, + }), +); + test( `${ruleName}: ✅ Good - non-federation object`, lintTest({ diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index bb575711e..8978e346a 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,5 +1,6 @@ import type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; -import { test } from "@fedify/fixture"; +import { signJsonLd } from "@fedify/fedify/sig"; +import { mockDocumentLoader, test } from "@fedify/fixture"; import { Activity, Arrive, @@ -9,6 +10,10 @@ import { Person, } from "@fedify/vocab"; import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { + rsaPrivateKey3, + rsaPublicKey3, +} from "../../fedify/src/testing/keys.ts"; import { createFederation, createOutboxContext } from "./mock.ts"; test("getSentActivities returns sent activities", async () => { @@ -198,6 +203,49 @@ test("postOutboxActivity forwardActivity respects skipIfUnsigned", async () => { assertEquals(mockFederation.sentActivities.length, 0); }); +test( + "postOutboxActivity forwardActivity treats linked data signatures as signed", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + { skipIfUnsigned: true }, + ); + }, + ); + + const signedJson = await signJsonLd( + { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/activities/1", + type: "Create", + actor: "https://example.com/users/alice", + }, + rsaPrivateKey3, + rsaPublicKey3.id!, + { contextLoader: mockDocumentLoader }, + ); + const activity = await Activity.fromJsonLd(signedJson, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 1); + assertEquals(mockFederation.sentActivities[0].activity, activity); + }, +); + test("postOutboxActivity prefers the most specific listener", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -273,6 +321,41 @@ test("postOutboxActivity rejects actor mismatch before dispatch", async () => { assertEquals(called, false); }); +test( + "postOutboxActivity accepts the dispatched actor id as the owner", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let called = false; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + if (identifier !== "alice") return null; + return new Person({ + id: new URL("https://example.com/actors/alice"), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, () => { + called = true; + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/actors/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(called, true); + }, +); + test("setOutboxListeners rejects duplicate listeners for the same type", () => { const mockFederation = createFederation(); const listeners = mockFederation.setOutboxListeners( diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index acda9b359..31477d307 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -71,6 +71,12 @@ function expandUriTemplate( }); } +function hasLinkedDataSignature(jsonLd: unknown): boolean { + return jsonLd != null && typeof jsonLd === "object" && + "signature" in jsonLd && jsonLd.signature != null && + typeof jsonLd.signature === "object"; +} + /** * Represents a sent activity with metadata about how it was sent. * @since 1.8.0 @@ -424,12 +430,14 @@ class MockFederation implements Federation { // deno-lint-ignore no-this-alias const mockFederation = this; - const url = baseUrlOrRequest instanceof Request - ? new URL(baseUrlOrRequest.url) - : baseUrlOrRequest; + const request = baseUrlOrRequest instanceof Request + ? baseUrlOrRequest + : null; + const url = request == null ? baseUrlOrRequest : new URL(request.url); return new MockContext({ url, + request, data: contextData, federation: mockFederation as any, }); @@ -493,12 +501,21 @@ class MockFederation implements Federation { identifier: string, activity: Activity, ): Promise { + const origin = new URL(this.options.origin ?? "https://example.com"); + const routingContext = this.createContext( + origin, + this.contextData as TContextData, + ); + const request = new Request(routingContext.getOutboxUri(identifier), { + method: "POST", + }); const baseContext = this.createContext( - new URL(this.options.origin ?? "https://example.com"), + request, this.contextData as TContextData, ); - const expectedActorId = baseContext.getActorUri(identifier); + const actor = await baseContext.getActor(identifier); + const expectedActorId = actor?.id ?? baseContext.getActorUri(identifier); if ( activity.actorIds.length < 1 || !activity.actorIds.every((actorId) => @@ -523,6 +540,9 @@ class MockFederation implements Federation { } if (listener != null) { + const rawActivity = await activity.toJsonLd({ + contextLoader: baseContext.contextLoader, + }); const context = createOutboxContext({ ...baseContext, clone: undefined, @@ -534,7 +554,9 @@ class MockFederation implements Federation { recipients: any, options?: any, ) => { - if (options?.skipIfUnsigned && await activity.getProof() == null) { + const hasProof = await activity.getProof() != null; + const hasLds = hasLinkedDataSignature(rawActivity); + if (options?.skipIfUnsigned && !hasProof && !hasLds) { return; } return baseContext.sendActivity( @@ -708,6 +730,7 @@ class MockContext implements Context { constructor( options: { url?: URL; + request?: Request | null; data: TContextData; federation: Federation; documentLoader?: DocumentLoader; @@ -721,7 +744,7 @@ class MockContext implements Context { this.host = url.host; this.hostname = url.hostname; this.url = url; - this.request = new Request(url); + this.request = options.request ?? new Request(url); this.data = options.data; this.federation = options.federation; // deno-lint-ignore require-await From 40240308a24a73dda712b7d5ccbfd2623909b54d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 22:49:51 +0900 Subject: [PATCH 15/64] Use the existing outbox docs link reference One of the outbox guide follow-ups had slipped back to an inline link for the Collections cross-reference. This switches it back to the page's existing reference-style link so the manual stays consistent. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- docs/manual/outbox.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manual/outbox.md b/docs/manual/outbox.md index 5413e71c3..ef93a084b 100644 --- a/docs/manual/outbox.md +++ b/docs/manual/outbox.md @@ -200,4 +200,4 @@ In particular, Fedify does not currently do the following for you: `ctx.forwardActivity()` If you need full `GET /outbox` support as well, combine this guide with the -[*Collections*](./collections.md#outbox) guide. +[*Collections*][collections-outbox] guide. From b722c526f7c5523b58b90513823a339250e455b2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 22:58:02 +0900 Subject: [PATCH 16/64] Fix the outbox queue auto-start test helper The queue auto-start regression test used an async listen() stub with no await, which tripped the repository's require-await lint rule during the final verification pass. Returning a resolved promise keeps the test behavior the same without the lint violation. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/middleware.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index c5a37e862..bbbf4b83a 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2519,8 +2519,9 @@ test("Federation.setOutboxListeners()", async (t) => { enqueued.push(message); return Promise.resolve(); }, - async listen(): Promise { + listen(): Promise { listenCalled = true; + return Promise.resolve(); }, }; const federation = new FederationImpl({ From 7fcd6f999025531929b181459f8d402f3392b998 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 17 Apr 2026 23:01:51 +0900 Subject: [PATCH 17/64] Polish outbox forwarding batch metadata The forwardActivity helper was still a little rough around the edges. This switches it over to the same span-as-return structure used by sendActivity() and generates a single started timestamp for each queued forwarding batch so every message in the batch shares the same enqueue metadata. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/middleware.ts | 61 ++++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index a5fbfa5c7..e2a2b1416 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2868,38 +2868,34 @@ function forwardActivity( metadata.name, metadata.version, ); - return new Promise((resolve, reject) => { - tracer.startActiveSpan( - "activitypub.outbox", - { - kind: ctx.federation.outboxQueue == null || options?.immediate - ? SpanKind.CLIENT - : SpanKind.PRODUCER, - attributes: { "activitypub.activity.type": ctx.activityType }, - }, - async (span) => { - try { - if (ctx.activityId != null) { - span.setAttribute("activitypub.activity.id", ctx.activityId); - } - resolve( - await forwardActivityInternal( - ctx, - loggerCategory, - forwarder, - recipients, - options, - ), - ); - } catch (e) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); - reject(e); - } finally { - span.end(); + return tracer.startActiveSpan( + "activitypub.outbox", + { + kind: ctx.federation.outboxQueue == null || options?.immediate + ? SpanKind.CLIENT + : SpanKind.PRODUCER, + attributes: { "activitypub.activity.type": ctx.activityType }, + }, + async (span) => { + try { + if (ctx.activityId != null) { + span.setAttribute("activitypub.activity.id", ctx.activityId); } - }, - ); - }); + return await forwardActivityInternal( + ctx, + loggerCategory, + forwarder, + recipients, + options, + ); + } catch (e) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); + throw e; + } finally { + span.end(); + } + }, + ); } async function forwardActivityInternal( @@ -3047,6 +3043,7 @@ async function forwardActivityInternal( const carrier: Record = {}; propagation.inject(context.active(), carrier); const orderingKey = options?.orderingKey; + const started = new Date().toISOString(); const messages: { message: OutboxMessage; orderingKey?: string }[] = []; for (const inbox in inboxes) { const inboxUrl = new URL(inbox); @@ -3060,7 +3057,7 @@ async function forwardActivityInternal( activityType: ctx.activityType, inbox, sharedInbox: inboxes[inbox].sharedInbox, - started: new Date().toISOString(), + started, attempt: 0, headers: {}, orderingKey: orderingKey == null From 624da56e3900f33ddc48a80db1250dd65d9bc07c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 00:36:37 +0900 Subject: [PATCH 18/64] Keep forwarded outbox queue state consistent The latest review round found a few remaining mismatches in the runtime outbox forwarding path. This preserves recipient actor IDs on queued forwardActivity() messages so permanent-failure handlers keep their context, stops logging the posted activity payload when the outbox error handler itself fails, and serializes the Logtape-based warning tests so parallel test execution does not race on global logger state. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/handler.ts | 2 +- .../fedify/src/federation/middleware.test.ts | 365 +++++++++--------- packages/fedify/src/federation/middleware.ts | 1 + 3 files changed, 193 insertions(+), 175 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index a463f0203..98cc079a5 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -626,7 +626,7 @@ export async function handleOutbox( { error, activityId: activity.id?.href, - activity: json, + activityType: getTypeId(activity).href, identifier, }, ); diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index bbbf4b83a..3eaf778a8 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -57,6 +57,14 @@ type IsEqual = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; type Assert = T; +let logtapeLock: Promise = Promise.resolve(); + +async function withLogtapeLock(fn: () => Promise): Promise { + const run = logtapeLock.then(fn, fn); + logtapeLock = run.then(() => undefined, () => undefined); + return await run; +} + test("createFederation()", async (t) => { const kv = new MemoryKvStore(); @@ -2258,180 +2266,86 @@ test("Federation.setOutboxListeners()", async (t) => { ); await t.step("warns when listener omits delivery", async () => { - const postedFixture = { - ...createFixture, - actor: "https://example.com/users/john", - }; - const records: LogRecord[] = []; - await reset(); - try { - await configure({ - sinks: { - buffer(record: LogRecord): void { - records.push(record); + await withLogtapeLock(async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; + const records: LogRecord[] = []; + await reset(); + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, }, - }, - filters: {}, - loggers: [{ category: [], sinks: ["buffer"] }], - }); - - const federation = createFederation({ - kv, - documentLoaderFactory: () => mockDocumentLoader, - }); - federation - .setActorDispatcher( - "/users/{identifier}", - (_ctx, identifier) => - identifier === "john" ? new vocab.Person({}) : null, - ) - .setKeyPairsDispatcher(() => [{ - privateKey: rsaPrivateKey2, - publicKey: rsaPublicKey2.publicKey!, - }]); + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); - federation - .setOutboxListeners("/users/{identifier}/outbox") - .on(vocab.Activity, () => {}) - .authorize((ctx, identifier) => { - return identifier === "john" && - ctx.request.headers.get("authorization") === "Bearer token"; + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, }); + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); - const response = await federation.fetch( - new Request("https://example.com/users/john/outbox", { - method: "POST", - body: JSON.stringify(postedFixture), - headers: { - authorization: "Bearer token", - "content-type": "application/activity+json", - }, - }), - { contextData: undefined }, - ); + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, () => {}) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); - assertEquals(response.status, 202); - assertEquals( - records.some((record) => - record.rawMessage === - "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." && - record.properties.identifier === "john" - ), - true, - ); - } finally { - await reset(); - } - }); + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); - await t.step("does not warn when listener calls sendActivity()", async () => { - const postedFixture = { - ...createFixture, - actor: "https://example.com/users/john", - }; - const records: LogRecord[] = []; - await reset(); - fetchMock.spyGlobal(); - fetchMock.post("https://remote.example/inbox", { - status: 202, - body: "Accepted", + assertEquals(response.status, 202); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." && + record.properties.identifier === "john" + ), + true, + ); + } finally { + await reset(); + } }); - - try { - await configure({ - sinks: { - buffer(record: LogRecord): void { - records.push(record); - }, - }, - filters: {}, - loggers: [{ category: [], sinks: ["buffer"] }], - }); - - const federation = createFederation({ - kv, - documentLoaderFactory: () => mockDocumentLoader, - }); - federation - .setActorDispatcher( - "/users/{identifier}", - (_ctx, identifier) => - identifier === "john" ? new vocab.Person({}) : null, - ) - .setKeyPairsDispatcher(() => [{ - privateKey: rsaPrivateKey2, - publicKey: rsaPublicKey2.publicKey!, - }]); - - federation - .setOutboxListeners("/users/{identifier}/outbox") - .on(vocab.Activity, async (ctx, activity) => { - await ctx.sendActivity( - { identifier: ctx.identifier }, - new vocab.Person({ - id: new URL("https://remote.example/users/alice"), - inbox: new URL("https://remote.example/inbox"), - }), - activity, - ); - }) - .authorize((ctx, identifier) => { - return identifier === "john" && - ctx.request.headers.get("authorization") === "Bearer token"; - }); - - const response = await federation.fetch( - new Request("https://example.com/users/john/outbox", { - method: "POST", - body: JSON.stringify(postedFixture), - headers: { - authorization: "Bearer token", - "content-type": "application/activity+json", - }, - }), - { contextData: undefined }, - ); - - assertEquals(response.status, 202); - assertEquals( - records.some((record) => - record.rawMessage === - "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." - ), - false, - ); - } finally { - fetchMock.hardReset(); - await reset(); - } }); - await t.step( - "does not warn when listener calls forwardActivity()", - async () => { - const postedFixture = await signJsonLd( - { - ...createFixture, - actor: "https://example.com/person2", - }, - rsaPrivateKey3, - rsaPublicKey3.id!, - { contextLoader: mockDocumentLoader }, - ); + await t.step("does not warn when listener calls sendActivity()", async () => { + await withLogtapeLock(async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; const records: LogRecord[] = []; - let ldsVerified = false; await reset(); fetchMock.spyGlobal(); - fetchMock.post("https://remote.example/inbox", async (cl) => { - const verifyOptions = { - documentLoader: mockDocumentLoader, - contextLoader: mockDocumentLoader, - }; - ldsVerified = await verifyJsonLd( - await cl.request!.json(), - verifyOptions, - ); - return new Response(null, { status: ldsVerified ? 202 : 401 }); + fetchMock.post("https://remote.example/inbox", { + status: 202, + body: "Accepted", }); try { @@ -2451,9 +2365,9 @@ test("Federation.setOutboxListeners()", async (t) => { }); federation .setActorDispatcher( - "/{identifier}", + "/users/{identifier}", (_ctx, identifier) => - identifier === "person2" ? new vocab.Person({}) : null, + identifier === "john" ? new vocab.Person({}) : null, ) .setKeyPairsDispatcher(() => [{ privateKey: rsaPrivateKey2, @@ -2462,23 +2376,23 @@ test("Federation.setOutboxListeners()", async (t) => { federation .setOutboxListeners("/users/{identifier}/outbox") - .on(vocab.Activity, async (ctx) => { - await ctx.forwardActivity( - [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], - { + .on(vocab.Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + new vocab.Person({ id: new URL("https://remote.example/users/alice"), - inboxId: new URL("https://remote.example/inbox"), - }, - { skipIfUnsigned: true }, + inbox: new URL("https://remote.example/inbox"), + }), + activity, ); }) .authorize((ctx, identifier) => { - return identifier === "person2" && + return identifier === "john" && ctx.request.headers.get("authorization") === "Bearer token"; }); const response = await federation.fetch( - new Request("https://example.com/users/person2/outbox", { + new Request("https://example.com/users/john/outbox", { method: "POST", body: JSON.stringify(postedFixture), headers: { @@ -2490,7 +2404,6 @@ test("Federation.setOutboxListeners()", async (t) => { ); assertEquals(response.status, 202); - assertEquals(ldsVerified, true); assertEquals( records.some((record) => record.rawMessage === @@ -2502,6 +2415,107 @@ test("Federation.setOutboxListeners()", async (t) => { fetchMock.hardReset(); await reset(); } + }); + }); + + await t.step( + "does not warn when listener calls forwardActivity()", + async () => { + await withLogtapeLock(async () => { + const postedFixture = await signJsonLd( + { + ...createFixture, + actor: "https://example.com/person2", + }, + rsaPrivateKey3, + rsaPublicKey3.id!, + { contextLoader: mockDocumentLoader }, + ); + const records: LogRecord[] = []; + let ldsVerified = false; + await reset(); + fetchMock.spyGlobal(); + fetchMock.post("https://remote.example/inbox", async (cl) => { + const verifyOptions = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }; + ldsVerified = await verifyJsonLd( + await cl.request!.json(), + verifyOptions, + ); + return new Response(null, { status: ldsVerified ? 202 : 401 }); + }); + + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); + + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation + .setActorDispatcher( + "/{identifier}", + (_ctx, identifier) => + identifier === "person2" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, async (ctx) => { + await ctx.forwardActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://remote.example/users/alice"), + inboxId: new URL("https://remote.example/inbox"), + }, + { skipIfUnsigned: true }, + ); + }) + .authorize((ctx, identifier) => { + return identifier === "person2" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/person2/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals(ldsVerified, true); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." + ), + false, + ); + } finally { + fetchMock.hardReset(); + await reset(); + } + }); }, ); @@ -2572,6 +2586,9 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals(listenCalled, true); assertEquals(enqueued.length, 1); assertEquals(enqueued[0].type, "outbox"); + assertEquals((enqueued[0] as OutboxMessage).actorIds, [ + "https://remote.example/users/alice", + ]); }, ); }); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index e2a2b1416..15276a702 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3057,6 +3057,7 @@ async function forwardActivityInternal( activityType: ctx.activityType, inbox, sharedInbox: inboxes[inbox].sharedInbox, + actorIds: [...inboxes[inbox].actorIds], started, attempt: 0, headers: {}, From 88d085b9c9eac608ac4eb06320976ed4ffc7a46d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 00:39:31 +0900 Subject: [PATCH 19/64] Cover template literal lint regressions The outbox delivery lint rule now strips template literal text while preserving interpolations, so the rule needs an explicit regression test for template strings that merely mention sendActivity and forwardActivity. This keeps the rule from regressing back to a raw-text false positive. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts index 18c5a37ff..ff4adfa1c 100644 --- a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -301,3 +301,22 @@ federation "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); + +test( + `${ruleName}: ❌ Bad - template literal mentioning delivery methods`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async () => { + return \`.sendActivity(.forwardActivity(\`; + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); From 1e559edf7f92742d4a7efa36e9fc528266441620 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 00:39:47 +0900 Subject: [PATCH 20/64] Bring the outbox testing helpers closer to runtime The mock outbox helper was still more permissive than the real POST /outbox flow. This teaches it to reject unknown owners, respect listener-side authorization and error hooks, and expand reserved URI variables in outbox paths. The testing manual now shows the actor registration and explicit delivery that this stricter mock expects, and it also fixes a small typo in the Fedify testing export comment. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- docs/manual/test.md | 18 ++- packages/fedify/src/testing/mod.ts | 2 +- packages/testing/src/mock.test.ts | 182 +++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 30 ++++- 4 files changed, 224 insertions(+), 8 deletions(-) diff --git a/docs/manual/test.md b/docs/manual/test.md index 5a6fbb32f..663744a9f 100644 --- a/docs/manual/test.md +++ b/docs/manual/test.md @@ -302,7 +302,7 @@ federation, use `postOutboxActivity()`: ~~~~ typescript twoslash import { createFederation } from "@fedify/testing"; -import { Create } from "@fedify/vocab"; +import { Create, Person } from "@fedify/vocab"; const federation = createFederation<{ userId: string }>({ contextData: { userId: "test-user" }, @@ -310,10 +310,24 @@ const federation = createFederation<{ userId: string }>({ let receivedActivityId = ""; +federation.setActorDispatcher("/users/{identifier}", (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); +}); + federation .setOutboxListeners("/users/{identifier}/outbox") - .on(Create, (_ctx, activity) => { + .on(Create, async (ctx, activity) => { receivedActivityId = activity.id?.href ?? ""; + await ctx.sendActivity( + { identifier: ctx.identifier }, + new Person({ + id: new URL("https://example.com/users/bob"), + inbox: new URL("https://example.com/users/bob/inbox"), + }), + activity, + ); }); const activity = new Create({ diff --git a/packages/fedify/src/testing/mod.ts b/packages/fedify/src/testing/mod.ts index 4be4728e2..fe72cbdda 100644 --- a/packages/fedify/src/testing/mod.ts +++ b/packages/fedify/src/testing/mod.ts @@ -3,5 +3,5 @@ export { createOutboxContext, createRequestContext, } from "./context.ts"; -// without bellows, `test:cfworkers` makes error +// Without the export below, `test:cfworkers` makes an error. export { testDefinitions } from "@fedify/fixture"; diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 8978e346a..cb0c994b1 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -117,6 +117,15 @@ test("postOutboxActivity triggers outbox listeners", async () => { }); let receivedIdentifier: string | null = null; + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on( @@ -152,6 +161,15 @@ test("postOutboxActivity supports forwardActivity", async () => { contextData: { test: "data" }, }); + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on( @@ -180,6 +198,15 @@ test("postOutboxActivity forwardActivity respects skipIfUnsigned", async () => { contextData: { test: "data" }, }); + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on( @@ -210,6 +237,15 @@ test( contextData: { test: "data" }, }); + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on( @@ -252,6 +288,15 @@ test("postOutboxActivity prefers the most specific listener", async () => { }); const calls: string[] = []; + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on(Activity, () => { @@ -277,6 +322,13 @@ test( const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, }); + + mockFederation + .setActorDispatcher("/users/{identifier}", (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }); const calls: string[] = []; mockFederation @@ -302,6 +354,15 @@ test("postOutboxActivity rejects actor mismatch before dispatch", async () => { }); let called = false; + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on(Create, () => { @@ -356,6 +417,103 @@ test( }, ); +test("postOutboxActivity rejects missing actors before dispatch", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let called = false; + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, () => { + called = true; + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + 'Actor "alice" not found.', + ); + assertEquals(called, false); +}); + +test("postOutboxActivity enforces authorize predicate", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let called = false; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .authorize(() => false) + .on(Create, () => { + called = true; + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "Unauthorized.", + ); + assertEquals(called, false); +}); + +test("postOutboxActivity invokes outbox error handler", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let handled: string | null = null; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .onError((_ctx, error) => { + handled = error.message; + }) + .on(Create, () => { + throw new Error("Boom"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "Boom", + ); + assertEquals(handled, "Boom"); +}); + test("setOutboxListeners rejects duplicate listeners for the same type", () => { const mockFederation = createFederation(); const listeners = mockFederation.setOutboxListeners( @@ -554,6 +712,21 @@ test("MockContext getOutboxUri respects outbox listener path", () => { ); }); +test("MockContext getOutboxUri supports reserved expansion", () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/actors/{+identifier}/outbox"); + + const context = mockFederation.createContext( + new URL("https://example.com"), + undefined, + ); + + assertEquals( + context.getOutboxUri("alice/profile").href, + "https://example.com/actors/alice/profile/outbox", + ); +}); + test("receiveActivity throws error when contextData not initialized", async () => { const mockFederation = createFederation(); @@ -581,6 +754,15 @@ test("receiveActivity throws error when contextData not initialized", async () = test("postOutboxActivity throws error when contextData not initialized", async () => { const mockFederation = createFederation(); + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation .setOutboxListeners("/users/{identifier}/outbox") .on(Create, (_ctx: OutboxContext, _activity: Create) => { diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 31477d307..47564112b 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -67,7 +67,8 @@ function expandUriTemplate( values: Record, ): string { return template.replace(/{([^}]+)}/g, (match, key) => { - return values[key] || match; + const normalizedKey = key.startsWith("+") ? key.slice(1) : key; + return values[normalizedKey] || match; }); } @@ -209,6 +210,8 @@ class MockFederation implements Federation { public objectDispatchers: Map = new Map(); private inboxDispatcher?: any; private outboxDispatcher?: any; + private outboxAuthorizePredicate?: any; + private outboxListenerErrorHandler?: any; private followingDispatcher?: any; private followersDispatcher?: any; private likedDispatcher?: any; @@ -382,10 +385,12 @@ class MockFederation implements Federation { self.outboxListeners.set(type, listener); return this; }, - onError(): any { + onError(handler: any): any { + self.outboxListenerErrorHandler = handler; return this; }, - authorize(): any { + authorize(predicate: any): any { + self.outboxAuthorizePredicate = predicate; return this; }, }; @@ -515,7 +520,17 @@ class MockFederation implements Federation { ); const actor = await baseContext.getActor(identifier); - const expectedActorId = actor?.id ?? baseContext.getActorUri(identifier); + if (actor == null) { + throw new Error(`Actor ${JSON.stringify(identifier)} not found.`); + } + if ( + this.outboxAuthorizePredicate != null && + !await this.outboxAuthorizePredicate(baseContext, identifier) + ) { + throw new Error("Unauthorized."); + } + + const expectedActorId = actor.id ?? baseContext.getActorUri(identifier); if ( activity.actorIds.length < 1 || !activity.actorIds.every((actorId) => @@ -567,7 +582,12 @@ class MockFederation implements Federation { ); }, }); - await listener(context, activity); + try { + await listener(context, activity); + } catch (error) { + await this.outboxListenerErrorHandler?.(context, error); + throw error; + } } } From efa55e073f7bfcf30dc519bed25fc0634fa609fb Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 01:23:47 +0900 Subject: [PATCH 21/64] Broaden lint coverage for delivery edge cases The outbox delivery lint rule already ignored ordinary string literals, but it still dropped bracket-notation calls like ctx["sendActivity"](). This keeps delivery-method string literals intact while the rest of the string content is stripped, and adds regression coverage for that case alongside the recent template-literal check. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.ts | 11 +++++++--- .../outbox-listener-delivery-required.test.ts | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts index c73b9ebb7..bd1d3095d 100644 --- a/packages/lint/src/rules/outbox-listener-delivery-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -74,11 +74,16 @@ function escapeRegExp(value: string): string { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -const stripCommentsAndStrings = (code: string): string => - code +function stripCommentsAndStrings(code: string): string { + return code .replaceAll(/\/\*[\s\S]*?\*\//g, "") .replaceAll(/\/\/.*$/gm, "") - .replaceAll(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, '""'); + .replaceAll(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, (literal) => { + const quote = literal[0]; + const value = literal.slice(1, -1); + return DELIVERY_METHOD_NAMES.has(value) ? literal : `${quote}${quote}`; + }); +} function getDeliveryAliasName(node: Node): string | null { if (node.type === "Identifier") return node.name; diff --git a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts index ff4adfa1c..5c7e318ed 100644 --- a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -130,6 +130,27 @@ federation }), ); +test( + `${ruleName}: ✅ Good - bracket notation delivery call`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + await ctx["sendActivity"]( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); + }); +`, + rule, + ruleName, + }), +); + test( `${ruleName}: ✅ Good - template literal delivery expression`, lintTest({ From 9742169b75f76000958d14ef59429bee8b009e7b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 01:40:47 +0900 Subject: [PATCH 22/64] Refine outbox validation error reporting The latest review round mostly focused on how outbox validation errors surface to applications. This preserves advertised activity id/type in parse-failure contexts, gives missing-actor posts a distinct 400 message instead of lumping them in with true ownership mismatches, keeps the 405 outbox test aligned with the route configuration it exercises, and cleans up the plural wording in OutboxContext.forwardActivity(). https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/context.ts | 4 +- .../fedify/src/federation/handler.test.ts | 52 ++++++++++++++++++- packages/fedify/src/federation/handler.ts | 33 +++++++++++- .../fedify/src/federation/middleware.test.ts | 1 + 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 19b385f45..a71e366e6 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -705,7 +705,7 @@ export interface OutboxContext extends Context { * modified, i.e., Linked Data Signatures and Object Integrity Proofs will * not be added. Therefore, if the posted activity is not signed (i.e., it * has neither Linked Data Signatures nor Object Integrity Proofs), the - * recipient probably will not trust the activity. + * recipients probably will not trust the activity. * @param forwarder The forwarder's identifier or the forwarder's username * or the forwarder's key pair(s). * @param recipients The recipients of the activity. @@ -729,7 +729,7 @@ export interface OutboxContext extends Context { * modified, i.e., Linked Data Signatures and Object Integrity Proofs will * not be added. Therefore, if the posted activity is not signed (i.e., it * has neither Linked Data Signatures nor Object Integrity Proofs), the - * recipient probably will not trust the activity. + * recipients probably will not trust the activity. * @param forwarder The forwarder's identifier or the forwarder's username. * @param recipients In this case, it must be `"followers"`. * @param options Options for forwarding the activity. diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index f763e63aa..1aa667223 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1495,10 +1495,14 @@ test("handleOutbox()", async () => { return new URL(`https://example.com/users/${identifier}`); }, }); + let invalidActivityId: string | undefined; + let invalidActivityType: string | undefined; response = await handleOutbox(invalidRequest, { identifier: "someone", context: invalidContext, - outboxContextFactory(identifier) { + outboxContextFactory(identifier, _json, activityId, activityType) { + invalidActivityId = activityId; + invalidActivityType = activityType; return createOutboxContext({ ...invalidContext, clone: undefined, @@ -1511,11 +1515,57 @@ test("handleOutbox()", async () => { onUnauthorized, }); assertEquals(response.status, 400); + assertEquals(invalidActivityId, undefined); + assertEquals(invalidActivityType, "Create"); const mismatchedActorJson = (await activity.toJsonLd()) as Record< string, unknown >; + + const missingActorRequest = new Request( + "https://example.com/users/someone/outbox", + { + method: "POST", + body: JSON.stringify({ + ...mismatchedActorJson, + actor: undefined, + }), + }, + ); + const missingActorContext = createRequestContext({ + federation, + request: missingActorRequest, + url: new URL(missingActorRequest.url), + data: undefined, + getActorUri(identifier: string) { + return new URL(`https://example.com/users/${identifier}`); + }, + }); + let missingActorErrorMessage: string | null = null; + response = await handleOutbox(missingActorRequest, { + identifier: "someone", + context: missingActorContext, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...missingActorContext, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + outboxErrorHandler: (_ctx, error) => { + missingActorErrorMessage = error.message; + }, + onNotFound, + onUnauthorized, + }); + assertEquals( + [response.status, await response.text()], + [400, "The posted activity has no actor."], + ); + assertEquals(missingActorErrorMessage, "The posted activity has no actor."); const mismatchedActorRequest = new Request( "https://example.com/users/someone/outbox", { diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 98cc079a5..dd8d092d3 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -581,7 +581,12 @@ export async function handleOutbox( ...summary, error, }); - const outboxContext = outboxContextFactory(identifier, json, undefined, ""); + const outboxContext = outboxContextFactory( + identifier, + json, + summary.activityId, + summary.activityType ?? "", + ); try { await outboxErrorHandler?.(outboxContext, error as Error); } catch (error) { @@ -602,8 +607,32 @@ export async function handleOutbox( getTypeId(activity).href, ); const expectedActorId = actor.id ?? ctx.getActorUri(identifier); + if (activity.actorIds.length < 1) { + const error = new Error("The posted activity has no actor."); + logger.error("The posted activity has no actor for outbox {identifier}.", { + identifier, + activityId: activity.id?.href, + expectedActorId: expectedActorId.href, + }); + try { + await outboxErrorHandler?.(outboxContext, error); + } catch (error) { + logger.error( + "An unexpected error occurred in outbox error handler:\n{error}", + { + error, + activityId: activity.id?.href, + activityType: getTypeId(activity).href, + identifier, + }, + ); + } + return new Response(error.message, { + status: 400, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } if ( - activity.actorIds.length < 1 || !activity.actorIds.every((actorId) => actorId.href === expectedActorId.href) ) { const error = new Error( diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 3eaf778a8..ea8f63c98 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2195,6 +2195,7 @@ test("Federation.setOutboxListeners()", async (t) => { { contextData: undefined }, ); assertEquals(response.status, 405); + assertEquals(response.headers.get("allow"), "GET, HEAD"); }); await t.step( From ca6860650e43e0cea38619ad0e7c577ce55a9c80 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 01:43:36 +0900 Subject: [PATCH 23/64] Match the mock outbox flow to runtime auth checks The mock postOutboxActivity() path was still looser than the real outbox handler in a few ways. This makes it fail fast when no outbox listeners are configured, honors dispatcher-level authorization when listener-specific auth is unset, and routes owner-mismatch failures through the registered outbox error handler so mock-based tests track runtime behavior more closely. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 100 +++++++++++++++++++++++++++++- packages/testing/src/mock.ts | 69 +++++++++++++++------ 2 files changed, 150 insertions(+), 19 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index cb0c994b1..c78739804 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -382,6 +382,43 @@ test("postOutboxActivity rejects actor mismatch before dispatch", async () => { assertEquals(called, false); }); +test("postOutboxActivity routes owner mismatch through onError", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let handled: string | null = null; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .onError((_ctx: OutboxContext<{ test: string }>, error: Error) => { + handled = error.message; + }) + .on(Create, () => { + throw new Error("listener should not run"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/bob"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "The activity actor does not match the outbox owner.", + ); + assertEquals(handled, "The activity actor does not match the outbox owner."); +}); + test( "postOutboxActivity accepts the dispatched actor id as the owner", async () => { @@ -477,6 +514,67 @@ test("postOutboxActivity enforces authorize predicate", async () => { assertEquals(called, false); }); +test("postOutboxActivity falls back to dispatcher authorize predicate", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let called = false; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + mockFederation + .setOutboxDispatcher("/users/{identifier}/outbox", () => ({ items: [] })) + .authorize(() => false); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, () => { + called = true; + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "Unauthorized.", + ); + assertEquals(called, false); +}); + +test("postOutboxActivity fails fast without outbox listeners", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + () => { + throw new Error("actor dispatcher should not run"); + }, + ); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.", + ); +}); + test("postOutboxActivity invokes outbox error handler", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -494,7 +592,7 @@ test("postOutboxActivity invokes outbox error handler", async () => { mockFederation .setOutboxListeners("/users/{identifier}/outbox") - .onError((_ctx, error) => { + .onError((_ctx: OutboxContext<{ test: string }>, error: Error) => { handled = error.message; }) .on(Create, () => { diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 47564112b..4d37658ad 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -211,6 +211,7 @@ class MockFederation implements Federation { private inboxDispatcher?: any; private outboxDispatcher?: any; private outboxAuthorizePredicate?: any; + private outboxDispatcherAuthorizePredicate?: any; private outboxListenerErrorHandler?: any; private followingDispatcher?: any; private followersDispatcher?: any; @@ -285,7 +286,10 @@ class MockFederation implements Federation { setCounter: () => this as any, setFirstCursor: () => this as any, setLastCursor: () => this as any, - authorize: () => this as any, + authorize: (predicate: any) => { + this.outboxDispatcherAuthorizePredicate = predicate; + return this as any; + }, }; } @@ -506,6 +510,25 @@ class MockFederation implements Federation { identifier: string, activity: Activity, ): Promise { + let ctor = activity.constructor as ActivityConstructor; + let listener = this.outboxListeners.get(ctor); + while (listener == null && ctor !== Activity) { + ctor = globalThis.Object.getPrototypeOf(ctor); + listener = this.outboxListeners.get(ctor); + } + + if (this.outboxListeners.size < 1) { + throw new Error( + "MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.", + ); + } + if (listener != null && this.contextData === undefined) { + throw new Error( + "MockFederation.postOutboxActivity(): contextData is not initialized. " + + "Please provide contextData through the constructor or call startQueue() before posting activities.", + ); + } + const origin = new URL(this.options.origin ?? "https://example.com"); const routingContext = this.createContext( origin, @@ -521,11 +544,23 @@ class MockFederation implements Federation { const actor = await baseContext.getActor(identifier); if (actor == null) { - throw new Error(`Actor ${JSON.stringify(identifier)} not found.`); + const error = new Error(`Actor ${JSON.stringify(identifier)} not found.`); + await this.outboxListenerErrorHandler?.( + createOutboxContext({ + ...baseContext, + clone: undefined, + federation: this as any, + identifier, + }), + error, + ); + throw error; } + const authorizePredicate = this.outboxAuthorizePredicate ?? + this.outboxDispatcherAuthorizePredicate; if ( - this.outboxAuthorizePredicate != null && - !await this.outboxAuthorizePredicate(baseContext, identifier) + authorizePredicate != null && + !await authorizePredicate(baseContext, identifier) ) { throw new Error("Unauthorized."); } @@ -537,21 +572,19 @@ class MockFederation implements Federation { actorId.href === expectedActorId.href ) ) { - throw new Error("The activity actor does not match the outbox owner."); - } - - let ctor = activity.constructor as ActivityConstructor; - let listener = this.outboxListeners.get(ctor); - while (listener == null && ctor !== Activity) { - ctor = globalThis.Object.getPrototypeOf(ctor); - listener = this.outboxListeners.get(ctor); - } - - if (listener != null && this.contextData === undefined) { - throw new Error( - "MockFederation.postOutboxActivity(): contextData is not initialized. " + - "Please provide contextData through the constructor or call startQueue() before posting activities.", + const error = new Error( + "The activity actor does not match the outbox owner.", + ); + await this.outboxListenerErrorHandler?.( + createOutboxContext({ + ...baseContext, + clone: undefined, + federation: this as any, + identifier, + }), + error, ); + throw error; } if (listener != null) { From 931589c0e4ae0b60a6ca13bafbb260b71402fdf2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 02:17:13 +0900 Subject: [PATCH 24/64] Handle delivery calls in string-like syntax safely The latest lint review batch found two conflicting edge cases in the regex-based delivery rule: bracket-notation delivery calls need to stay visible, but template-literal text that merely mentions ctx.sendActivity must not count as delivery. This teaches the string stripper to keep only the delivery method names that matter for bracket notation while blanking out unrelated template text, and it adds regression coverage for both bracket and template forms. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.ts | 139 ++++++++++++++++-- .../outbox-listener-delivery-required.test.ts | 40 +++++ 2 files changed, 169 insertions(+), 10 deletions(-) diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts index bd1d3095d..041f18262 100644 --- a/packages/lint/src/rules/outbox-listener-delivery-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -75,14 +75,133 @@ function escapeRegExp(value: string): string { } function stripCommentsAndStrings(code: string): string { - return code - .replaceAll(/\/\*[\s\S]*?\*\//g, "") - .replaceAll(/\/\/.*$/gm, "") - .replaceAll(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, (literal) => { - const quote = literal[0]; - const value = literal.slice(1, -1); - return DELIVERY_METHOD_NAMES.has(value) ? literal : `${quote}${quote}`; - }); + let result = ""; + let index = 0; + + const skipQuotedString = (quote: "'" | '"'): void => { + const start = index; + index += 1; + while (index < code.length) { + const char = code[index]; + if (char === "\\") { + index += 2; + continue; + } + index += 1; + if (char === quote) break; + } + const literal = code.slice(start, index); + const value = literal.slice(1, -1); + result += DELIVERY_METHOD_NAMES.has(value) ? literal : `${quote}${quote}`; + }; + + const stripTemplateLiteral = (): void => { + const start = index; + index += 1; + let raw = ""; + let hasExpression = false; + + while (index < code.length) { + const char = code[index]; + if (char === "\\") { + raw += char; + raw += code[index + 1] ?? ""; + index += 2; + continue; + } + if (char === "`") { + index += 1; + if (!hasExpression && DELIVERY_METHOD_NAMES.has(raw)) { + result += code.slice(start, index); + } else { + result += "``"; + } + return; + } + if (char === "$" && code[index + 1] === "{") { + hasExpression = true; + result += "`${"; + index += 2; + let depth = 1; + while (index < code.length && depth > 0) { + const exprChar = code[index]; + const next = code[index + 1]; + if (exprChar === "'" || exprChar === '"') { + skipQuotedString(exprChar); + continue; + } + if (exprChar === "`") { + stripTemplateLiteral(); + continue; + } + if (exprChar === "/" && next === "*") { + index += 2; + while (index < code.length) { + if (code[index] === "*" && code[index + 1] === "/") { + index += 2; + break; + } + index += 1; + } + continue; + } + if (exprChar === "/" && next === "/") { + index += 2; + while (index < code.length && code[index] !== "\n") { + index += 1; + } + continue; + } + result += exprChar; + index += 1; + if (exprChar === "{") depth += 1; + else if (exprChar === "}") depth -= 1; + } + continue; + } + raw += char; + index += 1; + } + + result += "``"; + }; + + while (index < code.length) { + const char = code[index]; + const next = code[index + 1]; + + if (char === "/" && next === "*") { + index += 2; + while (index < code.length) { + if (code[index] === "*" && code[index + 1] === "/") { + index += 2; + break; + } + index += 1; + } + continue; + } + if (char === "/" && next === "/") { + index += 2; + while (index < code.length && code[index] !== "\n") { + index += 1; + } + continue; + } + if (char === "'" || char === '"') { + skipQuotedString(char); + continue; + } + if (char === "`") { + stripTemplateLiteral(); + continue; + } + + result += char; + index += 1; + } + + return result; } function getDeliveryAliasName(node: Node): string | null { @@ -183,7 +302,7 @@ const listenerCallsDeliveryMethod = ( const contextExpr = buildContextExpressionPattern(contextName); const memberPattern = new RegExp( String - .raw`${contextExpr}\s*(?:\?\s*\.\s*(?:sendActivity|forwardActivity)|\.\s*(?:sendActivity|forwardActivity)|\?\s*\.\s*\[\s*["'](?:sendActivity|forwardActivity)["']\s*\]|\[\s*["'](?:sendActivity|forwardActivity)["']\s*\])\s*\(`, + .raw`${contextExpr}\s*(?:\?\s*\.\s*(?:sendActivity|forwardActivity)|\.\s*(?:sendActivity|forwardActivity)|\?\s*\.\s*\[\s*["'\`](?:sendActivity|forwardActivity)["'\`]\s*\]|\[\s*["'\`](?:sendActivity|forwardActivity)["'\`]\s*\])\s*\(`, ); if (memberPattern.test(code)) return true; @@ -206,7 +325,7 @@ const listenerCallsDeliveryMethod = ( const aliasPattern = new RegExp( String - .raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${contextExpr}\s*(?:\?\s*\.\s*(sendActivity|forwardActivity)|\.\s*(sendActivity|forwardActivity)|\?\s*\.\s*\[\s*["'](sendActivity|forwardActivity)["']\s*\]|\[\s*["'](sendActivity|forwardActivity)["']\s*\])`, + .raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${contextExpr}\s*(?:\?\s*\.\s*(sendActivity|forwardActivity)|\.\s*(sendActivity|forwardActivity)|\?\s*\.\s*\[\s*["'\`](sendActivity|forwardActivity)["'\`]\s*\]|\[\s*["'\`](sendActivity|forwardActivity)["'\`]\s*\])`, "g", ); for (const match of code.matchAll(aliasPattern)) { diff --git a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts index 5c7e318ed..00e38bf28 100644 --- a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -151,6 +151,27 @@ federation }), ); +test( + `${ruleName}: ✅ Good - template literal bracket delivery call`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + await ctx[\`sendActivity\`]( + { identifier: ctx.identifier }, + new URL("https://example.com/inbox"), + activity, + ); + }); +`, + rule, + ruleName, + }), +); + test( `${ruleName}: ✅ Good - template literal delivery expression`, lintTest({ @@ -341,3 +362,22 @@ federation "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", }), ); + +test( + `${ruleName}: ❌ Bad - template literal mentioning ctx.sendActivity`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async () => { + return \`ctx.sendActivity(\`; + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); From ca468c8d1d03c84f399f554987720c574e2ecd3b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 02:17:31 +0900 Subject: [PATCH 25/64] Match the mock outbox flow to runtime edge cases The latest review round found a few remaining gaps in the testing helper's POST /outbox simulation. This stops treating an empty listener map as uninitialized, makes unsupported activity types a no-op, keeps missing-actor failures distinct from true owner mismatches, and tightens the mock's RFC 6570 expansion and linked-data-signature checks so tests stay closer to runtime behavior. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 113 ++++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 46 +++++++++--- 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index c78739804..98d95c979 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -282,6 +282,55 @@ test( }, ); +test( + "postOutboxActivity forwardActivity skips malformed linked data signatures", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + { skipIfUnsigned: true }, + ); + }, + ); + + const activity = await Activity.fromJsonLd( + { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/activities/1", + type: "Create", + actor: "https://example.com/users/alice", + signature: { type: "RsaSignature2017" }, + }, + { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }, + ); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 0); + }, +); + test("postOutboxActivity prefers the most specific listener", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -419,6 +468,42 @@ test("postOutboxActivity routes owner mismatch through onError", async () => { assertEquals(handled, "The activity actor does not match the outbox owner."); }); +test("postOutboxActivity routes missing actor through onError", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let handled: string | null = null; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .onError((_ctx: OutboxContext<{ test: string }>, error: Error) => { + handled = error.message; + }) + .on(Create, () => { + throw new Error("listener should not run"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "The posted activity has no actor.", + ); + assertEquals(handled, "The posted activity has no actor."); +}); + test( "postOutboxActivity accepts the dispatched actor id as the owner", async () => { @@ -575,6 +660,20 @@ test("postOutboxActivity fails fast without outbox listeners", async () => { ); }); +test("postOutboxActivity without matching listener is a no-op", async () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/users/{identifier}/outbox"); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 0); +}); + test("postOutboxActivity invokes outbox error handler", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -879,6 +978,20 @@ test("postOutboxActivity throws error when contextData not initialized", async ( ); }); +test("postOutboxActivity without matching listener is a no-op", async () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/users/{identifier}/outbox"); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 0); +}); + test("MockFederation distinguishes between immediate and queued activities", async () => { const mockFederation = createFederation(); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 4d37658ad..305c0da5f 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -67,15 +67,26 @@ function expandUriTemplate( values: Record, ): string { return template.replace(/{([^}]+)}/g, (match, key) => { - const normalizedKey = key.startsWith("+") ? key.slice(1) : key; - return values[normalizedKey] || match; + const reserved = key.startsWith("+"); + const normalizedKey = reserved ? key.slice(1) : key; + const value = values[normalizedKey]; + if (value == null) return match; + return reserved ? value : encodeURIComponent(value); }); } function hasLinkedDataSignature(jsonLd: unknown): boolean { return jsonLd != null && typeof jsonLd === "object" && "signature" in jsonLd && jsonLd.signature != null && - typeof jsonLd.signature === "object"; + typeof jsonLd.signature === "object" && + "type" in jsonLd.signature && + jsonLd.signature.type === "RsaSignature2017" && + "creator" in jsonLd.signature && + typeof jsonLd.signature.creator === "string" && + "created" in jsonLd.signature && + typeof jsonLd.signature.created === "string" && + "signatureValue" in jsonLd.signature && + typeof jsonLd.signature.signatureValue === "string"; } /** @@ -510,6 +521,12 @@ class MockFederation implements Federation { identifier: string, activity: Activity, ): Promise { + if (this.outboxPath == null) { + throw new Error( + "MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.", + ); + } + let ctor = activity.constructor as ActivityConstructor; let listener = this.outboxListeners.get(ctor); while (listener == null && ctor !== Activity) { @@ -517,12 +534,9 @@ class MockFederation implements Federation { listener = this.outboxListeners.get(ctor); } - if (this.outboxListeners.size < 1) { - throw new Error( - "MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.", - ); - } - if (listener != null && this.contextData === undefined) { + if (listener == null) return; + + if (this.contextData === undefined) { throw new Error( "MockFederation.postOutboxActivity(): contextData is not initialized. " + "Please provide contextData through the constructor or call startQueue() before posting activities.", @@ -566,8 +580,20 @@ class MockFederation implements Federation { } const expectedActorId = actor.id ?? baseContext.getActorUri(identifier); + if (activity.actorIds.length < 1) { + const error = new Error("The posted activity has no actor."); + await this.outboxListenerErrorHandler?.( + createOutboxContext({ + ...baseContext, + clone: undefined, + federation: this as any, + identifier, + }), + error, + ); + throw error; + } if ( - activity.actorIds.length < 1 || !activity.actorIds.every((actorId) => actorId.href === expectedActorId.href ) From 7e76af1708923f30a43d382ab5eab14db10e1d23 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 02:51:15 +0900 Subject: [PATCH 26/64] Use ActivityListenerSet directly InboxListenerSet and OutboxListenerSet had become empty wrappers once the shared listener dispatch logic moved into ActivityListenerSet. This switches the inbox and outbox paths over to the shared type directly, removes the now-redundant outbox module, and keeps the listener set behavior in one place. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../src/federation/activity-listener.ts | 57 +++++++++++++++ packages/fedify/src/federation/builder.ts | 22 ++++-- .../fedify/src/federation/handler.test.ts | 15 ++-- packages/fedify/src/federation/handler.ts | 8 +-- packages/fedify/src/federation/inbox.test.ts | 7 +- packages/fedify/src/federation/inbox.ts | 69 ++----------------- packages/fedify/src/federation/outbox.ts | 59 ---------------- 7 files changed, 92 insertions(+), 145 deletions(-) create mode 100644 packages/fedify/src/federation/activity-listener.ts delete mode 100644 packages/fedify/src/federation/outbox.ts diff --git a/packages/fedify/src/federation/activity-listener.ts b/packages/fedify/src/federation/activity-listener.ts new file mode 100644 index 000000000..215216e1c --- /dev/null +++ b/packages/fedify/src/federation/activity-listener.ts @@ -0,0 +1,57 @@ +import { Activity } from "@fedify/vocab"; + +type ActivityConstructor = + // deno-lint-ignore no-explicit-any + new (...args: any[]) => TActivity; + +type ActivityListener = ( + context: TContext, + activity: TActivity, +) => void | Promise; + +export class ActivityListenerSet { + #listeners: Map>; + + constructor() { + this.#listeners = new Map(); + } + + clone(): this { + const Clone = this.constructor as new () => this; + const clone = new Clone(); + clone.#listeners = new Map(this.#listeners); + return clone; + } + + add( + type: ActivityConstructor, + listener: ActivityListener, + ): void { + if (this.#listeners.has(type)) { + throw new TypeError("Listener already set for this type."); + } + this.#listeners.set(type, listener as ActivityListener); + } + + dispatchWithClass( + activity: TActivity, + ): { + class: ActivityConstructor; + listener: ActivityListener; + } | null { + let cls: ActivityConstructor = activity.constructor as ActivityConstructor; + while (true) { + if (this.#listeners.has(cls)) break; + if (cls === Activity) return null; + cls = globalThis.Object.getPrototypeOf(cls); + } + const listener = this.#listeners.get(cls)!; + return { class: cls, listener }; + } + + dispatch( + activity: TActivity, + ): ActivityListener | null { + return this.dispatchWithClass(activity)?.listener ?? null; + } +} diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index f68d5e33f..98d4e5a36 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -35,7 +35,13 @@ import type { UnverifiedActivityHandler, WebFingerLinksDispatcher, } from "./callback.ts"; -import type { Context, RequestContext } from "./context.ts"; +import type { + Context, + InboxContext, + OutboxContext, + RequestContext, +} from "./context.ts"; +import { ActivityListenerSet } from "./activity-listener.ts"; import type { ActorCallbackSetters, CollectionCallbackSetters, @@ -55,8 +61,6 @@ import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; -import { InboxListenerSet } from "./inbox.ts"; -import { OutboxListenerSet } from "./outbox.ts"; import { Router, RouterError } from "./router.ts"; export class FederationBuilderImpl @@ -111,8 +115,8 @@ export class FederationBuilderImpl TContextData, void >; - inboxListeners?: InboxListenerSet; - outboxListeners?: OutboxListenerSet; + inboxListeners?: ActivityListenerSet>; + outboxListeners?: ActivityListenerSet>; inboxErrorHandler?: InboxErrorHandler; outboxListenerErrorHandler?: OutboxListenerErrorHandler; outboxAuthorizePredicate?: AuthorizePredicate; @@ -812,7 +816,9 @@ export class FederationBuilderImpl } this.outboxPath = outboxPath; } - const listeners = this.outboxListeners = new OutboxListenerSet(); + const listeners = this.outboxListeners = new ActivityListenerSet< + OutboxContext + >(); const setters: OutboxListenerSetters = { on( // deno-lint-ignore no-explicit-any @@ -1209,7 +1215,9 @@ export class FederationBuilderImpl ); } } - const listeners = this.inboxListeners = new InboxListenerSet(); + const listeners = this.inboxListeners = new ActivityListenerSet< + InboxContext + >(); const setters: InboxListenerSetters = { on( // deno-lint-ignore no-explicit-any diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 1aa667223..67598cc05 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -35,7 +35,7 @@ import type { CustomCollectionDispatcher, ObjectDispatcher, } from "./callback.ts"; -import type { RequestContext } from "./context.ts"; +import type { InboxContext, OutboxContext, RequestContext } from "./context.ts"; import type { ConstructorWithTypeId } from "./federation.ts"; import { type CustomCollectionCallbacks, @@ -48,10 +48,9 @@ import { respondWithObject, respondWithObjectIfAcceptable, } from "./handler.ts"; -import { InboxListenerSet } from "./inbox.ts"; +import { ActivityListenerSet } from "./activity-listener.ts"; import { MemoryKvStore } from "./kv.ts"; import { createFederation } from "./middleware.ts"; -import { OutboxListenerSet } from "./outbox.ts"; const QUOTE_CONTEXT_TERMS = { QuoteAuthorization: "https://w3id.org/fep/044f#QuoteAuthorization", @@ -1378,7 +1377,7 @@ test("handleOutbox()", async () => { if (identifier !== "someone") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Someone" }); }; - const listeners = new OutboxListenerSet(); + const listeners = new ActivityListenerSet>(); const seen: string[] = []; listeners.add(Activity, (ctx, activity) => { seen.push(`${ctx.identifier}:${activity.id?.href}`); @@ -1613,7 +1612,7 @@ test("handleOutbox()", async () => { "The activity actor does not match the outbox owner.", ); - const throwingListeners = new OutboxListenerSet(); + const throwingListeners = new ActivityListenerSet>(); let onErrorCalled = false; throwingListeners.add(Create, () => { throw new Error("Boom"); @@ -1689,7 +1688,7 @@ test("handleInbox() - authentication bypass vulnerability", async () => { const federation = createFederation({ kv: new MemoryKvStore() }); let processedActivity: Create | undefined; - const inboxListeners = new InboxListenerSet(); + const inboxListeners = new ActivityListenerSet>(); inboxListeners.add(Create, (_ctx, activity) => { // Track that the malicious activity was processed processedActivity = activity; @@ -2277,7 +2276,7 @@ test("handleInbox() records OpenTelemetry span events", async () => { }); }; - const listeners = new InboxListenerSet(); + const listeners = new ActivityListenerSet>(); let receivedActivity: Activity | null = null; listeners.add(Create, (_ctx, activity) => { receivedActivity = activity; @@ -2411,7 +2410,7 @@ test("handleInbox() records unverified HTTP signature details", async () => { acceptSignatureNonce: ["acceptSignatureNonce"], }, actorDispatcher, - inboxListeners: new InboxListenerSet(), + inboxListeners: new ActivityListenerSet>(), inboxErrorHandler: undefined, unverifiedActivityHandler() { return new Response("", { status: 202 }); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index dd8d092d3..8868b6fd0 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -53,18 +53,18 @@ import type { OutboxContext, RequestContext, } from "./context.ts"; +import type { ActivityListenerSet } from "./activity-listener.ts"; import type { ConstructorWithTypeId, IdempotencyKeyCallback, IdempotencyStrategy, InboxChallengePolicy, } from "./federation.ts"; -import { type InboxListenerSet, routeActivity } from "./inbox.ts"; +import { routeActivity } from "./inbox.ts"; import { KvKeyCache } from "./keycache.ts"; import type { KvKey, KvStore } from "./kv.ts"; import type { MessageQueue } from "./mq.ts"; import { acceptsJsonLd } from "./negotiation.ts"; -import type { OutboxListenerSet } from "./outbox.ts"; /** * Parameters for handling an actor request. @@ -484,7 +484,7 @@ export interface OutboxHandlerParameters { ): OutboxContext; actorDispatcher?: ActorDispatcher; authorizePredicate?: AuthorizePredicate; - outboxListeners?: OutboxListenerSet; + outboxListeners?: ActivityListenerSet>; outboxErrorHandler?: OutboxListenerErrorHandler; onUnauthorized(request: Request): Response | Promise; onNotFound(request: Request): Response | Promise; @@ -750,7 +750,7 @@ export interface InboxHandlerParameters { }; queue?: MessageQueue; actorDispatcher?: ActorDispatcher; - inboxListeners?: InboxListenerSet; + inboxListeners?: ActivityListenerSet>; inboxErrorHandler?: InboxErrorHandler; unverifiedActivityHandler?: UnverifiedActivityHandler; onNotFound(request: Request): Response | Promise; diff --git a/packages/fedify/src/federation/inbox.test.ts b/packages/fedify/src/federation/inbox.test.ts index 09a27ceb9..b7091de51 100644 --- a/packages/fedify/src/federation/inbox.test.ts +++ b/packages/fedify/src/federation/inbox.test.ts @@ -2,10 +2,11 @@ import { test } from "@fedify/fixture"; import { Activity, Create, Invite, Offer, Update } from "@fedify/vocab"; import { assertEquals } from "@std/assert/assert-equals"; import { assertThrows } from "@std/assert/assert-throws"; -import { InboxListenerSet } from "./inbox.ts"; +import type { InboxContext } from "./context.ts"; +import { ActivityListenerSet } from "./activity-listener.ts"; -test("InboxListenerSet", () => { - const listeners = new InboxListenerSet(); +test("ActivityListenerSet", () => { + const listeners = new ActivityListenerSet>(); const activity = new Activity({}); const offer = new Offer({}); const invite = new Invite({}); diff --git a/packages/fedify/src/federation/inbox.ts b/packages/fedify/src/federation/inbox.ts index c0a69b9e7..3b242191c 100644 --- a/packages/fedify/src/federation/inbox.ts +++ b/packages/fedify/src/federation/inbox.ts @@ -1,4 +1,5 @@ -import { Activity, getTypeId } from "@fedify/vocab"; +import { getTypeId } from "@fedify/vocab"; +import type { Activity } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { context, @@ -10,7 +11,7 @@ import { type TracerProvider, } from "@opentelemetry/api"; import metadata from "../../deno.json" with { type: "json" }; -import type { InboxErrorHandler, InboxListener } from "./callback.ts"; +import type { InboxErrorHandler } from "./callback.ts"; import type { Context, InboxContext } from "./context.ts"; import type { IdempotencyKeyCallback, @@ -19,74 +20,14 @@ import type { import type { KvKey, KvStore } from "./kv.ts"; import type { MessageQueue } from "./mq.ts"; import type { InboxMessage } from "./queue.ts"; - -export class InboxListenerSet { - #listeners: Map< - new (...args: unknown[]) => Activity, - InboxListener - >; - - constructor() { - this.#listeners = new Map(); - } - - clone(): InboxListenerSet { - const clone = new InboxListenerSet(); - clone.#listeners = new Map(this.#listeners); - return clone; - } - - add( - // deno-lint-ignore no-explicit-any - type: new (...args: any[]) => TActivity, - listener: InboxListener, - ): void { - if (this.#listeners.has(type)) { - throw new TypeError("Listener already set for this type."); - } - this.#listeners.set( - type, - listener as InboxListener, - ); - } - - dispatchWithClass( - activity: TActivity, - ): { - // deno-lint-ignore no-explicit-any - class: new (...args: any[]) => Activity; - listener: InboxListener; - } | null { - // deno-lint-ignore no-explicit-any - let cls: new (...args: any[]) => Activity = activity - // deno-lint-ignore no-explicit-any - .constructor as unknown as new (...args: any[]) => Activity; - const inboxListeners = this.#listeners; - if (inboxListeners == null) { - return null; - } - while (true) { - if (inboxListeners.has(cls)) break; - if (cls === Activity) return null; - cls = globalThis.Object.getPrototypeOf(cls); - } - const listener = inboxListeners.get(cls)!; - return { class: cls, listener }; - } - - dispatch( - activity: TActivity, - ): InboxListener | null { - return this.dispatchWithClass(activity)?.listener ?? null; - } -} +import type { ActivityListenerSet } from "./activity-listener.ts"; export interface RouteActivityParameters { context: Context; json: unknown; activity: Activity; recipient: string | null; - inboxListeners?: InboxListenerSet; + inboxListeners?: ActivityListenerSet>; inboxContextFactory( recipient: string | null, activity: unknown, diff --git a/packages/fedify/src/federation/outbox.ts b/packages/fedify/src/federation/outbox.ts deleted file mode 100644 index c3d87f7e6..000000000 --- a/packages/fedify/src/federation/outbox.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Activity } from "@fedify/vocab"; -import type { OutboxListener } from "./callback.ts"; - -export class OutboxListenerSet { - #listeners: Map< - new (...args: unknown[]) => Activity, - OutboxListener - >; - - constructor() { - this.#listeners = new Map(); - } - - clone(): OutboxListenerSet { - const clone = new OutboxListenerSet(); - clone.#listeners = new Map(this.#listeners); - return clone; - } - - add( - // deno-lint-ignore no-explicit-any - type: new (...args: any[]) => TActivity, - listener: OutboxListener, - ): void { - if (this.#listeners.has(type)) { - throw new TypeError("Listener already set for this type."); - } - this.#listeners.set( - type, - listener as OutboxListener, - ); - } - - dispatchWithClass( - activity: TActivity, - ): { - // deno-lint-ignore no-explicit-any - class: new (...args: any[]) => Activity; - listener: OutboxListener; - } | null { - // deno-lint-ignore no-explicit-any - let cls: new (...args: any[]) => Activity = activity - // deno-lint-ignore no-explicit-any - .constructor as unknown as new (...args: any[]) => Activity; - while (true) { - if (this.#listeners.has(cls)) break; - if (cls === Activity) return null; - cls = globalThis.Object.getPrototypeOf(cls); - } - const listener = this.#listeners.get(cls)!; - return { class: cls, listener }; - } - - dispatch( - activity: TActivity, - ): OutboxListener | null { - return this.dispatchWithClass(activity)?.listener ?? null; - } -} From d2fe608ac9fedee54fce8141cf8f173d974cf78c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 02:59:21 +0900 Subject: [PATCH 27/64] Validate unsupported mock outbox posts first The mock POST /outbox helper was still returning too early when no listener matched the posted activity type. This now runs actor, authorization, and owner checks before falling back to a no-op for unsupported activities, and the related tests were tightened to cover that validation order without keeping a duplicate no-op case around. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 57 +++++++++++++++++++++++-------- packages/testing/src/mock.ts | 32 ++++++++--------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 98d95c979..27e93cffe 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -662,6 +662,14 @@ test("postOutboxActivity fails fast without outbox listeners", async () => { test("postOutboxActivity without matching listener is a no-op", async () => { const mockFederation = createFederation(); + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); mockFederation.setOutboxListeners("/users/{identifier}/outbox"); const activity = new Create({ @@ -674,6 +682,41 @@ test("postOutboxActivity without matching listener is a no-op", async () => { assertEquals(mockFederation.sentActivities.length, 0); }); +test( + "postOutboxActivity without matching listener still validates ownership", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Arrive, () => { + throw new Error("listener should not run"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/bob"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "The activity actor does not match the outbox owner.", + ); + }, +); + test("postOutboxActivity invokes outbox error handler", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -978,20 +1021,6 @@ test("postOutboxActivity throws error when contextData not initialized", async ( ); }); -test("postOutboxActivity without matching listener is a no-op", async () => { - const mockFederation = createFederation(); - mockFederation.setOutboxListeners("/users/{identifier}/outbox"); - - const activity = new Create({ - id: new URL("https://example.com/activities/1"), - actor: new URL("https://example.com/users/alice"), - }); - - await mockFederation.postOutboxActivity("alice", activity); - - assertEquals(mockFederation.sentActivities.length, 0); -}); - test("MockFederation distinguishes between immediate and queued activities", async () => { const mockFederation = createFederation(); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 305c0da5f..5692a6dbf 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -527,22 +527,6 @@ class MockFederation implements Federation { ); } - let ctor = activity.constructor as ActivityConstructor; - let listener = this.outboxListeners.get(ctor); - while (listener == null && ctor !== Activity) { - ctor = globalThis.Object.getPrototypeOf(ctor); - listener = this.outboxListeners.get(ctor); - } - - if (listener == null) return; - - if (this.contextData === undefined) { - throw new Error( - "MockFederation.postOutboxActivity(): contextData is not initialized. " + - "Please provide contextData through the constructor or call startQueue() before posting activities.", - ); - } - const origin = new URL(this.options.origin ?? "https://example.com"); const routingContext = this.createContext( origin, @@ -613,6 +597,22 @@ class MockFederation implements Federation { throw error; } + let ctor = activity.constructor as ActivityConstructor; + let listener = this.outboxListeners.get(ctor); + while (listener == null && ctor !== Activity) { + ctor = globalThis.Object.getPrototypeOf(ctor); + listener = this.outboxListeners.get(ctor); + } + + if (listener == null) return; + + if (this.contextData === undefined) { + throw new Error( + "MockFederation.postOutboxActivity(): contextData is not initialized. " + + "Please provide contextData through the constructor or call startQueue() before posting activities.", + ); + } + if (listener != null) { const rawActivity = await activity.toJsonLd({ contextLoader: baseContext.contextLoader, From 0366aa3ac378d59046dc2d1359954cd20e1b29c5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 03:21:22 +0900 Subject: [PATCH 28/64] Refine mock outbox signature and URI fidelity The latest review round found a few remaining fidelity gaps in the @fedify/testing outbox mock. This tightens reserved URI-template expansion, broadens linked-data-signature detection beyond a single suite, fails fast when a matching listener would run without initialized context data, and records the raw forwarded payload so tests can tell forwardActivity() apart from sendActivity(). https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 105 ++++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 62 +++++++++++------- 2 files changed, 142 insertions(+), 25 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 27e93cffe..1056dcda7 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -279,6 +279,60 @@ test( assertEquals(mockFederation.sentActivities.length, 1); assertEquals(mockFederation.sentActivities[0].activity, activity); + assertEquals(mockFederation.sentActivities[0].rawActivity, signedJson); + }, +); + +test( + "postOutboxActivity forwardActivity treats alternate linked data signature suites as signed", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + { skipIfUnsigned: true }, + ); + }, + ); + + const signedJson = { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/activities/1", + type: "Create", + actor: "https://example.com/users/alice", + signature: { + type: "Ed25519Signature2020", + verificationMethod: "https://example.com/users/alice#main-key", + jws: "signature", + }, + }; + const activity = await Activity.fromJsonLd(signedJson, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 1); + assertEquals(mockFederation.sentActivities[0].activity, activity); + assertEquals(mockFederation.sentActivities[0].rawActivity, signedJson); }, ); @@ -636,6 +690,42 @@ test("postOutboxActivity falls back to dispatcher authorize predicate", async () assertEquals(called, false); }); +test( + "postOutboxActivity with matching listener fails fast before auth when contextData is missing", + async () => { + const mockFederation = createFederation(); + let authorizeCalled = false; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + () => { + throw new Error("actor dispatcher should not run"); + }, + ); + mockFederation + .setOutboxDispatcher("/users/{identifier}/outbox", () => ({ items: [] })) + .authorize(() => { + authorizeCalled = true; + return true; + }); + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, () => {}); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "MockFederation.postOutboxActivity(): contextData is not initialized. Please provide contextData through the constructor or call startQueue() before posting activities.", + ); + assertEquals(authorizeCalled, false); + }, +); + test("postOutboxActivity fails fast without outbox listeners", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -967,6 +1057,21 @@ test("MockContext getOutboxUri supports reserved expansion", () => { ); }); +test("MockContext reserved expansion encodes non-reserved characters", () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/actors/{+identifier}/outbox"); + + const context = mockFederation.createContext( + new URL("https://example.com"), + undefined, + ); + + assertEquals( + context.getOutboxUri("alice profile/notes").href, + "https://example.com/actors/alice%20profile/notes/outbox", + ); +}); + test("receiveActivity throws error when contextData not initialized", async () => { const mockFederation = createFederation(); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 5692a6dbf..7440c431c 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -71,7 +71,7 @@ function expandUriTemplate( const normalizedKey = reserved ? key.slice(1) : key; const value = values[normalizedKey]; if (value == null) return match; - return reserved ? value : encodeURIComponent(value); + return reserved ? encodeURI(value) : encodeURIComponent(value); }); } @@ -80,13 +80,14 @@ function hasLinkedDataSignature(jsonLd: unknown): boolean { "signature" in jsonLd && jsonLd.signature != null && typeof jsonLd.signature === "object" && "type" in jsonLd.signature && - jsonLd.signature.type === "RsaSignature2017" && - "creator" in jsonLd.signature && - typeof jsonLd.signature.creator === "string" && - "created" in jsonLd.signature && - typeof jsonLd.signature.created === "string" && - "signatureValue" in jsonLd.signature && - typeof jsonLd.signature.signatureValue === "string"; + typeof jsonLd.signature.type === "string" && + (("creator" in jsonLd.signature && + typeof jsonLd.signature.creator === "string") || + ("verificationMethod" in jsonLd.signature && + typeof jsonLd.signature.verificationMethod === "string")) && + (("signatureValue" in jsonLd.signature && + typeof jsonLd.signature.signatureValue === "string") || + ("jws" in jsonLd.signature && typeof jsonLd.signature.jws === "string")); } /** @@ -100,6 +101,8 @@ interface SentActivity { queue?: "inbox" | "outbox" | "fanout"; /** The activity that was sent. */ activity: Activity; + /** The raw forwarded payload, if preserved by the caller. */ + rawActivity?: unknown; /** The order in which the activity was sent (auto-incrementing counter). */ sentOrder: number; } @@ -131,6 +134,7 @@ interface TestContext sender: any; recipients: any; activity: Activity; + rawActivity?: unknown; }>; reset(): void; } @@ -527,6 +531,20 @@ class MockFederation implements Federation { ); } + let ctor = activity.constructor as ActivityConstructor; + let listener = this.outboxListeners.get(ctor); + while (listener == null && ctor !== Activity) { + ctor = globalThis.Object.getPrototypeOf(ctor); + listener = this.outboxListeners.get(ctor); + } + + if (listener != null && this.contextData === undefined) { + throw new Error( + "MockFederation.postOutboxActivity(): contextData is not initialized. " + + "Please provide contextData through the constructor or call startQueue() before posting activities.", + ); + } + const origin = new URL(this.options.origin ?? "https://example.com"); const routingContext = this.createContext( origin, @@ -597,22 +615,8 @@ class MockFederation implements Federation { throw error; } - let ctor = activity.constructor as ActivityConstructor; - let listener = this.outboxListeners.get(ctor); - while (listener == null && ctor !== Activity) { - ctor = globalThis.Object.getPrototypeOf(ctor); - listener = this.outboxListeners.get(ctor); - } - if (listener == null) return; - if (this.contextData === undefined) { - throw new Error( - "MockFederation.postOutboxActivity(): contextData is not initialized. " + - "Please provide contextData through the constructor or call startQueue() before posting activities.", - ); - } - if (listener != null) { const rawActivity = await activity.toJsonLd({ contextLoader: baseContext.contextLoader, @@ -637,7 +641,7 @@ class MockFederation implements Federation { forwarder, recipients, activity, - options, + { ...options, rawActivity }, ); }, }); @@ -804,6 +808,7 @@ class MockContext implements Context { sender: any; recipients: any; activity: Activity; + rawActivity?: unknown; }> = []; constructor( @@ -1114,9 +1119,14 @@ class MockContext implements Context { sender: any, recipients: any, activity: Activity, - _options?: any, + options?: any, ): Promise { - this.sentActivities.push({ sender, recipients, activity }); + this.sentActivities.push({ + sender, + recipients, + activity, + rawActivity: options?.rawActivity, + }); // If this is a MockFederation, also record it there if (this.federation instanceof MockFederation) { @@ -1125,6 +1135,7 @@ class MockContext implements Context { queued, queue: queued ? "outbox" : undefined, activity, + rawActivity: options?.rawActivity, sentOrder: ++this.federation.sentCounter, }); } @@ -1151,6 +1162,7 @@ class MockContext implements Context { sender: any; recipients: any; activity: Activity; + rawActivity?: unknown; }> { return [...this.sentActivities]; } From 948e430462477ddea9be8ea44a6b5f00ddee2f0d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 04:57:11 +0900 Subject: [PATCH 29/64] Clarify mock signature-shape narrowing The mock's linked-data-signature helper was already behaving correctly, but the property access chain was harder to read than it needed to be. This rewrites the helper around explicit Record intermediates so the narrowing is obvious and the intent is easier to follow. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 7440c431c..b3663b8a9 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -76,18 +76,17 @@ function expandUriTemplate( } function hasLinkedDataSignature(jsonLd: unknown): boolean { - return jsonLd != null && typeof jsonLd === "object" && - "signature" in jsonLd && jsonLd.signature != null && - typeof jsonLd.signature === "object" && - "type" in jsonLd.signature && - typeof jsonLd.signature.type === "string" && - (("creator" in jsonLd.signature && - typeof jsonLd.signature.creator === "string") || - ("verificationMethod" in jsonLd.signature && - typeof jsonLd.signature.verificationMethod === "string")) && - (("signatureValue" in jsonLd.signature && - typeof jsonLd.signature.signatureValue === "string") || - ("jws" in jsonLd.signature && typeof jsonLd.signature.jws === "string")); + if (jsonLd == null || typeof jsonLd !== "object") return false; + const record = jsonLd as Record; + const signature = record.signature; + if (signature == null || typeof signature !== "object") return false; + const signatureRecord = signature as Record; + + return typeof signatureRecord.type === "string" && + (typeof signatureRecord.creator === "string" || + typeof signatureRecord.verificationMethod === "string") && + (typeof signatureRecord.signatureValue === "string" || + typeof signatureRecord.jws === "string"); } /** From 85fbefcb9853bb9592e81e9fc181b7c278a0014f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 07:12:37 +0900 Subject: [PATCH 30/64] Reformat example assets after the Deno upgrade The latest Deno formatter release changed how several example SVG and HTML files are wrapped. This keeps the worktree in sync with the new formatter output so repository-wide checks can pass again. https://github.com/fedify-dev/fedify/pull/688 Assisted-by: OpenCode:gpt-5.4 --- examples/astro/public/astro-fedify-logo.svg | 118 +++++++++++------ examples/astro/public/astro-horizonal.svg | 25 ++-- examples/astro/public/astro-square.svg | 6 +- examples/next-integration/public/next.svg | 3 +- examples/rfc-9421-test/index.html | 4 +- .../src/lib/assets/favicon.svg | 119 ++++++++++++------ .../static/fedify-svelte-logo.svg | 119 ++++++++++++------ .../static/svelte-horizontal.svg | 9 +- 8 files changed, 268 insertions(+), 135 deletions(-) diff --git a/examples/astro/public/astro-fedify-logo.svg b/examples/astro/public/astro-fedify-logo.svg index 812ed3b38..ff43b08a7 100644 --- a/examples/astro/public/astro-fedify-logo.svg +++ b/examples/astro/public/astro-fedify-logo.svg @@ -9,52 +9,61 @@ xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" > - + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /> + diff --git a/examples/astro/public/astro-horizonal.svg b/examples/astro/public/astro-horizonal.svg index 0fe8f66fb..356db9d05 100644 --- a/examples/astro/public/astro-horizonal.svg +++ b/examples/astro/public/astro-horizonal.svg @@ -1,31 +1,38 @@ - diff --git a/examples/astro/public/astro-square.svg b/examples/astro/public/astro-square.svg index dec4782f4..eb2ae06b5 100644 --- a/examples/astro/public/astro-square.svg +++ b/examples/astro/public/astro-square.svg @@ -8,16 +8,14 @@ xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" > - + } + diff --git a/examples/rfc-9421-test/index.html b/examples/rfc-9421-test/index.html index 871e5e7a9..5b134637b 100644 --- a/examples/rfc-9421-test/index.html +++ b/examples/rfc-9421-test/index.html @@ -371,9 +371,7 @@

RFC 9421 Field Test

// Extract a readable name from the actor URI try { const u = new URL(info.id); - li.textContent = `${ - u.pathname.split("/").pop() - }@${u.hostname}`; + li.textContent = `${u.pathname.split("/").pop()}@${u.hostname}`; } catch { li.textContent = info.id; } diff --git a/examples/sveltekit-sample/src/lib/assets/favicon.svg b/examples/sveltekit-sample/src/lib/assets/favicon.svg index cd7aebc6d..1e9e6a6f0 100644 --- a/examples/sveltekit-sample/src/lib/assets/favicon.svg +++ b/examples/sveltekit-sample/src/lib/assets/favicon.svg @@ -34,12 +34,15 @@ inkscape:window-y="48" inkscape:window-maximized="0" inkscape:current-layer="svg5" - /> + + + + </clipPath> + </defs> + <title id="title1" - >FedifyFedify + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fedify + } diff --git a/examples/sveltekit-sample/static/fedify-svelte-logo.svg b/examples/sveltekit-sample/static/fedify-svelte-logo.svg index cd7aebc6d..1e9e6a6f0 100644 --- a/examples/sveltekit-sample/static/fedify-svelte-logo.svg +++ b/examples/sveltekit-sample/static/fedify-svelte-logo.svg @@ -34,12 +34,15 @@ inkscape:window-y="48" inkscape:window-maximized="0" inkscape:current-layer="svg5" - /> + + + + </clipPath> + </defs> + <title id="title1" - >FedifyFedify + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fedify + } diff --git a/examples/sveltekit-sample/static/svelte-horizontal.svg b/examples/sveltekit-sample/static/svelte-horizontal.svg index 119e7e6e8..e4d1eba79 100644 --- a/examples/sveltekit-sample/static/svelte-horizontal.svg +++ b/examples/sveltekit-sample/static/svelte-horizontal.svg @@ -4,13 +4,16 @@ height="139" viewBox="0 0 519 139" > - svelte-horizontalsvelte-horizontal + + + From 41385c3020f003306642884d67c957da7398bf2c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 07:12:51 +0900 Subject: [PATCH 31/64] Share signature-shape checks across mocks The latest review round found one remaining mismatch between runtime and @fedify/testing around skipIfUnsigned: runtime forwarding only treated a narrow RsaSignature2017 shape as signed, while the mock had already broadened its check. This shares a broader signature-shape helper across both paths, adds runtime coverage for alternate Linked Data Signature shapes, and drops the mock's missing-owner onError callback so it lines up with the real outbox handler. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/middleware.test.ts | 38 +++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 4 +- packages/fedify/src/sig/ld.test.ts | 25 ++++++++++++ packages/fedify/src/sig/ld.ts | 21 ++++++++++ packages/fedify/src/sig/mod.ts | 1 + packages/testing/src/mock.test.ts | 28 ++++++++++++++ packages/testing/src/mock.ts | 29 ++------------ 7 files changed, 118 insertions(+), 28 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index ea8f63c98..549e5f76b 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -4266,6 +4266,44 @@ test("InboxContextImpl.forwardActivity()", async (t) => { assertEquals(verified, ["ld"]); }); + await t.step("alternate LD signature shapes", async () => { + const activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://example.com/activity", + "actor": "https://example.com/person2", + "signature": { + "type": "Ed25519Signature2020", + "verificationMethod": "https://example.com/person2#main-key", + "jws": "signature", + }, + }; + const ctx = new InboxContextImpl( + null, + activity, + "https://example.com/activity", + "https://www.w3.org/ns/activitystreams#Create", + { + data: undefined, + federation, + url: new URL("https://example.com/"), + documentLoader: documentLoader, + contextLoader: documentLoader, + }, + ); + await assertRejects(() => + ctx.forwardActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + { skipIfUnsigned: true }, + ) + ); + assertEquals(verified, []); + }); + fetchMock.hardReset(); }); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 15276a702..f47d2b297 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -59,7 +59,7 @@ import { verifyRequest, } from "../sig/http.ts"; import { exportJwk, importJwk, validateCryptoKey } from "../sig/key.ts"; -import { hasSignature, signJsonLd } from "../sig/ld.ts"; +import { hasSignatureLike, signJsonLd } from "../sig/ld.ts"; import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts"; import { signObject, verifyObject } from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; @@ -2954,7 +2954,7 @@ async function forwardActivityInternal( } else { keys = [forwarder]; } - if (!hasSignature(ctx.activity)) { + if (!hasSignatureLike(ctx.activity)) { let hasProof: boolean; try { const activity = await Activity.fromJsonLd(ctx.activity, ctx); diff --git a/packages/fedify/src/sig/ld.test.ts b/packages/fedify/src/sig/ld.test.ts index c7f4db9a6..ed9c5d706 100644 --- a/packages/fedify/src/sig/ld.test.ts +++ b/packages/fedify/src/sig/ld.test.ts @@ -19,6 +19,7 @@ import { attachSignature, createSignature, detachSignature, + hasSignatureLike, type Signature, signJsonLd, verifyJsonLd, @@ -93,6 +94,30 @@ test("signJsonLd()", async () => { assert(verified); }); +test("hasSignatureLike()", () => { + assert(hasSignatureLike({ + signature: { + type: "RsaSignature2017", + creator: "https://example.com/users/alice#main-key", + signatureValue: "signature", + }, + })); + assert(hasSignatureLike({ + signature: { + type: "Ed25519Signature2020", + verificationMethod: "https://example.com/users/alice#main-key", + jws: "signature", + }, + })); + assertFalse(hasSignatureLike({ + signature: { + type: "Ed25519Signature2020", + verificationMethod: "https://example.com/users/alice#main-key", + }, + })); + assertFalse(hasSignatureLike(null)); +}); + const document = { "@context": [ "https://www.w3.org/ns/activitystreams", diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index 49a84e4c4..a757d108c 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -176,6 +176,27 @@ interface SignedJsonLd { signature: Signature; } +/** + * Checks if the given JSON-LD document has a Linked Data Signature-like + * object, without restricting it to a single suite-specific shape. + * @param jsonLd The JSON-LD document to check. + * @returns `true` if the document has a signature-like object; `false` + * otherwise. + * @since 2.2.0 + */ +export function hasSignatureLike(jsonLd: unknown): boolean { + if (typeof jsonLd !== "object" || jsonLd == null) return false; + const record = jsonLd as Record; + const signature = record.signature; + if (typeof signature !== "object" || signature == null) return false; + const signatureRecord = signature as Record; + return typeof signatureRecord.type === "string" && + (typeof signatureRecord.creator === "string" || + typeof signatureRecord.verificationMethod === "string") && + (typeof signatureRecord.signatureValue === "string" || + typeof signatureRecord.jws === "string"); +} + /** * Checks if the given JSON-LD document has a Linked Data Signature. * @param jsonLd The JSON-LD document to check. diff --git a/packages/fedify/src/sig/mod.ts b/packages/fedify/src/sig/mod.ts index 50d653886..a6f6b7211 100644 --- a/packages/fedify/src/sig/mod.ts +++ b/packages/fedify/src/sig/mod.ts @@ -41,6 +41,7 @@ export { createSignature, type CreateSignatureOptions, detachSignature, + hasSignatureLike, signJsonLd, type SignJsonLdOptions, verifyJsonLd, diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 1056dcda7..c2c1d6b47 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -558,6 +558,34 @@ test("postOutboxActivity routes missing actor through onError", async () => { assertEquals(handled, "The posted activity has no actor."); }); +test("postOutboxActivity missing owner does not invoke onError", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let handled = false; + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .onError((_ctx: OutboxContext<{ test: string }>, _error: Error) => { + handled = true; + }) + .on(Create, () => { + throw new Error("listener should not run"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + 'Actor "alice" not found.', + ); + assertEquals(handled, false); +}); + test( "postOutboxActivity accepts the dispatched actor id as the owner", async () => { diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index b3663b8a9..9d82268d5 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -10,6 +10,7 @@ import type { RequestContext, RouteActivityOptions, } from "@fedify/fedify/federation"; +import { hasSignatureLike } from "@fedify/fedify/sig"; import { Activity, CryptographicKey, Multikey } from "@fedify/vocab"; import type { Collection, @@ -75,20 +76,6 @@ function expandUriTemplate( }); } -function hasLinkedDataSignature(jsonLd: unknown): boolean { - if (jsonLd == null || typeof jsonLd !== "object") return false; - const record = jsonLd as Record; - const signature = record.signature; - if (signature == null || typeof signature !== "object") return false; - const signatureRecord = signature as Record; - - return typeof signatureRecord.type === "string" && - (typeof signatureRecord.creator === "string" || - typeof signatureRecord.verificationMethod === "string") && - (typeof signatureRecord.signatureValue === "string" || - typeof signatureRecord.jws === "string"); -} - /** * Represents a sent activity with metadata about how it was sent. * @since 1.8.0 @@ -559,17 +546,7 @@ class MockFederation implements Federation { const actor = await baseContext.getActor(identifier); if (actor == null) { - const error = new Error(`Actor ${JSON.stringify(identifier)} not found.`); - await this.outboxListenerErrorHandler?.( - createOutboxContext({ - ...baseContext, - clone: undefined, - federation: this as any, - identifier, - }), - error, - ); - throw error; + throw new Error(`Actor ${JSON.stringify(identifier)} not found.`); } const authorizePredicate = this.outboxAuthorizePredicate ?? this.outboxDispatcherAuthorizePredicate; @@ -632,7 +609,7 @@ class MockFederation implements Federation { options?: any, ) => { const hasProof = await activity.getProof() != null; - const hasLds = hasLinkedDataSignature(rawActivity); + const hasLds = hasSignatureLike(rawActivity); if (options?.skipIfUnsigned && !hasProof && !hasLds) { return; } From d3efeec5e071fa811a35cecaebf9339265d44dc5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 11:15:07 +0900 Subject: [PATCH 32/64] Polish outbox listener runtime edge cases The latest review round found a few small runtime rough edges around the new listener infrastructure. This makes ActivityListenerSet fail closed if the prototype chain is unexpectedly malformed, updates the outbox warning text so it no longer implies a delivery method was never called when skipIfUnsigned can short-circuit it, and makes the shared unsigned forwarding warning read naturally for both inbox and outbox paths. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/activity-listener.ts | 3 ++- packages/fedify/src/federation/handler.ts | 2 +- packages/fedify/src/federation/middleware.test.ts | 6 +++--- packages/fedify/src/federation/middleware.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/federation/activity-listener.ts b/packages/fedify/src/federation/activity-listener.ts index 215216e1c..ced22b4d6 100644 --- a/packages/fedify/src/federation/activity-listener.ts +++ b/packages/fedify/src/federation/activity-listener.ts @@ -40,11 +40,12 @@ export class ActivityListenerSet { listener: ActivityListener; } | null { let cls: ActivityConstructor = activity.constructor as ActivityConstructor; - while (true) { + while (cls != null) { if (this.#listeners.has(cls)) break; if (cls === Activity) return null; cls = globalThis.Object.getPrototypeOf(cls); } + if (cls == null) return null; const listener = this.#listeners.get(cls)!; return { class: cls, listener }; } diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 8868b6fd0..8d435877e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -710,7 +710,7 @@ export async function handleOutbox( !outboxContext.hasDeliveredActivity() ) { logger.warn( - "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity().", + "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery.", { identifier, activityId: activity.id?.href, diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 549e5f76b..f62395351 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2324,7 +2324,7 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals( records.some((record) => record.rawMessage === - "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." && + "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." && record.properties.identifier === "john" ), true, @@ -2408,7 +2408,7 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals( records.some((record) => record.rawMessage === - "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." + "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." ), false, ); @@ -2508,7 +2508,7 @@ test("Federation.setOutboxListeners()", async (t) => { assertEquals( records.some((record) => record.rawMessage === - "Outbox listener for {identifier} returned without delivering the posted activity through ctx.sendActivity() or ctx.forwardActivity()." + "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." ), false, ); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index f47d2b297..eb93c2306 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2965,7 +2965,7 @@ async function forwardActivityInternal( if (!hasProof) { if (options?.skipIfUnsigned) return false; logger.warn( - "The received activity {activityId} is not signed; even if it is " + + "The activity {activityId} is not signed; even if it is " + "forwarded to other servers as is, it may not be accepted by " + "them due to the lack of a signature/proof.", ); From a78cba593318b85f2e74896685d7ede34af28505 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 11:17:10 +0900 Subject: [PATCH 33/64] Align mock outbox registration with runtime The mock POST /outbox helper still diverged from runtime in a few setup edge cases. This makes it require setOutboxListeners() even when an outbox dispatcher is configured, rejects duplicate outbox-listener registration like the builder does, and keeps the related mock tests in sync with those stricter initialization rules. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 34 +++++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 7 ++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index c2c1d6b47..c32c59a99 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -778,6 +778,26 @@ test("postOutboxActivity fails fast without outbox listeners", async () => { ); }); +test("postOutboxActivity with only dispatcher still fails fast", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation + .setOutboxDispatcher("/users/{identifier}/outbox", () => ({ items: [] })); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.", + ); +}); + test("postOutboxActivity without matching listener is a no-op", async () => { const mockFederation = createFederation(); mockFederation.setActorDispatcher( @@ -886,6 +906,20 @@ test("setOutboxListeners rejects duplicate listeners for the same type", () => { ); }); +test("setOutboxListeners rejects duplicate registration", () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setOutboxListeners("/users/{identifier}/outbox"); + + assertThrows( + () => mockFederation.setOutboxListeners("/users/{identifier}/outbox"), + TypeError, + "Outbox listeners already set.", + ); +}); + test("createOutboxContext exposes identifier", () => { const mockFederation = createFederation(); const ctx = createOutboxContext({ diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 9d82268d5..2aec9bdf4 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -221,6 +221,7 @@ class MockFederation implements Federation { private featuredTagsDispatcher?: any; private inboxListeners: Map = new Map(); private outboxListeners: Map = new Map(); + private outboxListenersInitialized = false; private contextData?: TContextData; private receivedActivities: Activity[] = []; @@ -379,6 +380,10 @@ class MockFederation implements Federation { } setOutboxListeners(outboxPath: any): any { + if (this.outboxListenersInitialized) { + throw new TypeError("Outbox listeners already set."); + } + this.outboxListenersInitialized = true; this.outboxPath = outboxPath; // deno-lint-ignore no-this-alias const self = this; @@ -511,7 +516,7 @@ class MockFederation implements Federation { identifier: string, activity: Activity, ): Promise { - if (this.outboxPath == null) { + if (!this.outboxListenersInitialized) { throw new Error( "MockFederation.postOutboxActivity(): setOutboxListeners() is not initialized.", ); From 11194220e0874d5348477ea76b24339a59e6356d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 11:49:31 +0900 Subject: [PATCH 34/64] Decouple outbox auth from body parsing handleOutbox() used to authorize against the original Request and then parse the same body afterward, so an authorization callback that read ctx.request.json() would accidentally trigger a 500. This clones the request for parsing up front, lets auth callbacks inspect the original request body safely, and adds a regression test for that flow. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/handler.test.ts | 24 +++++++++++++++ packages/fedify/src/federation/handler.ts | 29 ++++++++++--------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 67598cc05..3125d9815 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1469,6 +1469,30 @@ test("handleOutbox()", async () => { `someone:${activity.id?.href}`, ]); + onUnauthorizedCalled = null; + ({ request, context } = createRequestContextPair()); + response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + authorizePredicate: async (ctx) => { + await ctx.request.json(); + return true; + }, + onNotFound, + onUnauthorized, + }); + assertEquals(onUnauthorizedCalled, null); + assertEquals([response.status, await response.text()], [202, ""]); + const invalidRequest = new Request( "https://example.com/users/someone/outbox", { diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 8d435877e..6e88bfe3c 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -525,6 +525,20 @@ export async function handleOutbox( }: OutboxHandlerParameters, ): Promise { const logger = getLogger(["fedify", "federation", "outbox"]); + if (request.bodyUsed) { + logger.error("Request body has already been read.", { identifier }); + return new Response("Internal server error.", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } else if (request.body?.locked) { + logger.error("Request body is locked.", { identifier }); + return new Response("Internal server error.", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + const requestForParsing = request.clone(); if (actorDispatcher == null) { logger.error("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); @@ -539,22 +553,9 @@ export async function handleOutbox( return await onUnauthorized(request); } } - if (request.bodyUsed) { - logger.error("Request body has already been read.", { identifier }); - return new Response("Internal server error.", { - status: 500, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); - } else if (request.body?.locked) { - logger.error("Request body is locked.", { identifier }); - return new Response("Internal server error.", { - status: 500, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); - } let json: unknown; try { - json = await request.json(); + json = await requestForParsing.json(); } catch (error) { logger.error("Failed to parse JSON:\n{error}", { identifier, error }); const outboxContext = outboxContextFactory(identifier, null, undefined, ""); From 7064a7cbce3359f2171a9757336514b89e33bd86 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 11:49:52 +0900 Subject: [PATCH 35/64] Tighten listener matching edge cases in lint and mocks The last open review threads were all about edge-case fidelity. This adds identifier boundaries to the outbox delivery lint rule so `ctx` does not match as a substring of another variable name, and it brings MockFederation.setOutboxListeners() closer to the builder by validating outbox path compatibility and template shape in addition to rejecting re-registration. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.ts | 4 +- .../outbox-listener-delivery-required.test.ts | 25 ++++++++++++ packages/testing/src/mock.test.ts | 38 +++++++++++++++++++ packages/testing/src/mock.ts | 19 ++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts index 041f18262..6e1335787 100644 --- a/packages/lint/src/rules/outbox-listener-delivery-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -214,7 +214,9 @@ function getDeliveryAliasName(node: Node): string | null { function buildContextExpressionPattern(contextName: string): string { const name = escapeRegExp(contextName); - return String.raw`(?:${name}|\(\s*${name}(?:\s+as\s+[^)]+)?\s*\))`; + const boundedName = String.raw`(? { + const myctx = { + sendActivity: async () => { + console.log(activity.id?.href); + }, + }; + await myctx.sendActivity(); + console.log(ctx.identifier); + }); +`, + rule, + ruleName, + expectedError: + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); + test( `${ruleName}: ❌ Bad - template literal mentioning delivery methods`, lintTest({ diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index c32c59a99..cfdfffa8a 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -920,6 +920,44 @@ test("setOutboxListeners rejects duplicate registration", () => { ); }); +test("setOutboxListeners validates dispatcher path compatibility", () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setOutboxDispatcher("/users/{identifier}/outbox", () => ({ + items: [], + })); + + assertThrows( + () => mockFederation.setOutboxListeners("/actors/{identifier}/outbox"), + TypeError, + "Outbox listener path must match outbox dispatcher path.", + ); +}); + +test("setOutboxListeners validates path variables", () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + assertThrows( + () => + mockFederation.setOutboxListeners( + "/users/outbox" as `${string}{identifier}${string}`, + ), + TypeError, + "Path for outbox must have one variable: {identifier}", + ); + + assertThrows( + () => + mockFederation.setOutboxListeners("/users/{identifier}/outbox/{extra}"), + TypeError, + "Path for outbox must have one variable: {identifier}", + ); +}); + test("createOutboxContext exposes identifier", () => { const mockFederation = createFederation(); const ctx = createOutboxContext({ diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 2aec9bdf4..749d0d44f 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -76,6 +76,24 @@ function expandUriTemplate( }); } +function validateOutboxListenerPath( + path: string, + dispatcherPath?: string, +): void { + if (dispatcherPath != null && dispatcherPath !== path) { + throw new TypeError( + "Outbox listener path must match outbox dispatcher path.", + ); + } + const variables = globalThis.Array.from( + path.matchAll(/{(\+?)([A-Za-z_][A-Za-z0-9_]*)}/g), + (match) => match[2], + ); + if (variables.length !== 1 || variables[0] !== "identifier") { + throw new TypeError("Path for outbox must have one variable: {identifier}"); + } +} + /** * Represents a sent activity with metadata about how it was sent. * @since 1.8.0 @@ -383,6 +401,7 @@ class MockFederation implements Federation { if (this.outboxListenersInitialized) { throw new TypeError("Outbox listeners already set."); } + validateOutboxListenerPath(outboxPath, this.outboxPath); this.outboxListenersInitialized = true; this.outboxPath = outboxPath; // deno-lint-ignore no-this-alias From f94a6aae56557b9d9660c975f8081f46a45ef074 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 12:19:35 +0900 Subject: [PATCH 36/64] Broaden signature-shape checks for forwarding The latest review round found one remaining mismatch in how unsigned forwarding is detected. This expands hasSignatureLike() to accept object and array forms of creator/verificationMethod references, and it passes structured context into the unsigned-forwarding warning so the log entry keeps its activity metadata. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/middleware.test.ts | 4 +++- packages/fedify/src/federation/middleware.ts | 5 +++++ packages/fedify/src/sig/ld.test.ts | 18 ++++++++++++++++++ packages/fedify/src/sig/ld.ts | 12 ++++++++++-- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index f62395351..aa82fb241 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -4274,7 +4274,9 @@ test("InboxContextImpl.forwardActivity()", async (t) => { "actor": "https://example.com/person2", "signature": { "type": "Ed25519Signature2020", - "verificationMethod": "https://example.com/person2#main-key", + "verificationMethod": { + "id": "https://example.com/person2#main-key", + }, "jws": "signature", }, }; diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index eb93c2306..b2b0509d0 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2968,6 +2968,11 @@ async function forwardActivityInternal( "The activity {activityId} is not signed; even if it is " + "forwarded to other servers as is, it may not be accepted by " + "them due to the lack of a signature/proof.", + { + activityId: ctx.activityId, + activityType: ctx.activityType, + identifier: identifier ?? undefined, + }, ); } } diff --git a/packages/fedify/src/sig/ld.test.ts b/packages/fedify/src/sig/ld.test.ts index ed9c5d706..f1f17183b 100644 --- a/packages/fedify/src/sig/ld.test.ts +++ b/packages/fedify/src/sig/ld.test.ts @@ -109,6 +109,24 @@ test("hasSignatureLike()", () => { jws: "signature", }, })); + assert(hasSignatureLike({ + signature: { + type: "Ed25519Signature2020", + verificationMethod: { + id: "https://example.com/users/alice#main-key", + }, + jws: "signature", + }, + })); + assert(hasSignatureLike({ + signature: { + type: "Ed25519Signature2020", + verificationMethod: [{ + id: "https://example.com/users/alice#main-key", + }], + jws: "signature", + }, + })); assertFalse(hasSignatureLike({ signature: { type: "Ed25519Signature2020", diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index a757d108c..adfe7f8f8 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -190,9 +190,17 @@ export function hasSignatureLike(jsonLd: unknown): boolean { const signature = record.signature; if (typeof signature !== "object" || signature == null) return false; const signatureRecord = signature as Record; + + const hasReference = (value: unknown): boolean => { + if (typeof value === "string") return true; + if (Array.isArray(value)) return value.some(hasReference); + return typeof value === "object" && value != null && + "id" in value && typeof value.id === "string"; + }; + return typeof signatureRecord.type === "string" && - (typeof signatureRecord.creator === "string" || - typeof signatureRecord.verificationMethod === "string") && + (hasReference(signatureRecord.creator) || + hasReference(signatureRecord.verificationMethod)) && (typeof signatureRecord.signatureValue === "string" || typeof signatureRecord.jws === "string"); } From cf733b319d1ebd3c7ec857420ec05a742d14f4e8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 12:21:51 +0900 Subject: [PATCH 37/64] Feed posted JSON through mock outbox auth MockFederation.postOutboxActivity() should expose the same posted body to body-aware authorization callbacks that runtime POST /outbox handling does. This serializes the posted activity into the mock Request before authorization runs and adds regressions for body-aware auth plus the remaining outbox path and signature-shape edge cases in the mock. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 46 ++++++++++++++++++++++++++++++- packages/testing/src/mock.ts | 9 ++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index cfdfffa8a..c77e61e45 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -319,7 +319,9 @@ test( actor: "https://example.com/users/alice", signature: { type: "Ed25519Signature2020", - verificationMethod: "https://example.com/users/alice#main-key", + verificationMethod: { + id: "https://example.com/users/alice#main-key", + }, jws: "signature", }, }; @@ -681,6 +683,48 @@ test("postOutboxActivity enforces authorize predicate", async () => { assertEquals(called, false); }); +test("postOutboxActivity authorize predicate can inspect posted body", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + let seenBody = ""; + let called = false; + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .authorize(async (ctx) => { + seenBody = await ctx.request.text(); + return true; + }) + .on(Create, () => { + called = true; + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(seenBody.length > 0, true); + assertEquals( + seenBody.includes('"https://www.w3.org/ns/activitystreams#actor"'), + true, + ); + assertEquals(seenBody.includes("alice"), true); + assertEquals(called, true); +}); + test("postOutboxActivity falls back to dispatcher authorize predicate", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 749d0d44f..5b8cb1443 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -560,8 +560,13 @@ class MockFederation implements Federation { origin, this.contextData as TContextData, ); + const postedJson = await activity.toJsonLd({ + contextLoader: routingContext.contextLoader, + }); const request = new Request(routingContext.getOutboxUri(identifier), { method: "POST", + body: JSON.stringify(postedJson), + headers: { "content-type": "application/activity+json" }, }); const baseContext = this.createContext( request, @@ -618,9 +623,7 @@ class MockFederation implements Federation { if (listener == null) return; if (listener != null) { - const rawActivity = await activity.toJsonLd({ - contextLoader: baseContext.contextLoader, - }); + const rawActivity = postedJson; const context = createOutboxContext({ ...baseContext, clone: undefined, From 44c4cb7b8bb3381c2b1e6aa36be9402d81c5b787 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 13:02:38 +0900 Subject: [PATCH 38/64] Avoid re-parsing forwarded proofs for warnings The shared forwarding helper only needs a cheap presence check before it warns about unsigned payloads. This adds a structural Data Integrity proof detector and uses it before the expensive Activity.fromJsonLd() path, while also preserving the warning metadata that the review thread called out. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/middleware.ts | 10 +---- packages/fedify/src/sig/proof.test.ts | 43 +++++++++++++++++++- packages/fedify/src/sig/proof.ts | 30 ++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index b2b0509d0..e088a93a4 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -61,7 +61,7 @@ import { import { exportJwk, importJwk, validateCryptoKey } from "../sig/key.ts"; import { hasSignatureLike, signJsonLd } from "../sig/ld.ts"; import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts"; -import { signObject, verifyObject } from "../sig/proof.ts"; +import { hasProofLike, signObject, verifyObject } from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { kvCache } from "../utils/kv-cache.ts"; import { FederationBuilderImpl } from "./builder.ts"; @@ -2955,13 +2955,7 @@ async function forwardActivityInternal( keys = [forwarder]; } if (!hasSignatureLike(ctx.activity)) { - let hasProof: boolean; - try { - const activity = await Activity.fromJsonLd(ctx.activity, ctx); - hasProof = await activity.getProof() != null; - } catch { - hasProof = false; - } + const hasProof = hasProofLike(ctx.activity); if (!hasProof) { if (options?.skipIfUnsigned) return false; logger.warn( diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 00b9bc576..c1fa3e8df 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -8,7 +8,13 @@ import { Place, } from "@fedify/vocab"; import { decodeMultibase, importMultibaseKey } from "@fedify/vocab-runtime"; -import { assertEquals, assertInstanceOf, assertRejects } from "@std/assert"; +import { + assert, + assertEquals, + assertFalse, + assertInstanceOf, + assertRejects, +} from "@std/assert"; import { decodeHex } from "byte-encodings/hex"; import { ed25519Multikey, @@ -20,6 +26,7 @@ import { import type { KeyCache } from "./key.ts"; import { createProof, + hasProofLike, signObject, verifyObject, type VerifyObjectOptions, @@ -265,6 +272,40 @@ test("signObject()", async () => { ); }); +test("hasProofLike()", () => { + assert(hasProofLike({ + proof: { + type: "DataIntegrityProof", + verificationMethod: "https://example.com/users/alice#main-key", + proofPurpose: "assertionMethod", + proofValue: "signature", + }, + })); + assert(hasProofLike({ + proof: { + type: "DataIntegrityProof", + verificationMethod: { id: "https://example.com/users/alice#main-key" }, + proofPurpose: "assertionMethod", + proofValue: "signature", + }, + })); + assert(hasProofLike({ + proof: [{ + type: "DataIntegrityProof", + verificationMethod: { id: "https://example.com/users/alice#main-key" }, + proofPurpose: "assertionMethod", + proofValue: "signature", + }], + })); + assertFalse(hasProofLike({ + proof: { + type: "DataIntegrityProof", + verificationMethod: { id: "https://example.com/users/alice#main-key" }, + proofPurpose: "assertionMethod", + }, + })); +}); + test("verifyProof()", async () => { const cache: Record = {}; const options: VerifyProofOptions = { diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index fe00938b5..46ba6cd3c 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -20,6 +20,36 @@ import { const logger = getLogger(["fedify", "sig", "proof"]); +/** + * Checks if the given JSON-LD document has a DataIntegrityProof-like object, + * without fully deserializing it into vocabulary classes. + * @param jsonLd The JSON-LD document to check. + * @returns `true` if the document has a proof-like object; `false` otherwise. + * @since 2.2.0 + */ +export function hasProofLike(jsonLd: unknown): boolean { + if (typeof jsonLd !== "object" || jsonLd == null) return false; + const record = jsonLd as Record; + const proof = record.proof; + + const isReference = (value: unknown): boolean => { + if (typeof value === "string") return true; + return typeof value === "object" && value != null && + "id" in value && typeof value.id === "string"; + }; + + const isProofLike = (value: unknown): boolean => { + if (typeof value !== "object" || value == null) return false; + const proofRecord = value as Record; + return proofRecord.type === "DataIntegrityProof" && + isReference(proofRecord.verificationMethod) && + typeof proofRecord.proofPurpose === "string" && + typeof proofRecord.proofValue === "string"; + }; + + return Array.isArray(proof) ? proof.some(isProofLike) : isProofLike(proof); +} + /** * Options for {@link createProof}. * @since 0.10.0 From aaea97e4be4ab787155951110ac30435b0e06553 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 13:04:25 +0900 Subject: [PATCH 39/64] Validate outbox listener paths before routing setOutboxListeners() was validating the template shape only after it had already registered the route. This left the builder in a broken state if an invalid path threw midway through registration. The validation now runs before the route is added, and a regression test covers retrying with a corrected path afterward. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/builder.test.ts | 10 +++++++ packages/fedify/src/federation/builder.ts | 27 ++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index b5f5e86ea..dc64876e7 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -157,6 +157,16 @@ test("FederationBuilder", async (t) => { RouterError, ); + const builderAfterInvalid = createFederationBuilder(); + assertThrows( + () => + builderAfterInvalid.setOutboxListeners( + "/users/{identifier}/outbox/{extra}", + ), + RouterError, + ); + builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox"); + const builder2 = createFederationBuilder(); builder2.setOutboxListeners("/users/{identifier}/outbox"); diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 98d4e5a36..d26c08411 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -63,6 +63,19 @@ import type { } from "./handler.ts"; import { Router, RouterError } from "./router.ts"; +function validateSingleIdentifierVariablePath( + path: string, + errorMessage: string, +): void { + const variables = globalThis.Array.from( + path.matchAll(/{(\+?)([A-Za-z_][A-Za-z0-9_]*)}/g), + (match) => match[2], + ); + if (variables.length !== 1 || variables[0] !== "identifier") { + throw new RouterError(errorMessage); + } +} + export class FederationBuilderImpl implements FederationBuilder { router: Router; @@ -805,15 +818,11 @@ export class FederationBuilderImpl ); } } else { - const variables = this.router.add(outboxPath, "outbox"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for outbox must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + outboxPath, + "Path for outbox must have one variable: {identifier}", + ); + this.router.add(outboxPath, "outbox"); this.outboxPath = outboxPath; } const listeners = this.outboxListeners = new ActivityListenerSet< From 68a4b9516155357c7037e1db05e49e4807086304 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 13:40:14 +0900 Subject: [PATCH 40/64] Finish mock outbox path and body parity The remaining mock review comments were both about keeping the testing helper aligned with runtime outbox behavior. This wires the posted JSON body into mock authorization, validates outbox dispatcher/listener path compatibility in both call orders, and clarifies the mock error message so it matches the fact that both {identifier} and {+identifier} are allowed. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 21 +++++++++++++++++++-- packages/testing/src/mock.ts | 8 +++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index c77e61e45..453c8f0cf 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -980,6 +980,23 @@ test("setOutboxListeners validates dispatcher path compatibility", () => { ); }); +test("setOutboxDispatcher validates listener path compatibility", () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setOutboxListeners("/users/{identifier}/outbox"); + + assertThrows( + () => + mockFederation.setOutboxDispatcher("/actors/{identifier}/outbox", () => ({ + items: [], + })), + TypeError, + "Outbox listener path must match outbox dispatcher path.", + ); +}); + test("setOutboxListeners validates path variables", () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -991,14 +1008,14 @@ test("setOutboxListeners validates path variables", () => { "/users/outbox" as `${string}{identifier}${string}`, ), TypeError, - "Path for outbox must have one variable: {identifier}", + "Path for outbox must have exactly one variable named identifier.", ); assertThrows( () => mockFederation.setOutboxListeners("/users/{identifier}/outbox/{extra}"), TypeError, - "Path for outbox must have one variable: {identifier}", + "Path for outbox must have exactly one variable named identifier.", ); }); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 5b8cb1443..5e9130ed3 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -90,7 +90,9 @@ function validateOutboxListenerPath( (match) => match[2], ); if (variables.length !== 1 || variables[0] !== "identifier") { - throw new TypeError("Path for outbox must have one variable: {identifier}"); + throw new TypeError( + "Path for outbox must have exactly one variable named identifier.", + ); } } @@ -300,6 +302,10 @@ class MockFederation implements Federation { } setOutboxDispatcher(path: any, dispatcher: any): any { + validateOutboxListenerPath( + path, + this.outboxListenersInitialized ? this.outboxPath : undefined, + ); this.outboxDispatcher = dispatcher; this.outboxPath = path; return { From b9ba95a1de2f89b8afd6a6433f61456ab62d4a10 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 14:12:06 +0900 Subject: [PATCH 41/64] Keep forwarded delivery checks conservative The latest review round turned up two cases where outbox forwarding was still too eager to count something as delivered: the unsigned check could still miss expanded proof shapes, and zero extracted inboxes were being treated as a successful forward. This broadens the structural proof detector and leaves forwardActivity() undelivered when no inboxes are found, with regressions for both cases. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/middleware.test.ts | 77 +++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 8 ++ packages/fedify/src/sig/proof.test.ts | 10 +++ packages/fedify/src/sig/proof.ts | 17 +++- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index aa82fb241..24c891097 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2520,6 +2520,83 @@ test("Federation.setOutboxListeners()", async (t) => { }, ); + await t.step( + "warns when forwardActivity resolves to zero inboxes", + async () => { + await withLogtapeLock(async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; + const records: LogRecord[] = []; + await reset(); + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); + + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + [], + ); + }) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." && + record.properties.identifier === "john" + ), + true, + ); + } finally { + await reset(); + } + }); + }, + ); + await t.step( "forwardActivity starts the outbox queue automatically", async () => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index e088a93a4..6b2ed23c1 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2988,6 +2988,14 @@ async function forwardActivityInternal( preferSharedInbox: options?.preferSharedInbox, excludeBaseUris: options?.excludeBaseUris, }); + if (globalThis.Object.keys(inboxes).length < 1) { + logger.debug("No inboxes found for activity {activityId}.", { + activityId: ctx.activityId, + activityType: ctx.activityType, + identifier: identifier ?? undefined, + }); + return false; + } logger.debug("Forwarding activity {activityId} to inboxes:\n{inboxes}", { inboxes: globalThis.Object.keys(inboxes), activityId: ctx.activityId, diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index c1fa3e8df..840c2e4ca 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -297,6 +297,16 @@ test("hasProofLike()", () => { proofValue: "signature", }], })); + assert(hasProofLike({ + proof: { + type: ["https://w3id.org/security#DataIntegrityProof"], + verificationMethod: [{ + "@id": "https://example.com/users/alice#main-key", + }], + proofPurpose: { "@id": "https://w3id.org/security#assertionMethod" }, + proofValue: "signature", + }, + })); assertFalse(hasProofLike({ proof: { type: "DataIntegrityProof", diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 46ba6cd3c..574c0f211 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -34,16 +34,27 @@ export function hasProofLike(jsonLd: unknown): boolean { const isReference = (value: unknown): boolean => { if (typeof value === "string") return true; + if (Array.isArray(value)) return value.some(isReference); return typeof value === "object" && value != null && - "id" in value && typeof value.id === "string"; + (("id" in value && typeof value.id === "string") || + ("@id" in value && typeof value["@id"] === "string")); + }; + + const hasType = (value: unknown): boolean => { + if (typeof value === "string") { + return value === "DataIntegrityProof" || + value === "https://w3id.org/security#DataIntegrityProof"; + } + if (Array.isArray(value)) return value.some(hasType); + return false; }; const isProofLike = (value: unknown): boolean => { if (typeof value !== "object" || value == null) return false; const proofRecord = value as Record; - return proofRecord.type === "DataIntegrityProof" && + return hasType(proofRecord.type) && isReference(proofRecord.verificationMethod) && - typeof proofRecord.proofPurpose === "string" && + isReference(proofRecord.proofPurpose) && typeof proofRecord.proofValue === "string"; }; From f62779af151fb5ac33c3e1a1294012c3654a4faf Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 14:14:24 +0900 Subject: [PATCH 42/64] Align mock outbox error contexts with runtime The mock outbox helper still handed a reduced OutboxContext to onError(), and that diverged from runtime behavior in two ways: recovery handlers could not call forwardActivity(), and body-aware authorization callbacks still had to guess at the posted request shape. This reuses a fully wired mock OutboxContext for both success and error paths and adds regressions for error-handler forwarding and body-aware auth. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 41 ++++++++++++++++++ packages/testing/src/mock.ts | 72 +++++++++++++------------------ 2 files changed, 70 insertions(+), 43 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 453c8f0cf..3e1960f0f 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -560,6 +560,47 @@ test("postOutboxActivity routes missing actor through onError", async () => { assertEquals(handled, "The posted activity has no actor."); }); +test("postOutboxActivity onError can forward after validation failure", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .onError(async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + ); + }) + .on(Create, () => { + throw new Error("listener should not run"); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/bob"), + }); + + await assertRejects( + () => mockFederation.postOutboxActivity("alice", activity), + Error, + "The activity actor does not match the outbox owner.", + ); + assertEquals(mockFederation.sentActivities.length, 1); + assertEquals(mockFederation.sentActivities[0].activity, activity); + assertEquals(mockFederation.sentActivities[0].rawActivity != null, true); +}); + test("postOutboxActivity missing owner does not invoke onError", async () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 5e9130ed3..36c0a1e2f 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -578,6 +578,32 @@ class MockFederation implements Federation { request, this.contextData as TContextData, ); + const rawActivity = postedJson; + const createMockOutboxContext = () => + createOutboxContext({ + ...baseContext, + clone: undefined, + federation: this as any, + identifier, + sendActivity: baseContext.sendActivity.bind(baseContext), + forwardActivity: async ( + forwarder: any, + recipients: any, + options?: any, + ) => { + const hasProof = await activity.getProof() != null; + const hasLds = hasSignatureLike(rawActivity); + if (options?.skipIfUnsigned && !hasProof && !hasLds) { + return; + } + return baseContext.sendActivity( + forwarder, + recipients, + activity, + { ...options, rawActivity }, + ); + }, + }); const actor = await baseContext.getActor(identifier); if (actor == null) { @@ -595,15 +621,7 @@ class MockFederation implements Federation { const expectedActorId = actor.id ?? baseContext.getActorUri(identifier); if (activity.actorIds.length < 1) { const error = new Error("The posted activity has no actor."); - await this.outboxListenerErrorHandler?.( - createOutboxContext({ - ...baseContext, - clone: undefined, - federation: this as any, - identifier, - }), - error, - ); + await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error); throw error; } if ( @@ -614,46 +632,14 @@ class MockFederation implements Federation { const error = new Error( "The activity actor does not match the outbox owner.", ); - await this.outboxListenerErrorHandler?.( - createOutboxContext({ - ...baseContext, - clone: undefined, - federation: this as any, - identifier, - }), - error, - ); + await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error); throw error; } if (listener == null) return; if (listener != null) { - const rawActivity = postedJson; - const context = createOutboxContext({ - ...baseContext, - clone: undefined, - federation: this as any, - identifier, - sendActivity: baseContext.sendActivity.bind(baseContext), - forwardActivity: async ( - forwarder: any, - recipients: any, - options?: any, - ) => { - const hasProof = await activity.getProof() != null; - const hasLds = hasSignatureLike(rawActivity); - if (options?.skipIfUnsigned && !hasProof && !hasLds) { - return; - } - return baseContext.sendActivity( - forwarder, - recipients, - activity, - { ...options, rawActivity }, - ); - }, - }); + const context = createMockOutboxContext(); try { await listener(context, activity); } catch (error) { From c5fb7e0de65c193f37aef707de4f909f27f32133 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 14:37:34 +0900 Subject: [PATCH 43/64] Harden forwarding delivery detection and signatures The latest review round found two gaps in forwarded outbox delivery. First, zero resolved inboxes were still being treated as a successful send, which could suppress the outbox warning even when nothing was delivered. Second, hasSignatureLike() still missed JSON-LD reference forms such as @id and signature arrays, which could make skipIfUnsigned too strict. This fixes both and adds regressions. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/middleware.test.ts | 78 +++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 54 +++++++++++-- packages/fedify/src/sig/ld.test.ts | 14 ++++ packages/fedify/src/sig/ld.ts | 23 ++++-- 4 files changed, 156 insertions(+), 13 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 24c891097..bbde3e07d 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2419,6 +2419,84 @@ test("Federation.setOutboxListeners()", async (t) => { }); }); + await t.step( + "warns when listener calls sendActivity() with zero inboxes", + async () => { + await withLogtapeLock(async () => { + const postedFixture = { + ...createFixture, + actor: "https://example.com/users/john", + }; + const records: LogRecord[] = []; + await reset(); + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [{ category: [], sinks: ["buffer"] }], + }); + + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation + .setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => + identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + + federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(vocab.Activity, async (ctx, activity) => { + await ctx.sendActivity( + { identifier: ctx.identifier }, + [], + activity, + ); + }) + .authorize((ctx, identifier) => { + return identifier === "john" && + ctx.request.headers.get("authorization") === "Bearer token"; + }); + + const response = await federation.fetch( + new Request("https://example.com/users/john/outbox", { + method: "POST", + body: JSON.stringify(postedFixture), + headers: { + authorization: "Bearer token", + "content-type": "application/activity+json", + }, + }), + { contextData: undefined }, + ); + + assertEquals(response.status, 202); + assertEquals( + records.some((record) => + record.rawMessage === + "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." && + record.properties.identifier === "john" + ), + true, + ); + } finally { + await reset(); + } + }); + }, + ); + await t.step( "does not warn when listener calls forwardActivity()", async () => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 6b2ed23c1..05e39e750 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2279,7 +2279,7 @@ export class ContextImpl implements Context { activity: Activity, options: SendActivityOptionsForCollection, span: Span, - ): Promise { + ): Promise { const logger = getLogger(["fedify", "federation", "outbox"]); let keys: SenderKeyPair[]; let identifier: string | null = null; @@ -2404,6 +2404,13 @@ export class ContextImpl implements Context { preferSharedInbox: options.preferSharedInbox, excludeBaseUris: options.excludeBaseUris, }); + if (globalThis.Object.keys(inboxes).length < 1) { + logger.debug("No inboxes found for activity {activityId}.", { + activityId: activity.id?.href, + activity, + }); + return false; + } logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", { inboxes: globalThis.Object.keys(inboxes), activityId: activity.id?.href, @@ -2415,7 +2422,7 @@ export class ContextImpl implements Context { globalThis.Object.keys(inboxes).length < FANOUT_THRESHOLD ) { await this.federation.sendActivity(keys, inboxes, activity, opts); - return; + return true; } const keyJwkPairs = await Promise.all( keys.map(async ({ keyId, privateKey }) => ({ @@ -2452,6 +2459,7 @@ export class ContextImpl implements Context { message, { orderingKey: options.orderingKey }, ); + return true; } async *getFollowers(identifier: string): AsyncIterable { @@ -3246,9 +3254,45 @@ export class OutboxContextImpl extends ContextImpl activity: Activity, options: SendActivityOptionsForCollection = {}, ): Promise { - return super.sendActivity(sender, recipients, activity, options).then( - () => { - this.#deliveryState.delivered = true; + const tracer = this.tracerProvider.getTracer( + metadata.name, + metadata.version, + ); + return tracer.startActiveSpan( + this.federation.outboxQueue == null || options.immediate + ? "activitypub.outbox" + : "activitypub.fanout", + { + kind: this.federation.outboxQueue == null || options.immediate + ? SpanKind.CLIENT + : SpanKind.PRODUCER, + attributes: { + "activitypub.activity.type": getTypeId(activity).href, + "activitypub.activity.to": activity.toIds.map((to) => to.href), + "activitypub.activity.cc": activity.toIds.map((cc) => cc.href), + "activitypub.activity.bto": activity.btoIds.map((bto) => bto.href), + "activitypub.activity.bcc": activity.toIds.map((bcc) => bcc.href), + }, + }, + async (span) => { + try { + if (activity.id != null) { + span.setAttribute("activitypub.activity.id", activity.id.href); + } + const delivered = await this.sendActivityInternal( + sender, + recipients, + activity, + options, + span, + ); + if (delivered) this.#deliveryState.delivered = true; + } catch (e) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); + throw e; + } finally { + span.end(); + } }, ); } diff --git a/packages/fedify/src/sig/ld.test.ts b/packages/fedify/src/sig/ld.test.ts index f1f17183b..d75f07f28 100644 --- a/packages/fedify/src/sig/ld.test.ts +++ b/packages/fedify/src/sig/ld.test.ts @@ -127,6 +127,20 @@ test("hasSignatureLike()", () => { jws: "signature", }, })); + assert(hasSignatureLike({ + signature: { + type: "Ed25519Signature2020", + verificationMethod: { "@id": "https://example.com/users/alice#main-key" }, + jws: "signature", + }, + })); + assert(hasSignatureLike({ + signature: [{ + type: "Ed25519Signature2020", + verificationMethod: { "@id": "https://example.com/users/alice#main-key" }, + jws: "signature", + }], + })); assertFalse(hasSignatureLike({ signature: { type: "Ed25519Signature2020", diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index adfe7f8f8..f77b22bf0 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -188,21 +188,28 @@ export function hasSignatureLike(jsonLd: unknown): boolean { if (typeof jsonLd !== "object" || jsonLd == null) return false; const record = jsonLd as Record; const signature = record.signature; - if (typeof signature !== "object" || signature == null) return false; - const signatureRecord = signature as Record; const hasReference = (value: unknown): boolean => { if (typeof value === "string") return true; if (Array.isArray(value)) return value.some(hasReference); return typeof value === "object" && value != null && - "id" in value && typeof value.id === "string"; + (("id" in value && typeof value.id === "string") || + ("@id" in value && typeof value["@id"] === "string")); }; - return typeof signatureRecord.type === "string" && - (hasReference(signatureRecord.creator) || - hasReference(signatureRecord.verificationMethod)) && - (typeof signatureRecord.signatureValue === "string" || - typeof signatureRecord.jws === "string"); + const hasSignatureObject = (value: unknown): boolean => { + if (typeof value !== "object" || value == null) return false; + const signatureRecord = value as Record; + return typeof signatureRecord.type === "string" && + (hasReference(signatureRecord.creator) || + hasReference(signatureRecord.verificationMethod)) && + (typeof signatureRecord.signatureValue === "string" || + typeof signatureRecord.jws === "string"); + }; + + return Array.isArray(signature) + ? signature.some(hasSignatureObject) + : hasSignatureObject(signature); } /** From 6de0472333bf00270dfba8872149ffabb23b1ff5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 14:40:41 +0900 Subject: [PATCH 44/64] Match outbox path checks to RFC6570 inputs The temporary outbox path validator was still narrower than the Rfc6570Expression type and rejected otherwise valid operator forms like {/identifier} and {?identifier}. Using a temporary Router for the validation keeps the builder state clean on failure and aligns the runtime check with the actual route syntax the public type allows. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/builder.test.ts | 5 ++++- packages/fedify/src/federation/builder.ts | 7 ++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index dc64876e7..62ffb322b 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -168,7 +168,7 @@ test("FederationBuilder", async (t) => { builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox"); const builder2 = createFederationBuilder(); - builder2.setOutboxListeners("/users/{identifier}/outbox"); + builder2.setOutboxListeners("/users{/identifier}/outbox"); assertThrows( () => @@ -178,6 +178,9 @@ test("FederationBuilder", async (t) => { ), RouterError, ); + + const builder3 = createFederationBuilder(); + builder3.setOutboxListeners("/users{?identifier}/outbox"); }); await t.step("should pass build options correctly", async () => { diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index d26c08411..2d1eebb1c 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -67,11 +67,8 @@ function validateSingleIdentifierVariablePath( path: string, errorMessage: string, ): void { - const variables = globalThis.Array.from( - path.matchAll(/{(\+?)([A-Za-z_][A-Za-z0-9_]*)}/g), - (match) => match[2], - ); - if (variables.length !== 1 || variables[0] !== "identifier") { + const variables = new Router().add(path, "outbox"); + if (variables.size !== 1 || !variables.has("identifier")) { throw new RouterError(errorMessage); } } From 173dfb453bf4b67de5a665b57cedc8d5a1625ab2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 15:23:49 +0900 Subject: [PATCH 45/64] Correct outbox telemetry metadata The outbox-listener span metadata still had a few inaccuracies. This fixes cc and bcc to use the correct recipient lists in span attributes and makes inbox-side forwardActivity() spans use the inbox category instead of being mislabeled as outbox work. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/middleware.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 05e39e750..daf4f9412 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2242,9 +2242,9 @@ export class ContextImpl implements Context { attributes: { "activitypub.activity.type": getTypeId(activity).href, "activitypub.activity.to": activity.toIds.map((to) => to.href), - "activitypub.activity.cc": activity.toIds.map((cc) => cc.href), + "activitypub.activity.cc": activity.ccIds.map((cc) => cc.href), "activitypub.activity.bto": activity.btoIds.map((bto) => bto.href), - "activitypub.activity.bcc": activity.toIds.map((bcc) => bcc.href), + "activitypub.activity.bcc": activity.bccIds.map((bcc) => bcc.href), }, }, async (span) => { @@ -2877,7 +2877,9 @@ function forwardActivity( metadata.version, ); return tracer.startActiveSpan( - "activitypub.outbox", + ctx.federation.outboxQueue == null || options?.immediate + ? `activitypub.${loggerCategory}` + : "activitypub.fanout", { kind: ctx.federation.outboxQueue == null || options?.immediate ? SpanKind.CLIENT From a65a6c343b8c377f13ddfbde146d28e2f80ed415 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 15:24:28 +0900 Subject: [PATCH 46/64] Support full RFC6570 identifier operators in mocks The @fedify/testing URI-template helpers were still narrower than the runtime builder and only handled simple and reserved identifier expansion. This teaches the mock helpers to support the full set of identifier operators Fedify accepts and adds regressions for the path, query, and dispatcher/listener compatibility cases. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 30 +++++++++++++++++++++++++ packages/testing/src/mock.ts | 37 ++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 3e1960f0f..6da35b594 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1259,6 +1259,36 @@ test("MockContext getOutboxUri supports reserved expansion", () => { ); }); +test("MockContext getOutboxUri supports path-segment expansion", () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/actors{/identifier}/outbox"); + + const context = mockFederation.createContext( + new URL("https://example.com"), + undefined, + ); + + assertEquals( + context.getOutboxUri("alice/profile").href, + "https://example.com/actors/alice/profile/outbox", + ); +}); + +test("MockContext getOutboxUri supports query expansion", () => { + const mockFederation = createFederation(); + mockFederation.setOutboxListeners("/actors/outbox{?identifier}"); + + const context = mockFederation.createContext( + new URL("https://example.com"), + undefined, + ); + + assertEquals( + context.getOutboxUri("alice/profile").href, + "https://example.com/actors/outbox?identifier=alice%2Fprofile", + ); +}); + test("MockContext reserved expansion encodes non-reserved characters", () => { const mockFederation = createFederation(); mockFederation.setOutboxListeners("/actors/{+identifier}/outbox"); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 36c0a1e2f..0fe37267e 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -57,8 +57,8 @@ const noopTracerProvider: any = { }; /** - * Helper function to expand URI templates with values. - * Supports simple placeholders like {identifier}, etc. + * Helper function to expand single-variable URI templates used by the mock. + * Supports the RFC 6570 operators accepted by Fedify's identifier paths. * @param template The URI template pattern * @param values The values to substitute * @returns The expanded URI path @@ -67,12 +67,33 @@ function expandUriTemplate( template: string, values: Record, ): string { - return template.replace(/{([^}]+)}/g, (match, key) => { - const reserved = key.startsWith("+"); - const normalizedKey = reserved ? key.slice(1) : key; - const value = values[normalizedKey]; + return template.replace(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g, ( + match, + operator, + key, + ) => { + const value = values[key]; if (value == null) return match; - return reserved ? encodeURI(value) : encodeURIComponent(value); + switch (operator) { + case "": + return encodeURIComponent(value); + case "+": + return encodeURI(value); + case "#": + return `#${encodeURI(value)}`; + case ".": + return `.${encodeURIComponent(value)}`; + case "/": + return `/${encodeURI(value)}`; + case ";": + return `;${key}=${encodeURIComponent(value)}`; + case "?": + return `?${key}=${encodeURIComponent(value)}`; + case "&": + return `&${key}=${encodeURIComponent(value)}`; + default: + return match; + } }); } @@ -86,7 +107,7 @@ function validateOutboxListenerPath( ); } const variables = globalThis.Array.from( - path.matchAll(/{(\+?)([A-Za-z_][A-Za-z0-9_]*)}/g), + path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g), (match) => match[2], ); if (variables.length !== 1 || variables[0] !== "identifier") { From 01f582f8424f41481e9827a1451c858e1ac1e51b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 16:12:44 +0900 Subject: [PATCH 47/64] Keep auth and telemetry handling accurate The latest review round reopened two runtime details that needed to stay correct together: body-aware outbox authorization must not consume the request that onUnauthorized() receives, and the outbox/inbox telemetry must report the right recipient attributes and span names. This keeps a fresh unauthorized request clone in the handler and adds focused regressions for the updated telemetry behavior. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/handler.test.ts | 35 ++++++- packages/fedify/src/federation/handler.ts | 3 +- .../fedify/src/federation/middleware.test.ts | 92 ++++++++++++++++++- packages/fedify/src/federation/middleware.ts | 4 +- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 3125d9815..3d5fed4b9 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -12,7 +12,7 @@ import { Tombstone, } from "@fedify/vocab"; import { FetchError } from "@fedify/vocab-runtime"; -import { assert, assertEquals } from "@std/assert"; +import { assert, assertEquals, assertInstanceOf } from "@std/assert"; import { parseAcceptSignature } from "../sig/accept.ts"; import { signRequest } from "../sig/http.ts"; import { @@ -1441,7 +1441,8 @@ test("handleOutbox()", async () => { onUnauthorized, }); assertEquals(onNotFoundCalled, null); - assertEquals(onUnauthorizedCalled, request); + assertInstanceOf(onUnauthorizedCalled, Request); + assertEquals(onUnauthorizedCalled === request, false); assertEquals(response.status, 401); assertEquals(seen, []); @@ -1493,6 +1494,36 @@ test("handleOutbox()", async () => { assertEquals(onUnauthorizedCalled, null); assertEquals([response.status, await response.text()], [202, ""]); + onUnauthorizedCalled = null; + ({ request, context } = createRequestContextPair()); + let unauthorizedBody: string | null = null; + response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + authorizePredicate: async (ctx) => { + await ctx.request.json(); + return false; + }, + onNotFound, + onUnauthorized: async (request) => { + onUnauthorizedCalled = request; + unauthorizedBody = await request.text(); + return new Response("Unauthorized", { status: 401 }); + }, + }); + assertInstanceOf(onUnauthorizedCalled, Request); + assertEquals((unauthorizedBody ?? "").includes('"type":"Create"'), true); + assertEquals([response.status, await response.text()], [401, "Unauthorized"]); + const invalidRequest = new Request( "https://example.com/users/someone/outbox", { diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 6e88bfe3c..31d4ef355 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -539,6 +539,7 @@ export async function handleOutbox( }); } const requestForParsing = request.clone(); + const requestForUnauthorized = request.clone() as Request; if (actorDispatcher == null) { logger.error("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); @@ -550,7 +551,7 @@ export async function handleOutbox( } if (authorizePredicate != null) { if (!await authorizePredicate(ctx, identifier)) { - return await onUnauthorized(request); + return await onUnauthorized(requestForUnauthorized); } } let json: unknown; diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index bbde3e07d..cdfa58a39 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1,4 +1,8 @@ -import { mockDocumentLoader, test } from "@fedify/fixture"; +import { + createTestTracerProvider, + mockDocumentLoader, + test, +} from "@fedify/fixture"; import { configure, type LogRecord, reset } from "@logtape/logtape"; import * as vocab from "@fedify/vocab"; import { getTypeId, lookupObject } from "@fedify/vocab"; @@ -3640,6 +3644,47 @@ test("ContextImpl.sendActivity()", async (t) => { ); }); + await t.step("records recipient span attributes correctly", async () => { + const [tracerProvider, exporter] = createTestTracerProvider(); + const federation3 = new FederationImpl({ + kv, + contextLoaderFactory: () => mockDocumentLoader, + tracerProvider, + }); + const ctx = federation3.createContext( + new URL("https://example.com/"), + undefined, + ); + const activity = new vocab.Create({ + id: new URL("https://example.com/activity/telemetry"), + actor: new URL("https://example.com/person"), + to: new URL("https://example.com/to"), + cc: new URL("https://example.com/cc"), + bto: new URL("https://example.com/bto"), + bcc: new URL("https://example.com/bcc"), + }); + + await ctx.sendActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity, + ); + + const span = exporter.getSpan("activitypub.outbox"); + assert(span != null); + assertEquals( + span.attributes["activitypub.activity.cc"], + ["https://example.com/cc"], + ); + assertEquals( + span.attributes["activitypub.activity.bcc"], + ["https://example.com/bcc"], + ); + }); + const queue: MessageQueue & { messages: Message[]; clear(): void } = { messages: [], enqueue(message) { @@ -4461,6 +4506,51 @@ test("InboxContextImpl.forwardActivity()", async (t) => { assertEquals(verified, []); }); + await t.step("records inbox forwarding span name", async () => { + const [tracerProvider, exporter] = createTestTracerProvider(); + const federationWithTracing = new FederationImpl({ + kv, + contextLoaderFactory: () => mockDocumentLoader, + tracerProvider, + }); + const activity = await signJsonLd( + { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://example.com/activity", + "actor": "https://example.com/person2", + }, + rsaPrivateKey3, + rsaPublicKey3.id!, + { contextLoader: mockDocumentLoader }, + ); + const ctx = new InboxContextImpl( + null, + activity, + "https://example.com/activity", + "https://www.w3.org/ns/activitystreams#Create", + { + data: undefined, + federation: federationWithTracing, + url: new URL("https://example.com/"), + documentLoader: documentLoader, + contextLoader: documentLoader, + }, + ); + + await ctx.forwardActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + { skipIfUnsigned: true }, + ); + + assertEquals(exporter.getSpans("activitypub.inbox").length, 1); + assertEquals(exporter.getSpans("activitypub.outbox").length, 0); + }); + fetchMock.hardReset(); }); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index daf4f9412..b8236480d 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3271,9 +3271,9 @@ export class OutboxContextImpl extends ContextImpl attributes: { "activitypub.activity.type": getTypeId(activity).href, "activitypub.activity.to": activity.toIds.map((to) => to.href), - "activitypub.activity.cc": activity.toIds.map((cc) => cc.href), + "activitypub.activity.cc": activity.ccIds.map((cc) => cc.href), "activitypub.activity.bto": activity.btoIds.map((bto) => bto.href), - "activitypub.activity.bcc": activity.toIds.map((bcc) => bcc.href), + "activitypub.activity.bcc": activity.bccIds.map((bcc) => bcc.href), }, }, async (span) => { From 6c649cfb3dfb697a1bc0fee854c9d1d7723fac21 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 16:35:41 +0900 Subject: [PATCH 48/64] Reject unroutable outbox identifier operators The outbox path surface had drifted into accepting RFC6570 operators that cannot ever match incoming POST /outbox requests because routing only looks at URL pathnames. This rejects query and fragment-style identifier operators in both the runtime builder and the testing mocks, and it keeps the mock path checks aligned with the same error semantics. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/builder.test.ts | 5 +++- packages/fedify/src/federation/builder.ts | 10 +++++++ packages/testing/src/mock.test.ts | 20 +++++-------- packages/testing/src/mock.ts | 29 ++++++++++++------- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index 62ffb322b..47e097f11 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -180,7 +180,10 @@ test("FederationBuilder", async (t) => { ); const builder3 = createFederationBuilder(); - builder3.setOutboxListeners("/users{?identifier}/outbox"); + assertThrows( + () => builder3.setOutboxListeners("/users{?identifier}/outbox"), + RouterError, + ); }); await t.step("should pass build options correctly", async () => { diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 2d1eebb1c..192bfa5eb 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -67,6 +67,16 @@ function validateSingleIdentifierVariablePath( path: string, errorMessage: string, ): void { + const operatorMatches = globalThis.Array.from( + path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g), + ); + if ( + operatorMatches.some((match) => + ["?", "&", "#"].includes(match[1]) && match[2] === "identifier" + ) + ) { + throw new RouterError(errorMessage); + } const variables = new Router().add(path, "outbox"); if (variables.size !== 1 || !variables.has("identifier")) { throw new RouterError(errorMessage); diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 6da35b594..2b9d1d486 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1017,7 +1017,7 @@ test("setOutboxListeners validates dispatcher path compatibility", () => { assertThrows( () => mockFederation.setOutboxListeners("/actors/{identifier}/outbox"), TypeError, - "Outbox listener path must match outbox dispatcher path.", + "Outbox listener path and outbox dispatcher path must match.", ); }); @@ -1034,7 +1034,7 @@ test("setOutboxDispatcher validates listener path compatibility", () => { items: [], })), TypeError, - "Outbox listener path must match outbox dispatcher path.", + "Outbox listener path and outbox dispatcher path must match.", ); }); @@ -1274,18 +1274,12 @@ test("MockContext getOutboxUri supports path-segment expansion", () => { ); }); -test("MockContext getOutboxUri supports query expansion", () => { +test("MockContext rejects query expansion for outbox paths", () => { const mockFederation = createFederation(); - mockFederation.setOutboxListeners("/actors/outbox{?identifier}"); - - const context = mockFederation.createContext( - new URL("https://example.com"), - undefined, - ); - - assertEquals( - context.getOutboxUri("alice/profile").href, - "https://example.com/actors/outbox?identifier=alice%2Fprofile", + assertThrows( + () => mockFederation.setOutboxListeners("/actors/outbox{?identifier}"), + TypeError, + "Path for outbox must have exactly one variable named identifier.", ); }); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 0fe37267e..90fb78df5 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -103,13 +103,22 @@ function validateOutboxListenerPath( ): void { if (dispatcherPath != null && dispatcherPath !== path) { throw new TypeError( - "Outbox listener path must match outbox dispatcher path.", + "Outbox listener path and outbox dispatcher path must match.", ); } - const variables = globalThis.Array.from( + const operatorMatches = globalThis.Array.from( path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g), - (match) => match[2], ); + if ( + operatorMatches.some((match) => + ["?", "&", "#"].includes(match[1]) && match[2] === "identifier" + ) + ) { + throw new TypeError( + "Path for outbox must have exactly one variable named identifier.", + ); + } + const variables = operatorMatches.map((match) => match[2]); if (variables.length !== 1 || variables[0] !== "identifier") { throw new TypeError( "Path for outbox must have exactly one variable named identifier.", @@ -659,14 +668,12 @@ class MockFederation implements Federation { if (listener == null) return; - if (listener != null) { - const context = createMockOutboxContext(); - try { - await listener(context, activity); - } catch (error) { - await this.outboxListenerErrorHandler?.(context, error); - throw error; - } + const context = createMockOutboxContext(); + try { + await listener(context, activity); + } catch (error) { + await this.outboxListenerErrorHandler?.(context, error); + throw error; } } From cf9afc949b6174fcddcd7a34168df47ffd1d1c0c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 16:37:57 +0900 Subject: [PATCH 49/64] Resolve hoisted outbox listener declarations in lint The outbox delivery lint rule was still analyzing .on(...) calls before later FunctionDeclaration bindings had been seen, which let hoisted listener declarations slip through without any delivery check. This defers reporting until Program:exit and adds a regression for that common declaration pattern. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../outbox-listener-delivery-required.ts | 66 +++++++++++-------- .../outbox-listener-delivery-required.test.ts | 21 ++++++ 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts index 6e1335787..e6305fb57 100644 --- a/packages/lint/src/rules/outbox-listener-delivery-required.ts +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -352,9 +352,43 @@ function createRule( return (context: Context) => { const federationTracker = trackFederationVariables(); const bindings = new Map(); + const pendingCalls: CallExpression[] = []; const sourceCode = (context as { sourceCode: { getText(node: unknown): string } }) .sourceCode; + + const inspectCall = (node: CallExpression): void => { + if ( + !hasMemberExpressionCallee(node) || + !hasIdentifierProperty(node) || + !hasMethodName("on")(node) || + node.arguments.length < 2 + ) { + return; + } + if ( + !isChainedFromOutboxListeners(node.callee.object, federationTracker) + ) { + return; + } + + const listener = node.arguments[1] as unknown; + const resolvedListener = + isNode(listener) && isFunction(listener as Expression) + ? listener as FunctionLikeNode + : isNode(listener) + ? resolveListenerReference(listener as Expression, bindings) + : null; + if (resolvedListener == null) return; + + if (listenerCallsDeliveryMethod(sourceCode, resolvedListener)) return; + + (context as { report: (arg: unknown) => void }).report({ + node: resolvedListener, + ...buildReport, + }); + }; + return { VariableDeclarator(node: VariableDeclarator): void { federationTracker.VariableDeclarator(node); @@ -373,35 +407,11 @@ function createRule( }, CallExpression(node: CallExpression): void { - if ( - !hasMemberExpressionCallee(node) || - !hasIdentifierProperty(node) || - !hasMethodName("on")(node) || - node.arguments.length < 2 - ) { - return; - } - if ( - !isChainedFromOutboxListeners(node.callee.object, federationTracker) - ) { - return; - } + pendingCalls.push(node); + }, - const listener = node.arguments[1] as unknown; - const resolvedListener = - isNode(listener) && isFunction(listener as Expression) - ? listener as FunctionLikeNode - : isNode(listener) - ? resolveListenerReference(listener as Expression, bindings) - : null; - if (resolvedListener == null) return; - - if (listenerCallsDeliveryMethod(sourceCode, resolvedListener)) return; - - (context as { report: (arg: unknown) => void }).report({ - node: resolvedListener, - ...buildReport, - }); + "Program:exit"(): void { + for (const node of pendingCalls) inspectCall(node); }, }; }; diff --git a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts index a9cd676fd..00533540a 100644 --- a/packages/lint/src/tests/outbox-listener-delivery-required.test.ts +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -283,6 +283,27 @@ federation }), ); +test( + `${ruleName}: ❌ Bad - hoisted function declaration without delivery`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, handleOutbox); + +function handleOutbox(ctx, activity) { + console.log(ctx.identifier, activity.id?.href); +} +`, + rule, + ruleName, + expectedError: + "Outbox listeners should deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); + test( `${ruleName}: ❌ Bad - comment mentioning delivery methods`, lintTest({ From edbf4a43b2c4521e6a4a545819ad925341038be9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 17:12:20 +0900 Subject: [PATCH 50/64] Encode path-segment identifiers like runtime The testing mock still expanded `{/identifier}` differently from the runtime router by leaving `/` unescaped in path-segment expansion. This switches that operator over to percent-encoding and updates the regression so mock-built outbox URLs now match Router.build(). https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 2 +- packages/testing/src/mock.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 2b9d1d486..e2dfee43a 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1255,7 +1255,7 @@ test("MockContext getOutboxUri supports reserved expansion", () => { assertEquals( context.getOutboxUri("alice/profile").href, - "https://example.com/actors/alice/profile/outbox", + "https://example.com/actors/alice%2Fprofile/outbox", ); }); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 90fb78df5..09bac6580 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -84,7 +84,7 @@ function expandUriTemplate( case ".": return `.${encodeURIComponent(value)}`; case "/": - return `/${encodeURI(value)}`; + return `/${encodeURIComponent(value)}`; case ";": return `;${key}=${encodeURIComponent(value)}`; case "?": From 564735844e6f6946db8f0e9b07104857463b2fc6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 18:14:42 +0900 Subject: [PATCH 51/64] Tighten outbox path and auth request handling The latest review round surfaced two small correctness gaps in the outbox listener path. First, the builder was only checking the set of variable names, so repeated {identifier} expressions could slip past the "one variable" rule. Second, the unauthorized-path request clones can be created only after actor lookup succeeds, while still preserving request.clone() semantics for body-aware authorization callbacks. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/builder.test.ts | 8 ++++++++ packages/fedify/src/federation/builder.ts | 6 ++++++ packages/fedify/src/federation/handler.ts | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index 47e097f11..e068a6a3c 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -157,6 +157,14 @@ test("FederationBuilder", async (t) => { RouterError, ); + assertThrows( + () => + builder.setOutboxListeners( + "/users/{identifier}/outbox/{identifier}", + ), + RouterError, + ); + const builderAfterInvalid = createFederationBuilder(); assertThrows( () => diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 192bfa5eb..8ee9182a7 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -70,6 +70,12 @@ function validateSingleIdentifierVariablePath( const operatorMatches = globalThis.Array.from( path.matchAll(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g), ); + if ( + operatorMatches.length !== 1 || + operatorMatches[0]?.[2] !== "identifier" + ) { + throw new RouterError(errorMessage); + } if ( operatorMatches.some((match) => ["?", "&", "#"].includes(match[1]) && match[2] === "identifier" diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 31d4ef355..e92be9e93 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -538,8 +538,6 @@ export async function handleOutbox( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } - const requestForParsing = request.clone(); - const requestForUnauthorized = request.clone() as Request; if (actorDispatcher == null) { logger.error("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); @@ -549,7 +547,9 @@ export async function handleOutbox( logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } + const requestForParsing = request.clone(); if (authorizePredicate != null) { + const requestForUnauthorized = request.clone() as Request; if (!await authorizePredicate(ctx, identifier)) { return await onUnauthorized(requestForUnauthorized); } From 7a75df566ad1099f7be2dc65788e1dafc4606d2a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 18:38:45 +0900 Subject: [PATCH 52/64] Correct mock URI-template expectation swaps The last remaining worktree change was just the swapped regression expectations for reserved and path-segment identifier expansion in the mock tests. This puts the assertions back in line with the runtime Router.build() behavior so the Deno test suite stays green. https://github.com/fedify-dev/fedify/pull/688 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index e2dfee43a..95a3aa09b 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1255,7 +1255,7 @@ test("MockContext getOutboxUri supports reserved expansion", () => { assertEquals( context.getOutboxUri("alice/profile").href, - "https://example.com/actors/alice%2Fprofile/outbox", + "https://example.com/actors/alice/profile/outbox", ); }); @@ -1270,7 +1270,7 @@ test("MockContext getOutboxUri supports path-segment expansion", () => { assertEquals( context.getOutboxUri("alice/profile").href, - "https://example.com/actors/alice/profile/outbox", + "https://example.com/actors/alice%2Fprofile/outbox", ); }); From bf2ab15ac37178694992d28b11dbfddae3bb5941 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 18:49:18 +0900 Subject: [PATCH 53/64] Return consistent 202 responses from handleOutbox The outbox handler was still returning bare 202 responses without the plain-text empty body used by the rest of the federation handlers. This makes both the successful listener path and the unsupported-activity path return an explicit empty text response and adds regression coverage for the header shape. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/handler.test.ts | 8 ++++++++ packages/fedify/src/federation/handler.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 3d5fed4b9..dcaf5d335 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1466,6 +1466,10 @@ test("handleOutbox()", async () => { }); assertEquals(onUnauthorizedCalled, null); assertEquals([response.status, await response.text()], [202, ""]); + assertEquals( + response.headers.get("content-type"), + "text/plain; charset=utf-8", + ); assertEquals(seen, [ `someone:${activity.id?.href}`, ]); @@ -1493,6 +1497,10 @@ test("handleOutbox()", async () => { }); assertEquals(onUnauthorizedCalled, null); assertEquals([response.status, await response.text()], [202, ""]); + assertEquals( + response.headers.get("content-type"), + "text/plain; charset=utf-8", + ); onUnauthorizedCalled = null; ({ request, context } = createRequestContextPair()); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index e92be9e93..fa6294d23 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -674,7 +674,10 @@ export async function handleOutbox( activityId: activity.id?.href, activityType: getTypeId(activity).href, }); - return new Response(null, { status: 202 }); + return new Response("", { + status: 202, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); } try { await dispatched.listener(outboxContext, activity); @@ -728,7 +731,10 @@ export async function handleOutbox( identifier, }, ); - return new Response(null, { status: 202 }); + return new Response("", { + status: 202, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); } /** From 5ab29764f5d9a3d2096a9f8de3093f51d08e6813 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 18:51:45 +0900 Subject: [PATCH 54/64] Recognize expanded proof keys in quick checks The structural proof detector used by the forwarding path still only looked under the compact `proof` key. Expanded JSON-LD can surface the same value under the full security vocabulary IRI, so this now checks both forms and adds a regression for that expanded representation. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/sig/proof.test.ts | 8 ++++++++ packages/fedify/src/sig/proof.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 840c2e4ca..5b91dc5db 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -307,6 +307,14 @@ test("hasProofLike()", () => { proofValue: "signature", }, })); + assert(hasProofLike({ + "https://w3id.org/security#proof": { + type: "DataIntegrityProof", + verificationMethod: { "@id": "https://example.com/users/alice#main-key" }, + proofPurpose: { "@id": "https://w3id.org/security#assertionMethod" }, + proofValue: "signature", + }, + })); assertFalse(hasProofLike({ proof: { type: "DataIntegrityProof", diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 574c0f211..8d469ef36 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -30,7 +30,7 @@ const logger = getLogger(["fedify", "sig", "proof"]); export function hasProofLike(jsonLd: unknown): boolean { if (typeof jsonLd !== "object" || jsonLd == null) return false; const record = jsonLd as Record; - const proof = record.proof; + const proof = record.proof ?? record["https://w3id.org/security#proof"]; const isReference = (value: unknown): boolean => { if (typeof value === "string") return true; From 49400972c2cd07555bb45d04cc9697fc111e5fed Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 18:52:02 +0900 Subject: [PATCH 55/64] Clarify mock outbox helper semantics The testing mock had one last reviewer-facing mismatch in its own surface area: the URI-template helper comment implied a single-variable limitation it did not actually have, and the outbox validation error did not explain that query and fragment expansion for identifier are what is being rejected. This aligns the docs and test expectations with the real behavior. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 2 +- packages/testing/src/mock.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 95a3aa09b..47cf53dd9 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1279,7 +1279,7 @@ test("MockContext rejects query expansion for outbox paths", () => { assertThrows( () => mockFederation.setOutboxListeners("/actors/outbox{?identifier}"), TypeError, - "Path for outbox must have exactly one variable named identifier.", + "Path for outbox cannot use query or fragment expansion for identifier.", ); }); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 09bac6580..438c94e77 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -57,7 +57,7 @@ const noopTracerProvider: any = { }; /** - * Helper function to expand single-variable URI templates used by the mock. + * Helper function to expand URI templates used by the mock. * Supports the RFC 6570 operators accepted by Fedify's identifier paths. * @param template The URI template pattern * @param values The values to substitute @@ -115,7 +115,7 @@ function validateOutboxListenerPath( ) ) { throw new TypeError( - "Path for outbox must have exactly one variable named identifier.", + "Path for outbox cannot use query or fragment expansion for identifier.", ); } const variables = operatorMatches.map((match) => match[2]); From 15c5ff23bdecba24c01f5bed8dc0fbf8721d0d94 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 20:09:53 +0900 Subject: [PATCH 56/64] Defer outbox request clones until they matter The outbox handler only needs a cloned Request after actor lookup succeeds, and only needs a second body-preserving clone at all when an authorization callback is configured. This moves the clones down to the point where they are actually needed while keeping the existing request.clone() behavior for body-aware authorization callbacks. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/handler.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index fa6294d23..a9503fc9c 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -547,11 +547,12 @@ export async function handleOutbox( logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } - const requestForParsing = request.clone(); + const requestForParsing = authorizePredicate == null + ? request + : request.clone(); if (authorizePredicate != null) { - const requestForUnauthorized = request.clone() as Request; if (!await authorizePredicate(ctx, identifier)) { - return await onUnauthorized(requestForUnauthorized); + return await onUnauthorized(requestForParsing as Request); } } let json: unknown; From 0020046fa422c65247e4a7a686c915860ed3493f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 20:10:15 +0900 Subject: [PATCH 57/64] Recognize expanded proof fields in quick checks The structural proof detector used by forwarded delivery warnings still expected compact property names inside Data Integrity proofs. Expanded JSON-LD can surface those fields under their full security vocabulary IRIs, so this teaches hasProofLike() to read both forms and adds a regression for the expanded representation. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/sig/proof.test.ts | 12 +++++++++++ packages/fedify/src/sig/proof.ts | 29 ++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 5b91dc5db..7705b0763 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -315,6 +315,18 @@ test("hasProofLike()", () => { proofValue: "signature", }, })); + assert(hasProofLike({ + "https://w3id.org/security#proof": [{ + "@type": ["https://w3id.org/security#DataIntegrityProof"], + "https://w3id.org/security#verificationMethod": [{ + "@id": "https://example.com/users/alice#main-key", + }], + "https://w3id.org/security#proofPurpose": [{ + "@id": "https://w3id.org/security#assertionMethod", + }], + "https://w3id.org/security#proofValue": [{ "@value": "signature" }], + }], + })); assertFalse(hasProofLike({ proof: { type: "DataIntegrityProof", diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 8d469ef36..6734becf7 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -32,12 +32,19 @@ export function hasProofLike(jsonLd: unknown): boolean { const record = jsonLd as Record; const proof = record.proof ?? record["https://w3id.org/security#proof"]; + const getField = ( + source: Record, + compact: string, + expanded: string, + ): unknown => source[compact] ?? source[expanded]; + const isReference = (value: unknown): boolean => { if (typeof value === "string") return true; if (Array.isArray(value)) return value.some(isReference); return typeof value === "object" && value != null && (("id" in value && typeof value.id === "string") || - ("@id" in value && typeof value["@id"] === "string")); + ("@id" in value && typeof value["@id"] === "string") || + ("@value" in value && typeof value["@value"] === "string")); }; const hasType = (value: unknown): boolean => { @@ -52,10 +59,22 @@ export function hasProofLike(jsonLd: unknown): boolean { const isProofLike = (value: unknown): boolean => { if (typeof value !== "object" || value == null) return false; const proofRecord = value as Record; - return hasType(proofRecord.type) && - isReference(proofRecord.verificationMethod) && - isReference(proofRecord.proofPurpose) && - typeof proofRecord.proofValue === "string"; + return hasType(proofRecord.type ?? proofRecord["@type"]) && + isReference(getField( + proofRecord, + "verificationMethod", + "https://w3id.org/security#verificationMethod", + )) && + isReference(getField( + proofRecord, + "proofPurpose", + "https://w3id.org/security#proofPurpose", + )) && + isReference(getField( + proofRecord, + "proofValue", + "https://w3id.org/security#proofValue", + )); }; return Array.isArray(proof) ? proof.some(isProofLike) : isProofLike(proof); From 9b0ea19a2634e763c1e617ac6ab1b6d407e900a5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 20:39:40 +0900 Subject: [PATCH 58/64] Make outbox delivery tracking part of the contract The outbox warning path should not rely on an implementation-only property. This adds hasDeliveredActivity() to OutboxContext itself, threads a default implementation through the testing helpers, and keeps summarizeJsonActivity() explicitly narrowed through Record so the handler remains type-safe. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/context.ts | 9 +++++++++ packages/fedify/src/federation/handler.ts | 13 ++++--------- packages/fedify/src/testing/context.ts | 1 + packages/testing/src/context.ts | 1 + 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index a71e366e6..630bf1edd 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -698,6 +698,15 @@ export interface OutboxContext extends Context { */ readonly identifier: string; + /** + * Indicates whether the posted activity has been delivered during the + * current outbox listener invocation. + * @returns `true` if the posted activity has been delivered; `false` + * otherwise. + * @since 2.2.0 + */ + hasDeliveredActivity(): boolean; + /** * Forwards a posted activity to the recipients' inboxes without * re-serializing the original payload. The forwarded activity will be diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index a9503fc9c..9d01d975b 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -495,10 +495,9 @@ function summarizeJsonActivity(json: unknown): { activityType?: string; } { if (json == null || typeof json !== "object") return {}; - const id = "id" in json && typeof json.id === "string" ? json.id : undefined; - const type = "type" in json && typeof json.type === "string" - ? json.type - : undefined; + const activity = json as Record; + const id = typeof activity.id === "string" ? activity.id : undefined; + const type = typeof activity.type === "string" ? activity.type : undefined; return { activityId: id, activityType: type }; } @@ -710,11 +709,7 @@ export async function handleOutbox( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } - if ( - "hasDeliveredActivity" in outboxContext && - typeof outboxContext.hasDeliveredActivity === "function" && - !outboxContext.hasDeliveredActivity() - ) { + if (!outboxContext.hasDeliveredActivity()) { logger.warn( "Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery.", { diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index 19d1f862f..e84dee5fa 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -171,6 +171,7 @@ export function createOutboxContext( ...createContext(args), clone: args.clone ?? ((data) => createOutboxContext({ ...args, data })), identifier: args.identifier, + hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false), forwardActivity, }; } diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index c203b2241..7721577fe 100644 --- a/packages/testing/src/context.ts +++ b/packages/testing/src/context.ts @@ -237,6 +237,7 @@ function createOutboxContext( clone: args.clone ?? ((data: TContextData) => createOutboxContext({ ...args, data })), identifier: args.identifier, + hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false), forwardActivity, }; } From 87cfb98a7fad6877370f29a6a0f7a60c21b53814 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 20:40:02 +0900 Subject: [PATCH 59/64] Accept array-typed signature kinds in quick checks Linked Data Signature payloads can expose `signature.type` as either a string or an array of strings after compaction and expansion. The quick signature detector now accepts both forms so skipIfUnsigned does not misclassify signed payloads, and a regression covers the array case. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/sig/ld.test.ts | 7 +++++++ packages/fedify/src/sig/ld.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/sig/ld.test.ts b/packages/fedify/src/sig/ld.test.ts index d75f07f28..b6db88171 100644 --- a/packages/fedify/src/sig/ld.test.ts +++ b/packages/fedify/src/sig/ld.test.ts @@ -141,6 +141,13 @@ test("hasSignatureLike()", () => { jws: "signature", }], })); + assert(hasSignatureLike({ + signature: { + type: ["Ed25519Signature2020"], + verificationMethod: "https://example.com/users/alice#main-key", + jws: "signature", + }, + })); assertFalse(hasSignatureLike({ signature: { type: "Ed25519Signature2020", diff --git a/packages/fedify/src/sig/ld.ts b/packages/fedify/src/sig/ld.ts index f77b22bf0..3a6c01554 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -200,7 +200,10 @@ export function hasSignatureLike(jsonLd: unknown): boolean { const hasSignatureObject = (value: unknown): boolean => { if (typeof value !== "object" || value == null) return false; const signatureRecord = value as Record; - return typeof signatureRecord.type === "string" && + const hasType = typeof signatureRecord.type === "string" || + (Array.isArray(signatureRecord.type) && + signatureRecord.type.some((item) => typeof item === "string")); + return hasType && (hasReference(signatureRecord.creator) || hasReference(signatureRecord.verificationMethod)) && (typeof signatureRecord.signatureValue === "string" || From 7a98a337055faf7d8c146866928750fb9de05b6d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 20:40:19 +0900 Subject: [PATCH 60/64] Match outbox dispatcher path validation to listeners Outbox listeners already reject query and fragment identifier operators because the router only matches URL pathnames. This applies the same validation to setOutboxDispatcher() so both entry points enforce the same routable pathname-only subset, with a regression for the query case. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/builder.test.ts | 10 ++++++++++ packages/fedify/src/federation/builder.ts | 14 +++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index e068a6a3c..b5d8a1a99 100644 --- a/packages/fedify/src/federation/builder.test.ts +++ b/packages/fedify/src/federation/builder.test.ts @@ -192,6 +192,16 @@ test("FederationBuilder", async (t) => { () => builder3.setOutboxListeners("/users{?identifier}/outbox"), RouterError, ); + + const builder4 = createFederationBuilder(); + assertThrows( + () => + builder4.setOutboxDispatcher( + "/users{?identifier}/outbox", + () => ({ items: [] }), + ), + RouterError, + ); }); await t.step("should pass build options correctly", async () => { diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 8ee9182a7..cb1253604 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -763,15 +763,11 @@ export class FederationBuilderImpl ); } } else { - const variables = this.router.add(path, "outbox"); - if ( - variables.size !== 1 || - !variables.has("identifier") - ) { - throw new RouterError( - "Path for outbox dispatcher must have one variable: {identifier}", - ); - } + validateSingleIdentifierVariablePath( + path, + "Path for outbox dispatcher must have one variable: {identifier}", + ); + this.router.add(path, "outbox"); this.outboxPath = path; } const callbacks: CollectionCallbacks< From a6e3de857fcf65e5b4cbb94446f734ea49dfa316 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 21:19:30 +0900 Subject: [PATCH 61/64] Tighten mock outbox path and delivery parity The remaining @fedify/testing gaps were both in the mock POST /outbox context. This makes outbox path validation reject non-absolute templates like the runtime router does, and it wires sendActivity() and forwardActivity() into the mock outbox delivery state so tests see the same hasDeliveredActivity() behavior as runtime. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 68 +++++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 23 ++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 47cf53dd9..41920ffe7 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1005,6 +1005,37 @@ test("setOutboxListeners rejects duplicate registration", () => { ); }); +test("setOutboxListeners requires a leading slash", () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + assertThrows( + () => + mockFederation.setOutboxListeners( + "users/{identifier}/outbox" as `${string}{identifier}${string}`, + ), + TypeError, + "Path must start with a slash.", + ); +}); + +test("setOutboxDispatcher requires a leading slash", () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + assertThrows( + () => + mockFederation.setOutboxDispatcher( + "users/{identifier}/outbox", + () => ({ items: [] }), + ), + TypeError, + "Path must start with a slash.", + ); +}); + test("setOutboxListeners validates dispatcher path compatibility", () => { const mockFederation = createFederation<{ test: string }>({ contextData: { test: "data" }, @@ -1060,6 +1091,43 @@ test("setOutboxListeners validates path variables", () => { ); }); +test("mock outbox context tracks delivery state", async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + const deliveryStates: boolean[] = []; + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Create, async (ctx, activity) => { + deliveryStates.push(ctx.hasDeliveredActivity()); + await ctx.sendActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + activity, + ); + deliveryStates.push(ctx.hasDeliveredActivity()); + }); + + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(deliveryStates, [false, true]); +}); + test("createOutboxContext exposes identifier", () => { const mockFederation = createFederation(); const ctx = createOutboxContext({ diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 438c94e77..cfb231353 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -101,6 +101,9 @@ function validateOutboxListenerPath( path: string, dispatcherPath?: string, ): void { + if (!path.startsWith("/")) { + throw new TypeError("Path must start with a slash."); + } if (dispatcherPath != null && dispatcherPath !== path) { throw new TypeError( "Outbox listener path and outbox dispatcher path must match.", @@ -609,13 +612,28 @@ class MockFederation implements Federation { this.contextData as TContextData, ); const rawActivity = postedJson; + const deliveryState = { delivered: false }; const createMockOutboxContext = () => createOutboxContext({ ...baseContext, clone: undefined, federation: this as any, identifier, - sendActivity: baseContext.sendActivity.bind(baseContext), + hasDeliveredActivity: () => deliveryState.delivered, + sendActivity: async ( + sender: any, + recipients: any, + outboundActivity: Activity, + options?: any, + ) => { + await baseContext.sendActivity( + sender, + recipients, + outboundActivity, + options, + ); + deliveryState.delivered = true; + }, forwardActivity: async ( forwarder: any, recipients: any, @@ -626,12 +644,13 @@ class MockFederation implements Federation { if (options?.skipIfUnsigned && !hasProof && !hasLds) { return; } - return baseContext.sendActivity( + await baseContext.sendActivity( forwarder, recipients, activity, { ...options, rawActivity }, ); + deliveryState.delivered = true; }, }); From 238154d2091a083fbdfb2c5e73927795d57b9953 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 21:52:55 +0900 Subject: [PATCH 62/64] Authorize outbox posts before actor lookup The remaining outbox handler review comments were all about request and queue control flow. Authorization now runs before actor resolution so write endpoints do not leak account existence, body-aware auth still gets a readable Request clone, and the queue enqueue branch has been flattened to the simpler equivalent structure the review suggested. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- .../fedify/src/federation/handler.test.ts | 27 ++++++++++++ packages/fedify/src/federation/handler.ts | 21 ++++++---- packages/fedify/src/federation/middleware.ts | 41 ++++--------------- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index dcaf5d335..9bf81cc48 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1446,6 +1446,29 @@ test("handleOutbox()", async () => { assertEquals(response.status, 401); assertEquals(seen, []); + onNotFoundCalled = null; + onUnauthorizedCalled = null; + ({ request, context } = createRequestContextPair()); + response = await handleOutbox(request, { + identifier: "someone", + context, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...context, + clone: undefined, + identifier, + }); + }, + actorDispatcher: () => null, + outboxListeners: listeners, + authorizePredicate: () => false, + onNotFound, + onUnauthorized, + }); + assertEquals(onNotFoundCalled, null); + assertInstanceOf(onUnauthorizedCalled, Request); + assertEquals(response.status, 401); + onUnauthorizedCalled = null; ({ request, context } = createRequestContextPair()); response = await handleOutbox(request, { @@ -1501,6 +1524,10 @@ test("handleOutbox()", async () => { response.headers.get("content-type"), "text/plain; charset=utf-8", ); + assertEquals(seen, [ + `someone:${activity.id?.href}`, + `someone:${activity.id?.href}`, + ]); onUnauthorizedCalled = null; ({ request, context } = createRequestContextPair()); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 9d01d975b..e407c18f9 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -541,19 +541,24 @@ export async function handleOutbox( logger.error("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); } + if (authorizePredicate != null) { + const authorizeContext = ctx.clone(ctx.data) as + & RequestContext + & { + request: Request; + }; + authorizeContext.request = request.clone() as Request; + const requestForUnauthorized = authorizeContext.request.clone() as Request; + if (!await authorizePredicate(authorizeContext, identifier)) { + return await onUnauthorized(requestForUnauthorized); + } + } const actor = await actorDispatcher(ctx, identifier); if (actor == null || actor instanceof Tombstone) { logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } - const requestForParsing = authorizePredicate == null - ? request - : request.clone(); - if (authorizePredicate != null) { - if (!await authorizePredicate(ctx, identifier)) { - return await onUnauthorized(requestForParsing as Request); - } - } + const requestForParsing = request.clone(); let json: unknown; try { json = await requestForParsing.json(); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index b8236480d..933660558 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3089,7 +3089,7 @@ async function forwardActivityInternal( }); } const { outboxQueue } = ctx.federation; - if (outboxQueue.enqueueMany == null) { + if (outboxQueue.enqueueMany == null || orderingKey != null) { const promises: Promise[] = messages.map((m) => outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }) ); @@ -3111,39 +3111,14 @@ async function forwardActivityInternal( throw errors[0]; } } else { - // Note: enqueueMany does not support per-message orderingKey, - // so we fall back to individual enqueues when orderingKey is specified - if (orderingKey != null) { - const promises: Promise[] = messages.map((m) => - outboxQueue.enqueue(m.message, { orderingKey: m.orderingKey }) + try { + await outboxQueue.enqueueMany(messages.map((m) => m.message)); + } catch (error) { + logger.error( + "Failed to enqueue activity {activityId} to forward later:\n{error}", + { activityId: ctx.activityId, error }, ); - const results = await Promise.allSettled(promises); - const errors = results - .filter((r) => r.status === "rejected") - .map((r) => (r as PromiseRejectedResult).reason); - if (errors.length > 0) { - logger.error( - "Failed to enqueue activity {activityId} to forward later:\n{errors}", - { activityId: ctx.activityId, errors }, - ); - if (errors.length > 1) { - throw new AggregateError( - errors, - `Failed to enqueue activity ${ctx.activityId} to forward later.`, - ); - } - throw errors[0]; - } - } else { - try { - await outboxQueue.enqueueMany(messages.map((m) => m.message)); - } catch (error) { - logger.error( - "Failed to enqueue activity {activityId} to forward later:\n{error}", - { activityId: ctx.activityId, error }, - ); - throw error; - } + throw error; } } return true; From d1dff226325f74d6a36fc3680abb7a6716eeea49 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 18 Apr 2026 21:53:51 +0900 Subject: [PATCH 63/64] Use raw proof shapes in mock outbox forwarding The mock outbox helper was still checking skipIfUnsigned against the parsed Activity instance instead of the raw posted JSON-LD. That could diverge from runtime when proof information only survived in the raw payload shape, so the mock now uses hasProofLike(rawActivity) and adds an expanded-proof regression for that path. https://github.com/fedify-dev/fedify/issues/430 Assisted-by: OpenCode:gpt-5.4 --- packages/testing/src/mock.test.ts | 58 +++++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 4 +-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 41920ffe7..cdd911d84 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -338,6 +338,64 @@ test( }, ); +test( + "postOutboxActivity forwardActivity treats expanded proof payloads as signed", + async () => { + const mockFederation = createFederation<{ test: string }>({ + contextData: { test: "data" }, + }); + + mockFederation.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + mockFederation + .setOutboxListeners("/users/{identifier}/outbox") + .on( + Create, + async (ctx: OutboxContext<{ test: string }>) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + new Person({ id: new URL("https://example.com/users/bob") }), + { skipIfUnsigned: true }, + ); + }, + ); + + const proofJson = { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/activities/1", + type: "Create", + actor: "https://example.com/users/alice", + "https://w3id.org/security#proof": { + "@type": ["https://w3id.org/security#DataIntegrityProof"], + "https://w3id.org/security#verificationMethod": [{ + "@id": "https://example.com/users/alice#main-key", + }], + "https://w3id.org/security#proofPurpose": [{ + "@id": "https://w3id.org/security#assertionMethod", + }], + "https://w3id.org/security#proofValue": [{ "@value": "signature" }], + }, + }; + const activity = await Activity.fromJsonLd(proofJson, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + + await mockFederation.postOutboxActivity("alice", activity); + + assertEquals(mockFederation.sentActivities.length, 1); + assertEquals(mockFederation.sentActivities[0].activity, activity); + assertEquals(mockFederation.sentActivities[0].rawActivity, proofJson); + }, +); + test( "postOutboxActivity forwardActivity skips malformed linked data signatures", async () => { diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index cfb231353..c618cd0a2 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -10,7 +10,7 @@ import type { RequestContext, RouteActivityOptions, } from "@fedify/fedify/federation"; -import { hasSignatureLike } from "@fedify/fedify/sig"; +import { hasProofLike, hasSignatureLike } from "@fedify/fedify/sig"; import { Activity, CryptographicKey, Multikey } from "@fedify/vocab"; import type { Collection, @@ -639,7 +639,7 @@ class MockFederation implements Federation { recipients: any, options?: any, ) => { - const hasProof = await activity.getProof() != null; + const hasProof = hasProofLike(rawActivity); const hasLds = hasSignatureLike(rawActivity); if (options?.skipIfUnsigned && !hasProof && !hasLds) { return; From d4cb2d863f1f3b1252ac035d5051c3abf360c874 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 19 Apr 2026 00:07:56 +0900 Subject: [PATCH 64/64] Fix stale outbox test expectations The current staged changes are both follow-ups to earlier outbox review fixes. One middleware regression now expects the 401 returned by auth-before-actor handling, and the mock proof-shape regression uses a normal Create instance with an overridden raw JSON-LD view instead of trying to deserialize an intentionally shape-only proof payload. https://github.com/fedify-dev/fedify/pull/688 Assisted-by: OpenCode:gpt-5.4 --- packages/fedify/src/federation/middleware.test.ts | 2 +- packages/testing/src/mock.test.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index cdfa58a39..efe9ea38a 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -2171,7 +2171,7 @@ test("Federation.setOutboxListeners()", async (t) => { }), { contextData: undefined }, ); - assertEquals(response.status, 404); + assertEquals(response.status, 401); }); await t.step("POST without listeners returns 405", async () => { diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index cdd911d84..9f2fcd85e 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -383,9 +383,12 @@ test( "https://w3id.org/security#proofValue": [{ "@value": "signature" }], }, }; - const activity = await Activity.fromJsonLd(proofJson, { - documentLoader: mockDocumentLoader, - contextLoader: mockDocumentLoader, + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + }); + Object.assign(activity, { + toJsonLd: () => Promise.resolve(proofJson), }); await mockFederation.postOutboxActivity("alice", activity);