Loading CHANGES.md +9 −0 Original line number Diff line number Diff line Loading @@ -29,6 +29,15 @@ To be released. - Added `Offer` class to Activity Vocabulary API. [[#65], [#76] by Lee Dogeon] - The key pair or the key pair for signing outgoing HTTP requests made from the shared inbox now can be configured. This improves the compatibility with other ActivityPub implementations that require authorized fetches (i.e., secure mode). - Added `SharedInboxKeyDispatcher` type. - Renamed `InboxListenerSetter` interface to `InboxListenerSetters`. - Added `InboxListenerSetters.setSharedKeyDispatcher()` method. [#71]: https://github.com/dahlia/fedify/issues/71 [#74]: https://github.com/dahlia/fedify/issues/74 [#76]: https://github.com/dahlia/fedify/pull/76 Loading cli/inbox.tsx +1 −0 Original line number Diff line number Diff line Loading @@ -182,6 +182,7 @@ const followers: Record<string, Actor> = {}; federation .setInboxListeners("/{handle}/inbox", "/inbox") .setSharedKeyDispatcher((_) => ({ handle: "i" })) .on(Activity, async (ctx, activity) => { activities[ctx.data].activity = activity; if (activity instanceof Follow) { Loading docs/manual/inbox.md +75 −5 Original line number Diff line number Diff line Loading @@ -56,16 +56,16 @@ federation In the above example, the `~Federation.setInboxListeners()` method registers path patterns for the personal inbox and the shared inbox, and the following `~InboxListenerSetter.on()` method registers an inbox listener for the `Follow` activity. The `~InboxListenerSetter.on()` method takes a class of the activity `~InboxListenerSetters.on()` method registers an inbox listener for the `Follow` activity. The `~InboxListenerSetters.on()` method takes a class of the activity and a callback function that takes a `Context` object and the activity object. Note that the `~InboxListenerSetter.on()` method can be chained to register Note that the `~InboxListenerSetters.on()` method can be chained to register multiple inbox listeners for different activity types. > [!WARNING] > Activities of any type that are not registered with > the `~InboxListenerSetter.on()` method are silently ignored. > the `~InboxListenerSetters.on()` method are silently ignored. > If you want to catch all types of activities anyway, add a listener > for the `Activity` class. Loading Loading @@ -98,13 +98,83 @@ the correct authentication. section](./vocab.md#object-ids-and-remote-objects) if you are not familiar with dereferencing accessors. ### Shared inbox key dispatcher *This API is available since Fedify 0.11.0.* > [!TIP] > We highly recommend configuring the shared inbox key dispatcher to avoid > potential incompatibility issues with ActivityPub servers that require > [authorized fetch] (i.e., secure mode). If you want to use an authenticated `DocumentLoader` object as the `Context.documentLoader` for a shared inbox, you can set the identity for the authentication using `~InboxListenerSetters.setSharedKeyDispatcher()` method. For example, the following shows how to implement the [instance actor] pattern: ~~~~ typescript{5-9,13-18} import { Application, Person } from "@fedify/fedify"; federation .setInboxListeners("/users/{handle}/inbox", "/inbox") // The following line assumes that there is an instance actor named `~actor` // for the server. The leading tilde (`~`) is just for avoiding conflicts // with regular actor handles, but you don't have to necessarily follow this // convention: .setSharedKeyDispatcher((_ctx) => ({ handle: "~actor" })); federation .setActorDispatcher("/users/{handle}", async (ctx, handle) => { if (handle === "~actor") { // Returns an Application object for the instance actor: return new Application({ // ... }); } // Fetches the regular actor from the database and returns a Person object: return new Person({ // ... }); }); ~~~~ Or you can manually configure the key pair instead of referring to an actor by its handle: ~~~~ typescript{11-18} import { importJwk } from "@fedify/fedify"; interface InstanceActor { privateKey: JsonWebKey; publicKeyUri: string; } federation .setInboxListeners("/users/{handle}/inbox", "/inbox") .setSharedKeyDispatcher(async (_ctx) => { // The following getInstanceActor() is just a hypothetical function that // fetches information about the instance actor from a database or some // other storage: const instanceActor: InstanceActor = await getInstanceActor(); return { privateKey: await importJwk(instanceActor.privateKey, "private"), keyId: new URL(instanceActor.publicKeyUri), }; }); ~~~~ [authorized fetch]: https://swicg.github.io/activitypub-http-signature/#authorized-fetch [instance actor]: https://seb.jambor.dev/posts/understanding-activitypub-part-4-threads/#the-instance-actor Error handling -------------- Since an incoming activity can be malformed or invalid, you may want to handle such cases. Also, your listener itself may throw an error. The `~InboxListenerSetter.onError()` method registers a callback The `~InboxListenerSetters.onError()` method registers a callback function that takes a `Context` object and an error object. The following shows an example of handling errors: Loading federation/callback.ts +19 −0 Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import type { Activity, CryptographicKey } from "../vocab/mod.ts"; import type { Object } from "../vocab/vocab.ts"; import type { PageItems } from "./collection.ts"; import type { RequestContext } from "./context.ts"; import type { SenderKeyPair } from "./send.ts"; /** * A callback that dispatches a {@link NodeInfo} object. Loading Loading @@ -127,6 +128,24 @@ export type InboxErrorHandler<TContextData> = ( error: Error, ) => void | Promise<void>; /** * A callback that dispatches the key pair for the authenticated document loader * of the {@link Context} passed to the shared inbox listener. * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The request context. * @returns The handle of the actor or the key pair for the authenticated * document loader of the {@link Context} passed to the shared inbox * listener. * @since 0.11.0 */ export type SharedInboxKeyDispatcher<TContextData> = ( context: RequestContext<TContextData>, ) => | SenderKeyPair | { handle: string } | Promise<SenderKeyPair | { handle: string }>; /** * A callback that handles errors during outbox processing. * Loading federation/middleware.ts +48 −24 Original line number Diff line number Diff line Loading @@ -32,6 +32,7 @@ import type { ObjectAuthorizePredicate, ObjectDispatcher, OutboxErrorHandler, SharedInboxKeyDispatcher, } from "./callback.ts"; import { buildCollectionSynchronizationHeader } from "./collection.ts"; import type { Loading Loading @@ -248,6 +249,7 @@ export class Federation<TContextData> { InboxListener<TContextData, Activity> >; #inboxErrorHandler?: InboxErrorHandler<TContextData>; #sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>; #documentLoader: DocumentLoader; #contextLoader: DocumentLoader; #authenticatedDocumentLoaderFactory: AuthenticatedDocumentLoaderFactory; Loading Loading @@ -1117,7 +1119,7 @@ export class Federation<TContextData> { setInboxListeners( inboxPath: `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetter<TContextData> { ): InboxListenerSetters<TContextData> { if (this.#inboxListeners != null) { throw new RouterError("Inbox listeners already set."); } Loading @@ -1144,26 +1146,32 @@ export class Federation<TContextData> { } } const listeners = this.#inboxListeners = new Map(); const setter: InboxListenerSetter<TContextData> = { const setters: InboxListenerSetters<TContextData> = { on<TActivity extends Activity>( // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener<TContextData, TActivity>, ): InboxListenerSetter<TContextData> { ): InboxListenerSetters<TContextData> { if (listeners.has(type)) { throw new TypeError("Listener already set for this type."); } listeners.set(type, listener as InboxListener<TContextData, Activity>); return setter; return setters; }, onError: ( handler: InboxErrorHandler<TContextData>, ): InboxListenerSetter<TContextData> => { ): InboxListenerSetters<TContextData> => { this.#inboxErrorHandler = handler; return setter; return setters; }, setSharedKeyDispatcher: ( dispatcher: SharedInboxKeyDispatcher<TContextData>, ): InboxListenerSetters<TContextData> => { this.#sharedInboxKeyDispatcher = dispatcher; return setters; }, }; return setter; return setters; } /** Loading Loading @@ -1331,7 +1339,8 @@ export class Federation<TContextData> { return response instanceof Promise ? await response : response; } let context = this.#createContext(request, contextData); switch (route.name.replace(/:.*$/, "")) { const routeName = route.name.replace(/:.*$/, ""); switch (routeName) { case "webfinger": return await handleWebFinger(request, { context, Loading Loading @@ -1399,17 +1408,21 @@ export class Federation<TContextData> { onNotAcceptable, }); } context = this.#createContext( request, contextData, { context = this.#createContext(request, contextData, { documentLoader: await context.getDocumentLoader({ handle: route.values.handle, }), }, ); }); // falls through case "sharedInbox": if (routeName !== "inbox" && this.#sharedInboxKeyDispatcher != null) { const identity = await this.#sharedInboxKeyDispatcher(context); context = this.#createContext(request, contextData, { documentLoader: "handle" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity), }); } return await handleInbox(request, { handle: route.values.handle ?? null, context, Loading Loading @@ -1760,11 +1773,9 @@ class ContextImpl<TContextData> implements Context<TContextData> { } getDocumentLoader(identity: { handle: string }): Promise<DocumentLoader>; getDocumentLoader(identity: SenderKeyPair): DocumentLoader; getDocumentLoader( identity: { keyId: URL; privateKey: CryptoKey }, ): DocumentLoader; getDocumentLoader( identity: { keyId: URL; privateKey: CryptoKey } | { handle: string }, identity: SenderKeyPair | { handle: string }, ): DocumentLoader | Promise<DocumentLoader> { if ("handle" in identity) { const keyPair = this.getRsaKeyPairFromHandle(identity.handle); Loading Loading @@ -2175,7 +2186,7 @@ export interface CollectionCallbackSetters<TContextData, TFilter> { /** * Registry for inbox listeners for different activity types. */ export interface InboxListenerSetter<TContextData> { export interface InboxListenerSetters<TContextData> { /** * Registers a listener for a specific incoming activity type. * Loading @@ -2187,7 +2198,7 @@ export interface InboxListenerSetter<TContextData> { // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener<TContextData, TActivity>, ): InboxListenerSetter<TContextData>; ): InboxListenerSetters<TContextData>; /** * Registers an error handler for inbox listeners. Any exceptions thrown Loading @@ -2198,7 +2209,20 @@ export interface InboxListenerSetter<TContextData> { */ onError( handler: InboxErrorHandler<TContextData>, ): InboxListenerSetter<TContextData>; ): InboxListenerSetters<TContextData>; /** * Configures a callback to dispatch the key pair for the authenticated * document loader of the {@link Context} passed to the shared inbox listener. * * @param dispatcher A callback to dispatch the key pair for the authenticated * document loader. * @returns The setters object so that settings can be chained. * @since 0.11.0 */ setSharedKeyDispatcher( dispatcher: SharedInboxKeyDispatcher<TContextData>, ): InboxListenerSetters<TContextData>; } interface SendActivityInternalOptions extends SendActivityOptions { Loading Loading
CHANGES.md +9 −0 Original line number Diff line number Diff line Loading @@ -29,6 +29,15 @@ To be released. - Added `Offer` class to Activity Vocabulary API. [[#65], [#76] by Lee Dogeon] - The key pair or the key pair for signing outgoing HTTP requests made from the shared inbox now can be configured. This improves the compatibility with other ActivityPub implementations that require authorized fetches (i.e., secure mode). - Added `SharedInboxKeyDispatcher` type. - Renamed `InboxListenerSetter` interface to `InboxListenerSetters`. - Added `InboxListenerSetters.setSharedKeyDispatcher()` method. [#71]: https://github.com/dahlia/fedify/issues/71 [#74]: https://github.com/dahlia/fedify/issues/74 [#76]: https://github.com/dahlia/fedify/pull/76 Loading
cli/inbox.tsx +1 −0 Original line number Diff line number Diff line Loading @@ -182,6 +182,7 @@ const followers: Record<string, Actor> = {}; federation .setInboxListeners("/{handle}/inbox", "/inbox") .setSharedKeyDispatcher((_) => ({ handle: "i" })) .on(Activity, async (ctx, activity) => { activities[ctx.data].activity = activity; if (activity instanceof Follow) { Loading
docs/manual/inbox.md +75 −5 Original line number Diff line number Diff line Loading @@ -56,16 +56,16 @@ federation In the above example, the `~Federation.setInboxListeners()` method registers path patterns for the personal inbox and the shared inbox, and the following `~InboxListenerSetter.on()` method registers an inbox listener for the `Follow` activity. The `~InboxListenerSetter.on()` method takes a class of the activity `~InboxListenerSetters.on()` method registers an inbox listener for the `Follow` activity. The `~InboxListenerSetters.on()` method takes a class of the activity and a callback function that takes a `Context` object and the activity object. Note that the `~InboxListenerSetter.on()` method can be chained to register Note that the `~InboxListenerSetters.on()` method can be chained to register multiple inbox listeners for different activity types. > [!WARNING] > Activities of any type that are not registered with > the `~InboxListenerSetter.on()` method are silently ignored. > the `~InboxListenerSetters.on()` method are silently ignored. > If you want to catch all types of activities anyway, add a listener > for the `Activity` class. Loading Loading @@ -98,13 +98,83 @@ the correct authentication. section](./vocab.md#object-ids-and-remote-objects) if you are not familiar with dereferencing accessors. ### Shared inbox key dispatcher *This API is available since Fedify 0.11.0.* > [!TIP] > We highly recommend configuring the shared inbox key dispatcher to avoid > potential incompatibility issues with ActivityPub servers that require > [authorized fetch] (i.e., secure mode). If you want to use an authenticated `DocumentLoader` object as the `Context.documentLoader` for a shared inbox, you can set the identity for the authentication using `~InboxListenerSetters.setSharedKeyDispatcher()` method. For example, the following shows how to implement the [instance actor] pattern: ~~~~ typescript{5-9,13-18} import { Application, Person } from "@fedify/fedify"; federation .setInboxListeners("/users/{handle}/inbox", "/inbox") // The following line assumes that there is an instance actor named `~actor` // for the server. The leading tilde (`~`) is just for avoiding conflicts // with regular actor handles, but you don't have to necessarily follow this // convention: .setSharedKeyDispatcher((_ctx) => ({ handle: "~actor" })); federation .setActorDispatcher("/users/{handle}", async (ctx, handle) => { if (handle === "~actor") { // Returns an Application object for the instance actor: return new Application({ // ... }); } // Fetches the regular actor from the database and returns a Person object: return new Person({ // ... }); }); ~~~~ Or you can manually configure the key pair instead of referring to an actor by its handle: ~~~~ typescript{11-18} import { importJwk } from "@fedify/fedify"; interface InstanceActor { privateKey: JsonWebKey; publicKeyUri: string; } federation .setInboxListeners("/users/{handle}/inbox", "/inbox") .setSharedKeyDispatcher(async (_ctx) => { // The following getInstanceActor() is just a hypothetical function that // fetches information about the instance actor from a database or some // other storage: const instanceActor: InstanceActor = await getInstanceActor(); return { privateKey: await importJwk(instanceActor.privateKey, "private"), keyId: new URL(instanceActor.publicKeyUri), }; }); ~~~~ [authorized fetch]: https://swicg.github.io/activitypub-http-signature/#authorized-fetch [instance actor]: https://seb.jambor.dev/posts/understanding-activitypub-part-4-threads/#the-instance-actor Error handling -------------- Since an incoming activity can be malformed or invalid, you may want to handle such cases. Also, your listener itself may throw an error. The `~InboxListenerSetter.onError()` method registers a callback The `~InboxListenerSetters.onError()` method registers a callback function that takes a `Context` object and an error object. The following shows an example of handling errors: Loading
federation/callback.ts +19 −0 Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import type { Activity, CryptographicKey } from "../vocab/mod.ts"; import type { Object } from "../vocab/vocab.ts"; import type { PageItems } from "./collection.ts"; import type { RequestContext } from "./context.ts"; import type { SenderKeyPair } from "./send.ts"; /** * A callback that dispatches a {@link NodeInfo} object. Loading Loading @@ -127,6 +128,24 @@ export type InboxErrorHandler<TContextData> = ( error: Error, ) => void | Promise<void>; /** * A callback that dispatches the key pair for the authenticated document loader * of the {@link Context} passed to the shared inbox listener. * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The request context. * @returns The handle of the actor or the key pair for the authenticated * document loader of the {@link Context} passed to the shared inbox * listener. * @since 0.11.0 */ export type SharedInboxKeyDispatcher<TContextData> = ( context: RequestContext<TContextData>, ) => | SenderKeyPair | { handle: string } | Promise<SenderKeyPair | { handle: string }>; /** * A callback that handles errors during outbox processing. * Loading
federation/middleware.ts +48 −24 Original line number Diff line number Diff line Loading @@ -32,6 +32,7 @@ import type { ObjectAuthorizePredicate, ObjectDispatcher, OutboxErrorHandler, SharedInboxKeyDispatcher, } from "./callback.ts"; import { buildCollectionSynchronizationHeader } from "./collection.ts"; import type { Loading Loading @@ -248,6 +249,7 @@ export class Federation<TContextData> { InboxListener<TContextData, Activity> >; #inboxErrorHandler?: InboxErrorHandler<TContextData>; #sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>; #documentLoader: DocumentLoader; #contextLoader: DocumentLoader; #authenticatedDocumentLoaderFactory: AuthenticatedDocumentLoaderFactory; Loading Loading @@ -1117,7 +1119,7 @@ export class Federation<TContextData> { setInboxListeners( inboxPath: `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetter<TContextData> { ): InboxListenerSetters<TContextData> { if (this.#inboxListeners != null) { throw new RouterError("Inbox listeners already set."); } Loading @@ -1144,26 +1146,32 @@ export class Federation<TContextData> { } } const listeners = this.#inboxListeners = new Map(); const setter: InboxListenerSetter<TContextData> = { const setters: InboxListenerSetters<TContextData> = { on<TActivity extends Activity>( // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener<TContextData, TActivity>, ): InboxListenerSetter<TContextData> { ): InboxListenerSetters<TContextData> { if (listeners.has(type)) { throw new TypeError("Listener already set for this type."); } listeners.set(type, listener as InboxListener<TContextData, Activity>); return setter; return setters; }, onError: ( handler: InboxErrorHandler<TContextData>, ): InboxListenerSetter<TContextData> => { ): InboxListenerSetters<TContextData> => { this.#inboxErrorHandler = handler; return setter; return setters; }, setSharedKeyDispatcher: ( dispatcher: SharedInboxKeyDispatcher<TContextData>, ): InboxListenerSetters<TContextData> => { this.#sharedInboxKeyDispatcher = dispatcher; return setters; }, }; return setter; return setters; } /** Loading Loading @@ -1331,7 +1339,8 @@ export class Federation<TContextData> { return response instanceof Promise ? await response : response; } let context = this.#createContext(request, contextData); switch (route.name.replace(/:.*$/, "")) { const routeName = route.name.replace(/:.*$/, ""); switch (routeName) { case "webfinger": return await handleWebFinger(request, { context, Loading Loading @@ -1399,17 +1408,21 @@ export class Federation<TContextData> { onNotAcceptable, }); } context = this.#createContext( request, contextData, { context = this.#createContext(request, contextData, { documentLoader: await context.getDocumentLoader({ handle: route.values.handle, }), }, ); }); // falls through case "sharedInbox": if (routeName !== "inbox" && this.#sharedInboxKeyDispatcher != null) { const identity = await this.#sharedInboxKeyDispatcher(context); context = this.#createContext(request, contextData, { documentLoader: "handle" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity), }); } return await handleInbox(request, { handle: route.values.handle ?? null, context, Loading Loading @@ -1760,11 +1773,9 @@ class ContextImpl<TContextData> implements Context<TContextData> { } getDocumentLoader(identity: { handle: string }): Promise<DocumentLoader>; getDocumentLoader(identity: SenderKeyPair): DocumentLoader; getDocumentLoader( identity: { keyId: URL; privateKey: CryptoKey }, ): DocumentLoader; getDocumentLoader( identity: { keyId: URL; privateKey: CryptoKey } | { handle: string }, identity: SenderKeyPair | { handle: string }, ): DocumentLoader | Promise<DocumentLoader> { if ("handle" in identity) { const keyPair = this.getRsaKeyPairFromHandle(identity.handle); Loading Loading @@ -2175,7 +2186,7 @@ export interface CollectionCallbackSetters<TContextData, TFilter> { /** * Registry for inbox listeners for different activity types. */ export interface InboxListenerSetter<TContextData> { export interface InboxListenerSetters<TContextData> { /** * Registers a listener for a specific incoming activity type. * Loading @@ -2187,7 +2198,7 @@ export interface InboxListenerSetter<TContextData> { // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener<TContextData, TActivity>, ): InboxListenerSetter<TContextData>; ): InboxListenerSetters<TContextData>; /** * Registers an error handler for inbox listeners. Any exceptions thrown Loading @@ -2198,7 +2209,20 @@ export interface InboxListenerSetter<TContextData> { */ onError( handler: InboxErrorHandler<TContextData>, ): InboxListenerSetter<TContextData>; ): InboxListenerSetters<TContextData>; /** * Configures a callback to dispatch the key pair for the authenticated * document loader of the {@link Context} passed to the shared inbox listener. * * @param dispatcher A callback to dispatch the key pair for the authenticated * document loader. * @returns The setters object so that settings can be chained. * @since 0.11.0 */ setSharedKeyDispatcher( dispatcher: SharedInboxKeyDispatcher<TContextData>, ): InboxListenerSetters<TContextData>; } interface SendActivityInternalOptions extends SendActivityOptions { Loading