diff --git a/CHANGES.md b/CHANGES.md index 0dae660bc..ed3812d9f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,15 @@ 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 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. Fedify now serves those actor URIs as `410 Gone` with the serialized tombstone body, and the corresponding WebFinger lookups also return @@ -24,8 +33,24 @@ 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 +[#688]: https://github.com/fedify-dev/fedify/pull/688 + +### @fedify/lint + + - Added the `outbox-listener-delivery-required` rule. It warns when an + outbox listener registered through `setOutboxListeners()` returns without an + 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 using either + `sendActivity()` or `forwardActivity()` can be tested without spinning up + a live federation server. [[#430], [#688]] ### @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..3fcb06407 100644 --- a/docs/manual/access-control.md +++ b/docs/manual/access-control.md @@ -102,6 +102,32 @@ 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; +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 session = await verifyAccessToken( + ctx.request.headers.get("authorization"), + ); + return session?.identifier === 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..abad8d0e1 100644 --- a/docs/manual/collections.md +++ b/docs/manual/collections.md @@ -43,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/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..2772ebb98 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -597,6 +597,55 @@ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => { }); ~~~~ +### `outbox-listener-delivery-required` + +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 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 choose an +explicit delivery path. + +~~~~ 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 }, + "followers", + activity, + ); + }); + +// ✅ Good: Listener forwards the original posted payload explicitly +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx) => { + await ctx.forwardActivity( + { identifier: ctx.identifier }, + "followers", + ); + }); +~~~~ + ### `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..ef93a084b --- /dev/null +++ b/docs/manual/outbox.md @@ -0,0 +1,203 @@ +--- +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-outbox] guide. + +[collections-outbox]: ./collections.md#outbox + + +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: + +~~~~ 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 verifyAccessToken( + authorization: string | null, +): Promise<{ identifier: string } | null> { + authorization; + return null; +} +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, + ); + }) + .authorize(async (ctx, identifier) => { + const session = await verifyAccessToken( + ctx.request.headers.get("authorization"), + ); + return session?.identifier === 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`. + +Fedify also rejects a posted activity if its `actor` does not match the local +actor who owns the addressed outbox. + +> [!TIP] +> 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 +> `{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-guide] for details. + +[uri-template-guide]: ./uri-template.md + + +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()` or +`~OutboxContext.forwardActivity()` 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 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*][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 +--------------- + +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()` or + `ctx.forwardActivity()` + +If you need full `GET /outbox` support as well, combine this guide with the +[*Collections*][collections-outbox] guide. diff --git a/docs/manual/test.md b/docs/manual/test.md index 22d0ef06e..663744a9f 100644 --- a/docs/manual/test.md +++ b/docs/manual/test.md @@ -281,6 +281,65 @@ 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 +~~~~ + +If you prefer to exercise registered outbox listeners through the mock +federation, use `postOutboxActivity()`: + +~~~~ typescript twoslash +import { createFederation } from "@fedify/testing"; +import { Create, Person } from "@fedify/vocab"; + +const federation = createFederation<{ userId: string }>({ + contextData: { userId: "test-user" }, +}); + +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, 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({ + 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 Mock contexts created with the `createContext()` method provide mock 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 + + + diff --git a/packages/fedify/src/federation/activity-listener.ts b/packages/fedify/src/federation/activity-listener.ts new file mode 100644 index 000000000..ced22b4d6 --- /dev/null +++ b/packages/fedify/src/federation/activity-listener.ts @@ -0,0 +1,58 @@ +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 (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 }; + } + + dispatch( + activity: TActivity, + ): ActivityListener | null { + return this.dispatchWithClass(activity)?.listener ?? null; + } +} diff --git a/packages/fedify/src/federation/builder.test.ts b/packages/fedify/src/federation/builder.test.ts index fbd9756c0..b5d8a1a99 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,78 @@ 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, + ); + + assertThrows( + () => + builder.setOutboxListeners( + "/users/{identifier}/outbox/{identifier}", + ), + 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"); + + assertThrows( + () => + builder2.setOutboxDispatcher( + "/actors/{identifier}/outbox", + () => ({ items: [] }), + ), + RouterError, + ); + + const builder3 = createFederationBuilder(); + assertThrows( + () => 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 () => { 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..cb1253604 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -28,12 +28,20 @@ import type { NodeInfoDispatcher, ObjectAuthorizePredicate, ObjectDispatcher, + OutboxListener, + OutboxListenerErrorHandler, OutboxPermanentFailureHandler, SharedInboxKeyDispatcher, 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, @@ -46,15 +54,41 @@ import type { IdempotencyStrategy, InboxListenerSetters, ObjectCallbackSetters, + OutboxListenerSetters, Rfc6570Expression, } from "./federation.ts"; import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; -import { InboxListenerSet } from "./inbox.ts"; import { Router, RouterError } from "./router.ts"; +function validateSingleIdentifierVariablePath( + path: string, + errorMessage: string, +): void { + 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" + ) + ) { + throw new RouterError(errorMessage); + } + const variables = new Router().add(path, "outbox"); + if (variables.size !== 1 || !variables.has("identifier")) { + throw new RouterError(errorMessage); + } +} + export class FederationBuilderImpl implements FederationBuilder { router: Router; @@ -64,6 +98,7 @@ export class FederationBuilderImpl objectCallbacks: Record>; objectTypeIds: Record>; inboxPath?: string; + outboxPath?: string; inboxCallbacks?: CollectionCallbacks< Activity, RequestContext, @@ -106,8 +141,11 @@ export class FederationBuilderImpl TContextData, void >; - inboxListeners?: InboxListenerSet; + inboxListeners?: ActivityListenerSet>; + outboxListeners?: ActivityListenerSet>; inboxErrorHandler?: InboxErrorHandler; + outboxListenerErrorHandler?: OutboxListenerErrorHandler; + outboxAuthorizePredicate?: AuthorizePredicate; sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher; unverifiedActivityHandler?: UnverifiedActivityHandler; outboxPermanentFailureHandler?: OutboxPermanentFailureHandler; @@ -166,6 +204,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 +227,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 +753,22 @@ 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( + if (this.router.has("outbox")) { + if (this.outboxPath !== path) { + throw new RouterError( + "Outbox dispatcher path must match outbox listener path.", + ); + } + } else { + validateSingleIdentifierVariablePath( + path, "Path for outbox dispatcher must have one variable: {identifier}", ); + this.router.add(path, "outbox"); + this.outboxPath = path; } const callbacks: CollectionCallbacks< Activity, @@ -767,6 +814,54 @@ export class FederationBuilderImpl return setters; } + setOutboxListeners( + outboxPath: `${string}${Rfc6570Expression<"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 { + validateSingleIdentifierVariablePath( + outboxPath, + "Path for outbox must have one variable: {identifier}", + ); + this.router.add(outboxPath, "outbox"); + this.outboxPath = outboxPath; + } + const listeners = this.outboxListeners = new ActivityListenerSet< + OutboxContext + >(); + 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< @@ -1138,7 +1233,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/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..630bf1edd 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -687,6 +687,82 @@ 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; + + /** + * 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 + * 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 + * 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. + * @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 + * 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. + * @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. + * @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..6dad929aa 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,35 @@ 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}`. 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 + */ + 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 +1224,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..9bf81cc48 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, @@ -12,11 +12,12 @@ 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 { createInboxContext, + createOutboxContext, createRequestContext, } from "../testing/context.ts"; import { @@ -34,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, @@ -43,10 +44,11 @@ import { handleCustomCollection, handleInbox, handleObject, + handleOutbox, 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"; @@ -1332,6 +1334,402 @@ 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/users/someone"), + object: new Note({ + id: new URL("https://example.com/notes/1"), + attribution: new URL("https://example.com/users/someone"), + content: "Hello, world!", + }), + }); + const requestUrl = "https://example.com/users/someone/outbox"; + const requestBody = JSON.stringify(await activity.toJsonLd()); + const federation = createFederation({ kv: new MemoryKvStore() }); + 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; + 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({ id: ctx.getActorUri(identifier), name: "Someone" }); + }; + const listeners = new ActivityListenerSet>(); + const seen: string[] = []; + listeners.add(Activity, (ctx, activity) => { + seen.push(`${ctx.identifier}:${activity.id?.href}`); + }); + + let { request, context } = createRequestContextPair(); + 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; + ({ request, context } = createRequestContextPair()); + 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; + ({ request, context } = createRequestContextPair()); + 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); + assertInstanceOf(onUnauthorizedCalled, Request); + assertEquals(onUnauthorizedCalled === request, false); + 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, { + 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( + response.headers.get("content-type"), + "text/plain; charset=utf-8", + ); + assertEquals(seen, [ + `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, ""]); + assertEquals( + 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()); + 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", + { + 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, + getActorUri(identifier: string) { + 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, _json, activityId, activityType) { + invalidActivityId = activityId; + invalidActivityType = activityType; + return createOutboxContext({ + ...invalidContext, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + onNotFound, + 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", + { + 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}`); + }, + }); + let mismatchedActorErrorMessage: string | null = null; + response = await handleOutbox(mismatchedActorRequest, { + identifier: "someone", + context: mismatchedActorContext, + outboxContextFactory(identifier) { + return createOutboxContext({ + ...mismatchedActorContext, + clone: undefined, + identifier, + }); + }, + actorDispatcher, + outboxListeners: listeners, + outboxErrorHandler: (_ctx, error) => { + mismatchedActorErrorMessage = error.message; + }, + onNotFound, + onUnauthorized, + }); + assertEquals( + [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 ActivityListenerSet>(); + let onErrorCalled = false; + throwingListeners.add(Create, () => { + throw new Error("Boom"); + }); + ({ request, context } = createRequestContextPair()); + 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({ @@ -1380,7 +1778,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; @@ -1968,7 +2366,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; @@ -2102,7 +2500,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 024035123..e407c18f9 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -43,17 +43,24 @@ 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 { 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"; @@ -462,6 +469,275 @@ 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?: ActivityListenerSet>; + outboxErrorHandler?: OutboxListenerErrorHandler; + onUnauthorized(request: Request): Response | Promise; + onNotFound(request: Request): Response | Promise; +} + +function summarizeJsonActivity(json: unknown): { + activityId?: string; + activityType?: string; +} { + if (json == null || typeof json !== "object") return {}; + 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 }; +} + +/** + * 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 (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" }, + }); + } + if (actorDispatcher == null) { + 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 = request.clone(); + let json: unknown; + try { + json = await requestForParsing.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) { + const summary = summarizeJsonActivity(json); + logger.error("Failed to parse activity:\n{error}", { + identifier, + ...summary, + error, + }); + const outboxContext = outboxContextFactory( + identifier, + json, + summary.activityId, + summary.activityType ?? "", + ); + try { + await outboxErrorHandler?.(outboxContext, error as Error); + } catch (error) { + logger.error( + "An unexpected error occurred in outbox error handler:\n{error}", + { error, identifier, ...summary }, + ); + } + return new Response("Invalid activity.", { + status: 400, + 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) { + 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.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}.", + { + identifier, + activityId: activity.id?.href, + expectedActorId: expectedActorId.href, + actorIds: activity.actorIds.map((actorId) => actorId.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" }, + }); + } + const dispatched = outboxListeners?.dispatchWithClass(activity); + if (dispatched == null) { + logger.debug("Unsupported activity type {activityType}.", { + identifier, + activityId: activity.id?.href, + activityType: getTypeId(activity).href, + }); + return new Response("", { + status: 202, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + 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, + activityType: getTypeId(activity).href, + identifier, + }, + ); + } + logger.error( + "Failed to process the incoming activity {activityId}:\n{error}", + { + error, + activityId: activity.id?.href, + activityType: getTypeId(activity).href, + identifier, + }, + ); + return new Response("Internal server error.", { + status: 500, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + 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.", + { + identifier, + activityId: activity.id?.href, + activityType: getTypeId(activity).href, + }, + ); + } + logger.info( + "Activity {activityId} has been processed in outbox listener.", + { + activityId: activity.id?.href, + activityType: getTypeId(activity).href, + identifier, + }, + ); + return new Response("", { + status: 202, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} + /** * Parameters for handling an inbox request. * @template TContextData The context data to pass to the context. @@ -483,7 +759,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/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 1711514f9..efe9ea38a 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1,4 +1,9 @@ -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"; import { @@ -56,6 +61,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(); @@ -2062,6 +2075,684 @@ 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 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) => 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(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}`, + ]); + + 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, 401); + }); + + 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); + assertEquals(response.headers.get("allow"), "GET, HEAD"); + }); + + 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 () => { + 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, () => {}) + .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 sendActivity()", async () => { + await withLogtapeLock(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", + }); + + 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; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." + ), + false, + ); + } finally { + fetchMock.hardReset(); + await reset(); + } + }); + }); + + 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 () => { + 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; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery." + ), + false, + ); + } finally { + fetchMock.hardReset(); + await reset(); + } + }); + }, + ); + + 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 () => { + 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(); + }, + listen(): Promise { + listenCalled = true; + return Promise.resolve(); + }, + }; + 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"); + assertEquals((enqueued[0] as OutboxMessage).actorIds, [ + "https://remote.example/users/alice", + ]); + }, + ); +}); + test("Federation.setInboxDispatcher()", async (t) => { const kv = new MemoryKvStore(); @@ -2953,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) { @@ -3734,6 +4466,91 @@ 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": { + "id": "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, []); + }); + + 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 e9050549e..933660558 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -59,9 +59,9 @@ 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 { 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"; @@ -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,29 @@ 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 ?? + this.outboxCallbacks?.authorizePredicate, + outboxListeners: this.outboxListeners, + outboxErrorHandler: this.outboxListenerErrorHandler, + onUnauthorized, + onNotFound, + }); + } return await handleCollection(request, { name: "outbox", identifier: route.values.identifier, @@ -1701,6 +1726,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; } @@ -2194,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) => { @@ -2231,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; @@ -2356,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, @@ -2367,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 }) => ({ @@ -2404,6 +2459,7 @@ export class ContextImpl implements Context { message, { orderingKey: options.orderingKey }, ); + return true; } async *getFollowers(identifier: string): AsyncIterable { @@ -2799,6 +2855,275 @@ 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 tracer.startActiveSpan( + ctx.federation.outboxQueue == null || options?.immediate + ? `activitypub.${loggerCategory}` + : "activitypub.fanout", + { + 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( + 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 (!hasSignatureLike(ctx.activity)) { + const hasProof = hasProofLike(ctx.activity); + if (!hasProof) { + if (options?.skipIfUnsigned) return false; + logger.warn( + "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, + }, + ); + } + } + 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, + }); + 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, + 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 }, + ); + if (!ctx.federation.manuallyStartQueue) { + ctx.federation._startQueueInternal(ctx.data); + } + 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 started = new Date().toISOString(); + 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, + actorIds: [...inboxes[inbox].actorIds], + started, + 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 || orderingKey != 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 { + 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; @@ -2862,25 +3187,83 @@ export class InboxContextImpl extends ContextImpl | { username: string }, recipients: Recipient | Recipient[] | "followers", options?: ForwardActivityOptions, + ): Promise { + return forwardActivity(this, "inbox", forwarder, recipients, options) + .then(() => undefined); + } +} + +export class OutboxContextImpl extends ContextImpl + implements OutboxContext { + readonly #deliveryState: { delivered: boolean }; + readonly identifier: string; + readonly activity: unknown; + readonly activityId?: string; + readonly activityType: string; + + constructor( + identifier: string, + activity: unknown, + activityId: string | undefined, + activityType: string, + options: ContextOptions, + deliveryState: { delivered: boolean } = { delivered: false }, + ) { + super(options); + this.#deliveryState = deliveryState; + this.identifier = identifier; + this.activity = activity; + this.activityId = activityId; + this.activityType = activityType; + } + + hasDeliveredActivity(): boolean { + return this.#deliveryState.delivered; + } + + override sendActivity( + sender: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string }, + recipients: Recipient | Recipient[] | "followers", + activity: Activity, + options: SendActivityOptionsForCollection = {}, ): Promise { const tracer = this.tracerProvider.getTracer( metadata.name, metadata.version, ); return tracer.startActiveSpan( - "activitypub.outbox", + this.federation.outboxQueue == null || options.immediate + ? "activitypub.outbox" + : "activitypub.fanout", { - kind: this.federation.outboxQueue == null || options?.immediate + kind: this.federation.outboxQueue == null || options.immediate ? SpanKind.CLIENT : SpanKind.PRODUCER, - attributes: { "activitypub.activity.type": this.activityType }, + attributes: { + "activitypub.activity.type": getTypeId(activity).href, + "activitypub.activity.to": activity.toIds.map((to) => to.href), + "activitypub.activity.cc": activity.ccIds.map((cc) => cc.href), + "activitypub.activity.bto": activity.btoIds.map((bto) => bto.href), + "activitypub.activity.bcc": activity.bccIds.map((bcc) => bcc.href), + }, }, async (span) => { try { - if (this.activityId != null) { - span.setAttribute("activitypub.activity.id", this.activityId); + if (activity.id != null) { + span.setAttribute("activitypub.activity.id", activity.id.href); } - await this.forwardActivityInternal(forwarder, recipients, options); + 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; @@ -2891,7 +3274,23 @@ export class InboxContextImpl extends ContextImpl ); } - private async forwardActivityInternal( + 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[] @@ -2900,221 +3299,29 @@ export class InboxContextImpl extends ContextImpl 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, + return forwardActivity(this, "outbox", forwarder, recipients, options) + .then((delivered) => { + if (delivered) this.#deliveryState.delivered = true; }); - } - 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; - } - } - } + } + + 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, + }, + this.#deliveryState, + ); } } diff --git a/packages/fedify/src/sig/ld.test.ts b/packages/fedify/src/sig/ld.test.ts index c7f4db9a6..b6db88171 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,69 @@ 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", + }, + })); + 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", + }, + })); + 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", + }], + })); + 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..3a6c01554 100644 --- a/packages/fedify/src/sig/ld.ts +++ b/packages/fedify/src/sig/ld.ts @@ -176,6 +176,45 @@ 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; + + 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")); + }; + + const hasSignatureObject = (value: unknown): boolean => { + if (typeof value !== "object" || value == null) return false; + const signatureRecord = value as Record; + 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" || + typeof signatureRecord.jws === "string"); + }; + + return Array.isArray(signature) + ? signature.some(hasSignatureObject) + : hasSignatureObject(signature); +} + /** * 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/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 00b9bc576..7705b0763 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,70 @@ 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", + }], + })); + 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", + }, + })); + 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", + }, + })); + 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", + 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..6734becf7 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -20,6 +20,66 @@ 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 ?? 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") || + ("@value" in value && typeof value["@value"] === "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 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); +} + /** * Options for {@link createProof}. * @since 0.10.0 diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index c5d5fac1d..e84dee5fa 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"; @@ -147,8 +148,30 @@ export function createInboxContext( ...createContext(args), clone: args.clone ?? ((data) => createInboxContext({ ...args, data })), recipient: args.recipient ?? null, - forwardActivity: args.forwardActivity ?? ((_params) => { + forwardActivity: args.forwardActivity ?? + ((_forwarder, _recipients, _options) => { + throw new Error("Not implemented"); + }), + }; +} + +export function createOutboxContext( + args: Partial> & { + url?: URL; + data: TContextData; + identifier: string; + 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, + hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false), + forwardActivity, }; } diff --git a/packages/fedify/src/testing/mod.ts b/packages/fedify/src/testing/mod.ts index 91d655a70..fe72cbdda 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"; -// without bellows, `test:cfworkers` makes error +export { + createInboxContext, + createOutboxContext, + createRequestContext, +} from "./context.ts"; +// Without the export below, `test:cfworkers` makes an error. export { testDefinitions } from "@fedify/fixture"; diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index a5d897cd2..c6ae11ae8 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 outboxListenerDeliveryRequired, +} from "./rules/outbox-listener-delivery-required.ts"; const rules: Record< typeof RULE_IDS[keyof typeof RULE_IDS], @@ -94,6 +97,7 @@ const rules: Record< [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, + [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 793e21520..d94e1871f 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 + outboxListenerDeliveryRequired: "outbox-listener-delivery-required", } as const; diff --git a/packages/lint/src/mod.ts b/packages/lint/src/mod.ts index b745c2aa8..6cfc7c501 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 outboxListenerDeliveryRequired, +} from "./rules/outbox-listener-delivery-required.ts"; const plugin: Deno.lint.Plugin = { name: "fedify-lint", @@ -87,6 +90,7 @@ const plugin: Deno.lint.Plugin = { [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, + [RULE_IDS.outboxListenerDeliveryRequired]: outboxListenerDeliveryRequired, }, }; diff --git a/packages/lint/src/rules/outbox-listener-delivery-required.ts b/packages/lint/src/rules/outbox-listener-delivery-required.ts new file mode 100644 index 000000000..e6305fb57 --- /dev/null +++ b/packages/lint/src/rules/outbox-listener-delivery-required.ts @@ -0,0 +1,440 @@ +import type { Rule } from "eslint"; +import { + hasIdentifierProperty, + hasMemberExpressionCallee, + hasMethodName, + isFunction, + isNode, +} from "../lib/pred.ts"; +import { trackFederationVariables } from "../lib/tracker.ts"; +import type { + AssignmentPattern, + 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()."; + +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 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; +}; + +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, "\\$&"); +} + +function stripCommentsAndStrings(code: string): string { + 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 { + 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); + const boundedName = String.raw`(?, + 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: FunctionLikeNode, +): boolean => { + const code = stripCommentsAndStrings(sourceCode.getText(listener)); + const aliases = new Set(); + const contextParam = unwrapContextParam( + listener.params[0] as Node | undefined, + ); + const contextName = contextParam?.type === "Identifier" + ? contextParam.name + : null; + + if (contextParam?.type === "ObjectPattern") { + for (const prop of contextParam.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 == null || !DELIVERY_METHOD_NAMES.has(keyName)) continue; + 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`${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*${contextExpr}`, + "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*${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)) { + aliases.add(match[1]); + } + } + + return globalThis.Array.from(aliases).some((alias) => + new RegExp(String.raw`\b${escapeRegExp(alias)}\s*\(`).test(code) + ); +}; + +function createRule( + buildReport: Context extends Deno.lint.RuleContext ? { + message: string; + } + : { + messageId: string; + data: { message: string }; + }, +) { + 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); + 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 { + pendingCalls.push(node); + }, + + "Program:exit"(): void { + for (const node of pendingCalls) inspectCall(node); + }, + }; + }; +} + +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 explicit delivery methods", + }, + 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..6136eca56 100644 --- a/packages/lint/src/tests/integration.test.ts +++ b/packages/lint/src/tests/integration.test.ts @@ -179,6 +179,98 @@ test("Integration: ✅ Complete valid code passes all rules", () => { assertNoErrors(COMPLETE_VALID_CODE); }); +test( + "Integration: ✅ outbox-listener-delivery-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-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} + +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-delivery-required"), + ), +); + +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 new file mode 100644 index 000000000..00533540a --- /dev/null +++ b/packages/lint/src/tests/outbox-listener-delivery-required.test.ts @@ -0,0 +1,429 @@ +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-delivery-required.ts"; + +const ruleName = RULE_IDS.outboxListenerDeliveryRequired; + +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 - 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 - 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 - 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 - 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 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({ + 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({ + 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 delivery 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 deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); + +test( + `${ruleName}: ❌ Bad - chained authorize without delivery`, + 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 deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); + +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 - 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({ + code: ` +import { Activity } from "@fedify/vocab"; + +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 deliver posted activities explicitly with ctx.sendActivity() or ctx.forwardActivity().", + }), +); + +test( + `${ruleName}: ❌ Bad - string 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().", + }), +); + +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().", + }), +); + +test( + `${ruleName}: ❌ Bad - identifier containing ctx substring`, + lintTest({ + code: ` +import { Activity } from "@fedify/vocab"; + +federation + .setOutboxListeners("/users/{identifier}/outbox") + .on(Activity, async (ctx, activity) => { + 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({ + 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().", + }), +); + +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().", + }), +); diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index 799874e32..7721577fe 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 @@ -193,15 +201,51 @@ 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) => { + forwardActivity, + }; +} + +/** + * 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 { + 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, + hasDeliveredActivity: args.hasDeliveredActivity ?? (() => false), + forwardActivity, }; } // 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..9f2fcd85e 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,8 +1,20 @@ -import type { InboxContext } 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 type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; +import { signJsonLd } from "@fedify/fedify/sig"; +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { + Activity, + Arrive, + Create, + IntransitiveActivity, + Note, + 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 () => { const mockFederation = createFederation(); @@ -99,6 +111,1096 @@ 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.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 }>, 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("postOutboxActivity supports forwardActivity", 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") }), + ); + }, + ); + + 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.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 = 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 forwardActivity treats linked data signatures 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 = 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); + 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: { + id: "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); + }, +); + +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 = 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); + + 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 () => { + 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" }, + }); + 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, () => { + 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( + "postOutboxActivity matches listeners through the prototype chain", + 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 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.setActorDispatcher( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + + 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("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 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 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" }, + }); + 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 () => { + 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("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 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" }, + }); + 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 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" }, + }); + + 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 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( + "/users/{identifier}", + (_ctx, identifier) => { + return new Person({ + id: new URL(`https://example.com/users/${identifier}`), + }); + }, + ); + 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 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" }, + }); + 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("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( + "/users/{identifier}/outbox", + ); + + listeners.on(Create, () => {}); + + assertThrows( + () => listeners.on(Create, () => {}), + TypeError, + ); +}); + +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("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" }, + }); + + mockFederation.setOutboxDispatcher("/users/{identifier}/outbox", () => ({ + items: [], + })); + + assertThrows( + () => mockFederation.setOutboxListeners("/actors/{identifier}/outbox"), + TypeError, + "Outbox listener path and outbox dispatcher path must match.", + ); +}); + +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 and outbox dispatcher path must match.", + ); +}); + +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 exactly one variable named identifier.", + ); + + assertThrows( + () => + mockFederation.setOutboxListeners("/users/{identifier}/outbox/{extra}"), + TypeError, + "Path for outbox must have exactly one variable named identifier.", + ); +}); + +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({ + 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 +1358,75 @@ 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("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("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%2Fprofile/outbox", + ); +}); + +test("MockContext rejects query expansion for outbox paths", () => { + const mockFederation = createFederation(); + assertThrows( + () => mockFederation.setOutboxListeners("/actors/outbox{?identifier}"), + TypeError, + "Path for outbox cannot use query or fragment expansion for identifier.", + ); +}); + +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(); @@ -280,6 +1451,36 @@ 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) => { + 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 63efec738..c618cd0a2 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -10,9 +10,9 @@ import type { RequestContext, RouteActivityOptions, } from "@fedify/fedify/federation"; -import { CryptographicKey, Multikey } from "@fedify/vocab"; +import { hasProofLike, hasSignatureLike } from "@fedify/fedify/sig"; +import { Activity, CryptographicKey, Multikey } from "@fedify/vocab"; import type { - Activity, Collection, LookupObjectOptions, Object, @@ -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 @@ -51,8 +57,8 @@ const noopTracerProvider: any = { }; /** - * Helper function to expand URI templates with values. - * Supports simple placeholders like {identifier}, etc. + * 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 * @returns The expanded URI path @@ -61,11 +67,68 @@ function expandUriTemplate( template: string, values: Record, ): string { - return template.replace(/{([^}]+)}/g, (match, key) => { - return values[key] || match; + return template.replace(/{([+#./;?&]?)([A-Za-z_][A-Za-z0-9_]*)}/g, ( + match, + operator, + key, + ) => { + const value = values[key]; + if (value == null) return match; + switch (operator) { + case "": + return encodeURIComponent(value); + case "+": + return encodeURI(value); + case "#": + return `#${encodeURI(value)}`; + case ".": + return `.${encodeURIComponent(value)}`; + case "/": + return `/${encodeURIComponent(value)}`; + case ";": + return `;${key}=${encodeURIComponent(value)}`; + case "?": + return `?${key}=${encodeURIComponent(value)}`; + case "&": + return `&${key}=${encodeURIComponent(value)}`; + default: + return match; + } }); } +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.", + ); + } + 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 TypeError( + "Path for outbox cannot use query or fragment expansion for 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.", + ); + } +} + /** * Represents a sent activity with metadata about how it was sent. * @since 1.8.0 @@ -77,6 +140,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; } @@ -108,6 +173,7 @@ interface TestContext sender: any; recipients: any; activity: Activity; + rawActivity?: unknown; }>; reset(): void; } @@ -126,6 +192,7 @@ interface TestFederation // Test-specific methods receiveActivity(activity: Activity): Promise; + postOutboxActivity(identifier: string, activity: Activity): Promise; reset(): void; // Override createContext to return TestContext @@ -135,6 +202,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 @@ -195,12 +264,17 @@ class MockFederation implements Federation { public objectDispatchers: Map = new Map(); private inboxDispatcher?: any; private outboxDispatcher?: any; + private outboxAuthorizePredicate?: any; + private outboxDispatcherAuthorizePredicate?: any; + private outboxListenerErrorHandler?: any; private followingDispatcher?: any; private followersDispatcher?: any; private likedDispatcher?: any; private featuredDispatcher?: any; private featuredTagsDispatcher?: any; private inboxListeners: Map = new Map(); + private outboxListeners: Map = new Map(); + private outboxListenersInitialized = false; private contextData?: TContextData; private receivedActivities: Activity[] = []; @@ -261,13 +335,20 @@ class MockFederation implements Federation { } setOutboxDispatcher(path: any, dispatcher: any): any { + validateOutboxListenerPath( + path, + this.outboxListenersInitialized ? this.outboxPath : undefined, + ); this.outboxDispatcher = dispatcher; this.outboxPath = path; return { 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; + }, }; } @@ -355,6 +436,34 @@ class MockFederation implements Federation { }; } + setOutboxListeners(outboxPath: any): any { + 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 + const self = this; + return { + on(type: any, listener: any): any { + if (self.outboxListeners.has(type)) { + throw new TypeError("Listener already set for this type."); + } + self.outboxListeners.set(type, listener); + return this; + }, + onError(handler: any): any { + self.outboxListenerErrorHandler = handler; + return this; + }, + authorize(predicate: any): any { + self.outboxAuthorizePredicate = predicate; + return this; + }, + }; + } + setOutboxPermanentFailureHandler(_handler: any): void { // Mock implementation - no-op } @@ -394,12 +503,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, }); @@ -449,6 +560,142 @@ 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 { + if (!this.outboxListenersInitialized) { + 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) { + 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, + 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, + this.contextData as TContextData, + ); + const rawActivity = postedJson; + const deliveryState = { delivered: false }; + const createMockOutboxContext = () => + createOutboxContext({ + ...baseContext, + clone: undefined, + federation: this as any, + identifier, + 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, + options?: any, + ) => { + const hasProof = hasProofLike(rawActivity); + const hasLds = hasSignatureLike(rawActivity); + if (options?.skipIfUnsigned && !hasProof && !hasLds) { + return; + } + await baseContext.sendActivity( + forwarder, + recipients, + activity, + { ...options, rawActivity }, + ); + deliveryState.delivered = true; + }, + }); + + const actor = await baseContext.getActor(identifier); + if (actor == null) { + throw new Error(`Actor ${JSON.stringify(identifier)} not found.`); + } + const authorizePredicate = this.outboxAuthorizePredicate ?? + this.outboxDispatcherAuthorizePredicate; + if ( + authorizePredicate != null && + !await authorizePredicate(baseContext, identifier) + ) { + throw new Error("Unauthorized."); + } + + 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?.(createMockOutboxContext(), error); + throw error; + } + if ( + !activity.actorIds.every((actorId) => + actorId.href === expectedActorId.href + ) + ) { + const error = new Error( + "The activity actor does not match the outbox owner.", + ); + await this.outboxListenerErrorHandler?.(createMockOutboxContext(), error); + throw error; + } + + if (listener == null) return; + + const context = createMockOutboxContext(); + try { + await listener(context, activity); + } catch (error) { + await this.outboxListenerErrorHandler?.(context, error); + throw error; + } + } + /** * Clears all sent activities from the mock federation. * This method is specific to the mock implementation and is used for @@ -603,11 +850,13 @@ class MockContext implements Context { sender: any; recipients: any; activity: Activity; + rawActivity?: unknown; }> = []; constructor( options: { url?: URL; + request?: Request | null; data: TContextData; federation: Federation; documentLoader?: DocumentLoader; @@ -621,7 +870,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 @@ -912,9 +1161,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) { @@ -923,6 +1177,7 @@ class MockContext implements Context { queued, queue: queued ? "outbox" : undefined, activity, + rawActivity: options?.rawActivity, sentOrder: ++this.federation.sentCounter, }); } @@ -949,6 +1204,7 @@ class MockContext implements Context { sender: any; recipients: any; activity: Activity; + rawActivity?: unknown; }> { return [...this.sentActivities]; } 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 {