Loading CHANGES.md +3 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,8 @@ Version 0.11.0 To be released. - Added `Federation.setInboxDispatcher()` method. [[#71]] - Frequently used JSON-LD contexts are now preloaded. [[74]] - The `fetchDocumentLoader()` function now preloads the following JSON-LD Loading @@ -25,6 +27,7 @@ To be released. - Added `Offer` class to Activity Vocabulary API. [[#65], [#76] by Lee Dogeon] [#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 docs/manual/collections.md +35 −1 Original line number Diff line number Diff line Loading @@ -73,7 +73,7 @@ federation }), }) ); return { items } return { items }; }); ~~~~ Loading Loading @@ -314,6 +314,40 @@ federation ~~~~ Inbox ----- *This API is available since Fedify 0.11.0.* The inbox collection is similar to the outbox collection, but it's a collection of activities that an actor has received. Cursors and counters for the inbox collection are implemented in the same way as the outbox collection, so we don't repeat the explanation here. The below example shows how to construct an inbox collection: ~~~~ typescript import { Activity } from "@fedify/fedify"; federation .setInboxDispatcher("/users/{handle}/inbox", async (ctx, handle) => { // Work with the database to find the activities that the actor has received // (the following `getInboxByUserHandle` is a hypothetical function): const items: Activity[] = await getInboxByUserHandle(handle); return { items }; }) .setCounter(async (ctx, handle) => { // The following `countInboxByUserHandle` is a hypothetical function: return await countInboxByUserHandle(handle); }); ~~~~ > [!NOTE] > The path for the inbox collection dispatcher must match the path for the inbox > listeners. Following --------- Loading federation/middleware.test.ts +91 −0 Original line number Diff line number Diff line Loading @@ -432,6 +432,46 @@ test("Federation.setInboxListeners()", async (t) => { ); }); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxDispatcher( "/users/{handle}/inbox", () => ({ items: [] }), ); assertThrows( () => federation.setInboxListeners("/users/{handle}/inbox2"), RouterError, ); }); await t.step("wrong variables in path", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); assertThrows( () => federation.setInboxListeners( "/users/inbox" as `${string}{handle}${string}`, ), RouterError, ); assertThrows( () => federation.setInboxListeners("/users/{handle}/inbox/{handle2}"), RouterError, ); assertThrows( () => federation.setInboxListeners( "/users/{handle2}/inbox" as `${string}{handle}${string}`, ), RouterError, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = createFederation<void>({ Loading Loading @@ -640,3 +680,54 @@ test("Federation.setInboxListeners()", async (t) => { mf.uninstall(); }); test("Federation.setInboxDispatcher()", async (t) => { const kv = new MemoryKvStore(); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxListeners("/users/{handle}/inbox"); assertThrows( () => federation.setInboxDispatcher( "/users/{handle}/inbox2", () => ({ items: [] }), ), RouterError, ); }); await t.step("wrong variables in path", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); assertThrows( () => federation.setInboxDispatcher( "/users/inbox" as `${string}{handle}${string}`, () => ({ items: [] }), ), RouterError, ); assertThrows( () => federation.setInboxDispatcher( "/users/{handle}/inbox/{handle2}", () => ({ items: [] }), ), RouterError, ); assertThrows( () => federation.setInboxDispatcher( "/users/{handle2}/inbox" as `${string}{handle}${string}`, () => ({ items: [] }), ), RouterError, ); }); }); federation/middleware.ts +92 −12 Original line number Diff line number Diff line Loading @@ -238,10 +238,12 @@ export class Federation<TContextData> { // deno-lint-ignore no-explicit-any (new (...args: any[]) => Object) & { typeId: URL } >; #inboxPath?: string; #inboxCallbacks?: CollectionCallbacks<Activity, TContextData, void>; #outboxCallbacks?: CollectionCallbacks<Activity, TContextData, void>; #followingCallbacks?: CollectionCallbacks<Actor | URL, TContextData, void>; #followersCallbacks?: CollectionCallbacks<Recipient, TContextData, URL>; #inboxListeners: Map< #inboxListeners?: Map< new (...args: unknown[]) => Activity, InboxListener<TContextData, Activity> >; Loading Loading @@ -281,7 +283,6 @@ export class Federation<TContextData> { this.#router = new Router(); this.#router.add("/.well-known/webfinger", "webfinger"); this.#router.add("/.well-known/nodeinfo", "nodeInfoJrd"); this.#inboxListeners = new Map(); this.#objectCallbacks = {}; this.#objectTypeIds = {}; this.#documentLoader = options.documentLoader ?? kvCache({ Loading Loading @@ -857,6 +858,65 @@ export class Federation<TContextData> { return setters; } /** * Registers an inbox dispatcher. * * @param path The URI path pattern for the outbox dispatcher. The syntax is * based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path * must have one variable: `{handle}`, and must match the inbox * listener path. * @param dispatcher An inbox dispatcher callback to register. * @throws {@link RouterError} Thrown if the path pattern is invalid. * @since 0.11.0 */ setInboxDispatcher( path: `${string}{handle}${string}`, dispatcher: CollectionDispatcher<Activity, TContextData, void>, ): CollectionCallbackSetters<TContextData, void> { if (this.#inboxCallbacks != null) { throw new RouterError("Inbox dispatcher already set."); } if (this.#router.has("inbox")) { if (this.#inboxPath !== path) { throw new RouterError( "Inbox dispatcher path must match inbox listener path.", ); } } else { const variables = this.#router.add(path, "inbox"); if (variables.size !== 1 || !variables.has("handle")) { throw new RouterError( "Path for inbox dispatcher must have one variable: {handle}", ); } this.#inboxPath = path; } const callbacks: CollectionCallbacks<Activity, TContextData, void> = { dispatcher, }; this.#inboxCallbacks = callbacks; const setters: CollectionCallbackSetters<TContextData, void> = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } /** * Registers an outbox dispatcher. * Loading Loading @@ -1045,7 +1105,8 @@ export class Federation<TContextData> { * @param inboxPath The URI path pattern for the inbox. The syntax is based * on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). * The path must have one variable: `{handle}`. * The path must have one variable: `{handle}`, and must * match the inbox dispatcher path. * @param sharedInboxPath An optional URI path pattern for the shared inbox. * The syntax is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). Loading @@ -1057,15 +1118,23 @@ export class Federation<TContextData> { inboxPath: `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetter<TContextData> { if (this.#inboxListeners != null) { throw new RouterError("Inbox listeners already set."); } if (this.#router.has("inbox")) { throw new RouterError("Inbox already set."); if (this.#inboxPath !== inboxPath) { throw new RouterError( "Inbox listener path must match inbox dispatcher path.", ); } } else { const variables = this.#router.add(inboxPath, "inbox"); if (variables.size !== 1 || !variables.has("handle")) { throw new RouterError( "Path for inbox must have one variable: {handle}", ); } } if (sharedInboxPath != null) { const siVars = this.#router.add(sharedInboxPath, "sharedInbox"); if (siVars.size !== 0) { Loading @@ -1074,7 +1143,7 @@ export class Federation<TContextData> { ); } } const listeners = this.#inboxListeners; const listeners = this.#inboxListeners = new Map(); const setter: InboxListenerSetter<TContextData> = { on<TActivity extends Activity>( // deno-lint-ignore no-explicit-any Loading Loading @@ -1319,6 +1388,17 @@ export class Federation<TContextData> { onNotAcceptable, }); case "inbox": if (request.method !== "POST") { return await handleCollection(request, { name: "inbox", handle: route.values.handle, context, collectionCallbacks: this.#inboxCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); } context = this.#createContext( request, contextData, Loading @@ -1336,7 +1416,7 @@ export class Federation<TContextData> { kv: this.#kv, kvPrefix: this.#kvPrefixes.activityIdempotence, actorDispatcher: this.#actorCallbacks?.dispatcher, inboxListeners: this.#inboxListeners, inboxListeners: this.#inboxListeners ?? new Map(), inboxErrorHandler: this.#inboxErrorHandler, onNotFound, signatureTimeWindow: this.#signatureTimeWindow, Loading Loading
CHANGES.md +3 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,8 @@ Version 0.11.0 To be released. - Added `Federation.setInboxDispatcher()` method. [[#71]] - Frequently used JSON-LD contexts are now preloaded. [[74]] - The `fetchDocumentLoader()` function now preloads the following JSON-LD Loading @@ -25,6 +27,7 @@ To be released. - Added `Offer` class to Activity Vocabulary API. [[#65], [#76] by Lee Dogeon] [#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
docs/manual/collections.md +35 −1 Original line number Diff line number Diff line Loading @@ -73,7 +73,7 @@ federation }), }) ); return { items } return { items }; }); ~~~~ Loading Loading @@ -314,6 +314,40 @@ federation ~~~~ Inbox ----- *This API is available since Fedify 0.11.0.* The inbox collection is similar to the outbox collection, but it's a collection of activities that an actor has received. Cursors and counters for the inbox collection are implemented in the same way as the outbox collection, so we don't repeat the explanation here. The below example shows how to construct an inbox collection: ~~~~ typescript import { Activity } from "@fedify/fedify"; federation .setInboxDispatcher("/users/{handle}/inbox", async (ctx, handle) => { // Work with the database to find the activities that the actor has received // (the following `getInboxByUserHandle` is a hypothetical function): const items: Activity[] = await getInboxByUserHandle(handle); return { items }; }) .setCounter(async (ctx, handle) => { // The following `countInboxByUserHandle` is a hypothetical function: return await countInboxByUserHandle(handle); }); ~~~~ > [!NOTE] > The path for the inbox collection dispatcher must match the path for the inbox > listeners. Following --------- Loading
federation/middleware.test.ts +91 −0 Original line number Diff line number Diff line Loading @@ -432,6 +432,46 @@ test("Federation.setInboxListeners()", async (t) => { ); }); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxDispatcher( "/users/{handle}/inbox", () => ({ items: [] }), ); assertThrows( () => federation.setInboxListeners("/users/{handle}/inbox2"), RouterError, ); }); await t.step("wrong variables in path", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); assertThrows( () => federation.setInboxListeners( "/users/inbox" as `${string}{handle}${string}`, ), RouterError, ); assertThrows( () => federation.setInboxListeners("/users/{handle}/inbox/{handle2}"), RouterError, ); assertThrows( () => federation.setInboxListeners( "/users/{handle2}/inbox" as `${string}{handle}${string}`, ), RouterError, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = createFederation<void>({ Loading Loading @@ -640,3 +680,54 @@ test("Federation.setInboxListeners()", async (t) => { mf.uninstall(); }); test("Federation.setInboxDispatcher()", async (t) => { const kv = new MemoryKvStore(); await t.step("path match", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); federation.setInboxListeners("/users/{handle}/inbox"); assertThrows( () => federation.setInboxDispatcher( "/users/{handle}/inbox2", () => ({ items: [] }), ), RouterError, ); }); await t.step("wrong variables in path", () => { const federation = createFederation<void>({ kv, documentLoader: mockDocumentLoader, }); assertThrows( () => federation.setInboxDispatcher( "/users/inbox" as `${string}{handle}${string}`, () => ({ items: [] }), ), RouterError, ); assertThrows( () => federation.setInboxDispatcher( "/users/{handle}/inbox/{handle2}", () => ({ items: [] }), ), RouterError, ); assertThrows( () => federation.setInboxDispatcher( "/users/{handle2}/inbox" as `${string}{handle}${string}`, () => ({ items: [] }), ), RouterError, ); }); });
federation/middleware.ts +92 −12 Original line number Diff line number Diff line Loading @@ -238,10 +238,12 @@ export class Federation<TContextData> { // deno-lint-ignore no-explicit-any (new (...args: any[]) => Object) & { typeId: URL } >; #inboxPath?: string; #inboxCallbacks?: CollectionCallbacks<Activity, TContextData, void>; #outboxCallbacks?: CollectionCallbacks<Activity, TContextData, void>; #followingCallbacks?: CollectionCallbacks<Actor | URL, TContextData, void>; #followersCallbacks?: CollectionCallbacks<Recipient, TContextData, URL>; #inboxListeners: Map< #inboxListeners?: Map< new (...args: unknown[]) => Activity, InboxListener<TContextData, Activity> >; Loading Loading @@ -281,7 +283,6 @@ export class Federation<TContextData> { this.#router = new Router(); this.#router.add("/.well-known/webfinger", "webfinger"); this.#router.add("/.well-known/nodeinfo", "nodeInfoJrd"); this.#inboxListeners = new Map(); this.#objectCallbacks = {}; this.#objectTypeIds = {}; this.#documentLoader = options.documentLoader ?? kvCache({ Loading Loading @@ -857,6 +858,65 @@ export class Federation<TContextData> { return setters; } /** * Registers an inbox dispatcher. * * @param path The URI path pattern for the outbox dispatcher. The syntax is * based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path * must have one variable: `{handle}`, and must match the inbox * listener path. * @param dispatcher An inbox dispatcher callback to register. * @throws {@link RouterError} Thrown if the path pattern is invalid. * @since 0.11.0 */ setInboxDispatcher( path: `${string}{handle}${string}`, dispatcher: CollectionDispatcher<Activity, TContextData, void>, ): CollectionCallbackSetters<TContextData, void> { if (this.#inboxCallbacks != null) { throw new RouterError("Inbox dispatcher already set."); } if (this.#router.has("inbox")) { if (this.#inboxPath !== path) { throw new RouterError( "Inbox dispatcher path must match inbox listener path.", ); } } else { const variables = this.#router.add(path, "inbox"); if (variables.size !== 1 || !variables.has("handle")) { throw new RouterError( "Path for inbox dispatcher must have one variable: {handle}", ); } this.#inboxPath = path; } const callbacks: CollectionCallbacks<Activity, TContextData, void> = { dispatcher, }; this.#inboxCallbacks = callbacks; const setters: CollectionCallbackSetters<TContextData, void> = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } /** * Registers an outbox dispatcher. * Loading Loading @@ -1045,7 +1105,8 @@ export class Federation<TContextData> { * @param inboxPath The URI path pattern for the inbox. The syntax is based * on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). * The path must have one variable: `{handle}`. * The path must have one variable: `{handle}`, and must * match the inbox dispatcher path. * @param sharedInboxPath An optional URI path pattern for the shared inbox. * The syntax is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). Loading @@ -1057,15 +1118,23 @@ export class Federation<TContextData> { inboxPath: `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetter<TContextData> { if (this.#inboxListeners != null) { throw new RouterError("Inbox listeners already set."); } if (this.#router.has("inbox")) { throw new RouterError("Inbox already set."); if (this.#inboxPath !== inboxPath) { throw new RouterError( "Inbox listener path must match inbox dispatcher path.", ); } } else { const variables = this.#router.add(inboxPath, "inbox"); if (variables.size !== 1 || !variables.has("handle")) { throw new RouterError( "Path for inbox must have one variable: {handle}", ); } } if (sharedInboxPath != null) { const siVars = this.#router.add(sharedInboxPath, "sharedInbox"); if (siVars.size !== 0) { Loading @@ -1074,7 +1143,7 @@ export class Federation<TContextData> { ); } } const listeners = this.#inboxListeners; const listeners = this.#inboxListeners = new Map(); const setter: InboxListenerSetter<TContextData> = { on<TActivity extends Activity>( // deno-lint-ignore no-explicit-any Loading Loading @@ -1319,6 +1388,17 @@ export class Federation<TContextData> { onNotAcceptable, }); case "inbox": if (request.method !== "POST") { return await handleCollection(request, { name: "inbox", handle: route.values.handle, context, collectionCallbacks: this.#inboxCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); } context = this.#createContext( request, contextData, Loading @@ -1336,7 +1416,7 @@ export class Federation<TContextData> { kv: this.#kv, kvPrefix: this.#kvPrefixes.activityIdempotence, actorDispatcher: this.#actorCallbacks?.dispatcher, inboxListeners: this.#inboxListeners, inboxListeners: this.#inboxListeners ?? new Map(), inboxErrorHandler: this.#inboxErrorHandler, onNotFound, signatureTimeWindow: this.#signatureTimeWindow, Loading