Loading CHANGES.md +12 −2 Original line number Diff line number Diff line Loading @@ -10,8 +10,18 @@ To be released. - Added `PUBLIC_COLLECTION` constant for [public addressing]. - Added `RequestContext.getSignedKey()` method for [authorized fetch] (also known as secure mode). - `Federation` now supports [authorized fetch] for actor dispatcher and collection dispatchers. - Added `ActorCallbackSetters.authorize()` method. - Added `CollectionCallbackSetters.authorize()` method. - Added `AuthorizedPredicate` type. - Added `RequestContext.getSignedKey()` method. - Added `FederationFetchOptions.onUnauthorized` option for handling unauthorized fetches. - The default implementation of `FederationFetchOptions.onNotAcceptable` option now responds with `Vary: Accept, Signature` header. [public addressing]: https://www.w3.org/TR/activitypub/#public-addressing [authorized fetch]: https://swicg.github.io/activitypub-http-signature/#authorized-fetch Loading federation/callback.ts +17 −0 Original line number Diff line number Diff line Loading @@ -98,3 +98,20 @@ export type OutboxErrorHandler = ( error: Error, activity: Activity | null, ) => void | Promise<void>; /** * A callback that determines if a request is authorized or not. * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The request context. * @param handle The handle of the actor that is being requested. * @param signedKey The key that was used to sign the request, or `null` if * the request was not signed or the signature was invalid. * @returns `true` if the request is authorized, `false` otherwise. * @since 0.7.0 */ export type AuthorizePredicate<TContextData> = ( context: RequestContext<TContextData>, handle: string, signedKey: CryptographicKey | null, ) => boolean | Promise<boolean>; federation/handler.test.ts +165 −0 Original line number Diff line number Diff line import { assert, assertEquals, assertFalse } from "@std/assert"; import { createRequestContext } from "../testing/context.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { publicKey2 } from "../testing/keys.ts"; import { type Activity, Create, Note, Person } from "../vocab/vocab.ts"; import type { ActorDispatcher, Loading Loading @@ -71,6 +72,11 @@ Deno.test("handleActor()", async () => { onNotAcceptableCalled = request; return new Response("Not acceptable", { status: 406 }); }; let onUnauthorizedCalled: Request | null = null; const onUnauthorized = (request: Request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleActor( context.request, { Loading @@ -78,11 +84,13 @@ Deno.test("handleActor()", async () => { handle: "someone", onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleActor( Loading @@ -93,11 +101,13 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 406); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotAcceptableCalled = null; response = await handleActor( Loading @@ -108,11 +118,13 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext<void>({ Loading @@ -131,6 +143,7 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading Loading @@ -160,6 +173,7 @@ Deno.test("handleActor()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleActor( context.request, Loading @@ -169,11 +183,85 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext<void>({ ...context, request: new Request(context.url, { headers: { Accept: "application/activity+json", }, }), }); response = await handleActor( context.request, { context, handle: "someone", actorDispatcher, authorizePredicate: (_ctx, _handle, signedKey) => signedKey != null, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext<void>({ ...context, getSignedKey: () => Promise.resolve(publicKey2), }); response = await handleActor( context.request, { context, handle: "someone", actorDispatcher, authorizePredicate: (_ctx, _handle, signedKey) => signedKey != null, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); assertEquals( response.headers.get("Content-Type"), "application/activity+json", ); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { manuallyApprovesFollowers: "as:manuallyApprovesFollowers", discoverable: "toot:discoverable", indexable: "toot:indexable", memorial: "toot:memorial", suspended: "toot:suspended", toot: "http://joinmastodon.org/ns#", schema: "http://schema.org#", PropertyValue: "schema:PropertyValue", value: "schema:value", }, ], id: "https://example.com/users/someone", type: "Person", name: "Someone", }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); }); Deno.test("handleCollection()", async () => { Loading Loading @@ -221,6 +309,11 @@ Deno.test("handleCollection()", async () => { onNotAcceptableCalled = request; return new Response("Not acceptable", { status: 406 }); }; let onUnauthorizedCalled: Request | null = null; const onUnauthorized = (request: Request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleCollection( context.request, { Loading @@ -228,11 +321,13 @@ Deno.test("handleCollection()", async () => { handle: "someone", onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleCollection( Loading @@ -243,11 +338,13 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 406); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotAcceptableCalled = null; response = await handleCollection( Loading @@ -258,11 +355,13 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext<void>({ Loading @@ -281,11 +380,13 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleCollection( Loading @@ -296,6 +397,63 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); assertEquals( response.headers.get("Content-Type"), "application/activity+json", ); assertEquals(await response.json(), { "@context": "https://www.w3.org/ns/activitystreams", type: "OrderedCollection", items: [ { type: "Create", id: "https://example.com/activities/1" }, { type: "Create", id: "https://example.com/activities/2" }, { type: "Create", id: "https://example.com/activities/3" }, ], }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection( context.request, { context, handle: "someone", collectionCallbacks: { dispatcher, authorizePredicate: (_ctx, _handle, key) => key != null, }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext<void>({ ...context, getSignedKey: () => Promise.resolve(publicKey2), }); response = await handleCollection( context.request, { context, handle: "someone", collectionCallbacks: { dispatcher, authorizePredicate: (_ctx, _handle, key) => key != null, }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -314,6 +472,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection( context.request, Loading @@ -328,6 +487,7 @@ Deno.test("handleCollection()", async () => { }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -344,6 +504,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); let url = new URL("https://example.com/?cursor=0"); context = createRequestContext({ Loading @@ -368,6 +529,7 @@ Deno.test("handleCollection()", async () => { }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -387,6 +549,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); url = new URL("https://example.com/?cursor=2"); context = createRequestContext({ Loading @@ -411,6 +574,7 @@ Deno.test("handleCollection()", async () => { }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -430,6 +594,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); }); Deno.test("respondWithObject()", async () => { Loading federation/handler.ts +33 −42 Original line number Diff line number Diff line Loading @@ -11,6 +11,7 @@ import { } from "../vocab/vocab.ts"; import type { ActorDispatcher, AuthorizePredicate, CollectionCounter, CollectionCursor, CollectionDispatcher, Loading @@ -35,6 +36,8 @@ export interface ActorHandlerParameters<TContextData> { handle: string; context: RequestContext<TContextData>; actorDispatcher?: ActorDispatcher<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; onUnauthorized(request: Request): Response | Promise<Response>; onNotFound(request: Request): Response | Promise<Response>; onNotAcceptable(request: Request): Response | Promise<Response>; } Loading @@ -45,8 +48,10 @@ export async function handleActor<TContextData>( handle, context, actorDispatcher, authorizePredicate, onNotFound, onNotAcceptable, onUnauthorized, }: ActorHandlerParameters<TContextData>, ): Promise<Response> { if (actorDispatcher == null) { Loading @@ -55,13 +60,13 @@ export async function handleActor<TContextData>( } const key = await context.getActorKey(handle); const actor = await actorDispatcher(context, handle, key); if (actor == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; if (actor == null) return await onNotFound(request); if (!acceptsJsonLd(request)) return await onNotAcceptable(request); if (authorizePredicate != null) { const key = await context.getSignedKey(); if (!await authorizePredicate(context, handle, key)) { return await onUnauthorized(request); } if (!acceptsJsonLd(request)) { const response = onNotAcceptable(request); return response instanceof Promise ? await response : response; } const jsonLd = await actor.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { Loading Loading @@ -95,12 +100,18 @@ export interface CollectionCallbacks<TItem, TContextData> { * A callback that returns the last cursor for a collection. */ lastCursor?: CollectionCursor<TContextData>; /** * A callback that determines if a request is authorized to access the collection. */ authorizePredicate?: AuthorizePredicate<TContextData>; } export interface CollectionHandlerParameters<TItem, TContextData> { handle: string; context: RequestContext<TContextData>; collectionCallbacks?: CollectionCallbacks<TItem, TContextData>; onUnauthorized(request: Request): Response | Promise<Response>; onNotFound(request: Request): Response | Promise<Response>; onNotAcceptable(request: Request): Response | Promise<Response>; } Loading @@ -114,51 +125,34 @@ export async function handleCollection< handle, context, collectionCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }: CollectionHandlerParameters<TItem, TContextData>, ): Promise<Response> { if (collectionCallbacks == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } if (collectionCallbacks == null) return await onNotFound(request); const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); let collection: OrderedCollection | OrderedCollectionPage; if (cursor == null) { const firstCursorPromise = collectionCallbacks.firstCursor?.( const firstCursor = await collectionCallbacks.firstCursor?.( context, handle, ); const firstCursor = firstCursorPromise instanceof Promise ? await firstCursorPromise : firstCursorPromise; const totalItemsPromise = collectionCallbacks.counter?.(context, handle); const totalItems = totalItemsPromise instanceof Promise ? await totalItemsPromise : totalItemsPromise; const totalItems = await collectionCallbacks.counter?.(context, handle); if (firstCursor == null) { const pagePromise = collectionCallbacks.dispatcher(context, handle, null); const page = pagePromise instanceof Promise ? await pagePromise : pagePromise; if (page == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const page = await collectionCallbacks.dispatcher(context, handle, null); if (page == null) return await onNotFound(request); const { items } = page; collection = new OrderedCollection({ totalItems: totalItems == null ? null : Number(totalItems), items, }); } else { const lastCursorPromise = collectionCallbacks.lastCursor?.( const lastCursor = await collectionCallbacks.lastCursor?.( context, handle, ); const lastCursor = lastCursorPromise instanceof Promise ? await lastCursorPromise : lastCursorPromise; const first = new URL(context.url); first.searchParams.set("cursor", firstCursor); let last = null; Loading @@ -173,14 +167,8 @@ export async function handleCollection< }); } } else { const pagePromise = collectionCallbacks.dispatcher(context, handle, cursor); const page = pagePromise instanceof Promise ? await pagePromise : pagePromise; if (page == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const page = await collectionCallbacks.dispatcher(context, handle, cursor); if (page == null) return await onNotFound(request); const { items, prevCursor, nextCursor } = page; let prev = null; if (prevCursor != null) { Loading @@ -196,9 +184,12 @@ export async function handleCollection< partOf.searchParams.delete("cursor"); collection = new OrderedCollectionPage({ prev, next, items, partOf }); } if (!acceptsJsonLd(request)) { const response = onNotAcceptable(request); return response instanceof Promise ? await response : response; if (!acceptsJsonLd(request)) return await onNotAcceptable(request); if (collectionCallbacks.authorizePredicate != null) { const key = await context.getSignedKey(); if (!await collectionCallbacks.authorizePredicate(context, handle, key)) { return await onUnauthorized(request); } } const jsonLd = await collection.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { Loading federation/middleware.ts +85 −4 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, ActorKeyPairDispatcher, AuthorizePredicate, CollectionCounter, CollectionCursor, CollectionDispatcher, Loading Loading @@ -470,12 +471,14 @@ export class Federation<TContextData> { const callbacks: ActorCallbacks<TContextData> = { dispatcher }; this.#actorCallbacks = callbacks; const setters: ActorCallbackSetters<TContextData> = { setKeyPairDispatcher: ( dispatcher: ActorKeyPairDispatcher<TContextData>, ) => { setKeyPairDispatcher(dispatcher: ActorKeyPairDispatcher<TContextData>) { callbacks.keyPairDispatcher = dispatcher; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -533,6 +536,10 @@ export class Federation<TContextData> { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -578,6 +585,10 @@ export class Federation<TContextData> { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -623,6 +634,10 @@ export class Federation<TContextData> { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -801,11 +816,13 @@ export class Federation<TContextData> { { onNotFound, onNotAcceptable, onUnauthorized, contextData, }: FederationFetchOptions<TContextData>, ): Promise<Response> { onNotFound ??= notFound; onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); const route = this.#router.route(url.pathname); if (route == null) { Loading @@ -832,6 +849,8 @@ export class Federation<TContextData> { handle: route.values.handle, context, actorDispatcher: this.#actorCallbacks?.dispatcher, authorizePredicate: this.#actorCallbacks?.authorizePredicate, onUnauthorized, onNotFound, onNotAcceptable, }); Loading @@ -840,6 +859,7 @@ export class Federation<TContextData> { handle: route.values.handle, context, collectionCallbacks: this.#outboxCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); Loading Loading @@ -867,6 +887,7 @@ export class Federation<TContextData> { handle: route.values.handle, context, collectionCallbacks: this.#followingCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); Loading @@ -875,6 +896,7 @@ export class Federation<TContextData> { handle: route.values.handle, context, collectionCallbacks: this.#followersCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); Loading Loading @@ -913,11 +935,21 @@ export interface FederationFetchOptions<TContextData> { * @returns The response to the request. */ onNotAcceptable?: (request: Request) => Response | Promise<Response>; /** * A callback to handle a request when the request is unauthorized. * If not provided, a 401 response is returned. * @param request The request object. * @returns The response to the request. * @since 0.7.0 */ onUnauthorized?: (request: Request) => Response | Promise<Response>; } interface ActorCallbacks<TContextData> { dispatcher?: ActorDispatcher<TContextData>; keyPairDispatcher?: ActorKeyPairDispatcher<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; } /** Loading @@ -942,23 +974,58 @@ export interface ActorCallbackSetters<TContextData> { setKeyPairDispatcher( dispatcher: ActorKeyPairDispatcher<TContextData>, ): ActorCallbackSetters<TContextData>; /** * Specifies the conditions under which requests are authorized. * @param predicate A callback that returns whether a request is authorized. * @returns The setters object so that settings can be chained. * @since 0.7.0 */ authorize( predicate: AuthorizePredicate<TContextData>, ): ActorCallbackSetters<TContextData>; } /** * Additional settings for a collection dispatcher. */ export interface CollectionCallbackSetters<TContextData> { /** * Sets the counter for the collection. * @param counter A callback that returns the number of items in the collection. * @returns The setters object so that settings can be chained. */ setCounter( counter: CollectionCounter<TContextData>, ): CollectionCallbackSetters<TContextData>; /** * Sets the first cursor for the collection. * @param cursor The cursor for the first item in the collection. * @returns The setters object so that settings can be chained. */ setFirstCursor( cursor: CollectionCursor<TContextData>, ): CollectionCallbackSetters<TContextData>; /** * Sets the last cursor for the collection. * @param cursor The cursor for the last item in the collection. * @returns The setters object so that settings can be chained. */ setLastCursor( cursor: CollectionCursor<TContextData>, ): CollectionCallbackSetters<TContextData>; /** * Specifies the conditions under which requests are authorized. * @param predicate A callback that returns whether a request is authorized. * @returns The setters object so that settings can be chained. * @since 0.7.0 */ authorize( predicate: AuthorizePredicate<TContextData>, ): CollectionCallbackSetters<TContextData>; } /** Loading Loading @@ -995,5 +1062,19 @@ function notFound(_request: Request): Response { } function notAcceptable(_request: Request): Response { return new Response("Not Acceptable", { status: 406 }); return new Response("Not Acceptable", { status: 406, headers: { Vary: "Accept, Signature", }, }); } function unauthorized(_request: Request): Response { return new Response("Unauthorized", { status: 401, headers: { Vary: "Accept, Signature", }, }); } Loading
CHANGES.md +12 −2 Original line number Diff line number Diff line Loading @@ -10,8 +10,18 @@ To be released. - Added `PUBLIC_COLLECTION` constant for [public addressing]. - Added `RequestContext.getSignedKey()` method for [authorized fetch] (also known as secure mode). - `Federation` now supports [authorized fetch] for actor dispatcher and collection dispatchers. - Added `ActorCallbackSetters.authorize()` method. - Added `CollectionCallbackSetters.authorize()` method. - Added `AuthorizedPredicate` type. - Added `RequestContext.getSignedKey()` method. - Added `FederationFetchOptions.onUnauthorized` option for handling unauthorized fetches. - The default implementation of `FederationFetchOptions.onNotAcceptable` option now responds with `Vary: Accept, Signature` header. [public addressing]: https://www.w3.org/TR/activitypub/#public-addressing [authorized fetch]: https://swicg.github.io/activitypub-http-signature/#authorized-fetch Loading
federation/callback.ts +17 −0 Original line number Diff line number Diff line Loading @@ -98,3 +98,20 @@ export type OutboxErrorHandler = ( error: Error, activity: Activity | null, ) => void | Promise<void>; /** * A callback that determines if a request is authorized or not. * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The request context. * @param handle The handle of the actor that is being requested. * @param signedKey The key that was used to sign the request, or `null` if * the request was not signed or the signature was invalid. * @returns `true` if the request is authorized, `false` otherwise. * @since 0.7.0 */ export type AuthorizePredicate<TContextData> = ( context: RequestContext<TContextData>, handle: string, signedKey: CryptographicKey | null, ) => boolean | Promise<boolean>;
federation/handler.test.ts +165 −0 Original line number Diff line number Diff line import { assert, assertEquals, assertFalse } from "@std/assert"; import { createRequestContext } from "../testing/context.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { publicKey2 } from "../testing/keys.ts"; import { type Activity, Create, Note, Person } from "../vocab/vocab.ts"; import type { ActorDispatcher, Loading Loading @@ -71,6 +72,11 @@ Deno.test("handleActor()", async () => { onNotAcceptableCalled = request; return new Response("Not acceptable", { status: 406 }); }; let onUnauthorizedCalled: Request | null = null; const onUnauthorized = (request: Request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleActor( context.request, { Loading @@ -78,11 +84,13 @@ Deno.test("handleActor()", async () => { handle: "someone", onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleActor( Loading @@ -93,11 +101,13 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 406); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotAcceptableCalled = null; response = await handleActor( Loading @@ -108,11 +118,13 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext<void>({ Loading @@ -131,6 +143,7 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading Loading @@ -160,6 +173,7 @@ Deno.test("handleActor()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleActor( context.request, Loading @@ -169,11 +183,85 @@ Deno.test("handleActor()", async () => { actorDispatcher, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext<void>({ ...context, request: new Request(context.url, { headers: { Accept: "application/activity+json", }, }), }); response = await handleActor( context.request, { context, handle: "someone", actorDispatcher, authorizePredicate: (_ctx, _handle, signedKey) => signedKey != null, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext<void>({ ...context, getSignedKey: () => Promise.resolve(publicKey2), }); response = await handleActor( context.request, { context, handle: "someone", actorDispatcher, authorizePredicate: (_ctx, _handle, signedKey) => signedKey != null, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); assertEquals( response.headers.get("Content-Type"), "application/activity+json", ); assertEquals(await response.json(), { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { manuallyApprovesFollowers: "as:manuallyApprovesFollowers", discoverable: "toot:discoverable", indexable: "toot:indexable", memorial: "toot:memorial", suspended: "toot:suspended", toot: "http://joinmastodon.org/ns#", schema: "http://schema.org#", PropertyValue: "schema:PropertyValue", value: "schema:value", }, ], id: "https://example.com/users/someone", type: "Person", name: "Someone", }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); }); Deno.test("handleCollection()", async () => { Loading Loading @@ -221,6 +309,11 @@ Deno.test("handleCollection()", async () => { onNotAcceptableCalled = request; return new Response("Not acceptable", { status: 406 }); }; let onUnauthorizedCalled: Request | null = null; const onUnauthorized = (request: Request) => { onUnauthorizedCalled = request; return new Response("Unauthorized", { status: 401 }); }; let response = await handleCollection( context.request, { Loading @@ -228,11 +321,13 @@ Deno.test("handleCollection()", async () => { handle: "someone", onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleCollection( Loading @@ -243,11 +338,13 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 406); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, context.request); assertEquals(onUnauthorizedCalled, null); onNotAcceptableCalled = null; response = await handleCollection( Loading @@ -258,11 +355,13 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; context = createRequestContext<void>({ Loading @@ -281,11 +380,13 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 404); assertEquals(onNotFoundCalled, context.request); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); onNotFoundCalled = null; response = await handleCollection( Loading @@ -296,6 +397,63 @@ Deno.test("handleCollection()", async () => { collectionCallbacks: { dispatcher }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); assertEquals( response.headers.get("Content-Type"), "application/activity+json", ); assertEquals(await response.json(), { "@context": "https://www.w3.org/ns/activitystreams", type: "OrderedCollection", items: [ { type: "Create", id: "https://example.com/activities/1" }, { type: "Create", id: "https://example.com/activities/2" }, { type: "Create", id: "https://example.com/activities/3" }, ], }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection( context.request, { context, handle: "someone", collectionCallbacks: { dispatcher, authorizePredicate: (_ctx, _handle, key) => key != null, }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 401); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, context.request); onUnauthorizedCalled = null; context = createRequestContext<void>({ ...context, getSignedKey: () => Promise.resolve(publicKey2), }); response = await handleCollection( context.request, { context, handle: "someone", collectionCallbacks: { dispatcher, authorizePredicate: (_ctx, _handle, key) => key != null, }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -314,6 +472,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); response = await handleCollection( context.request, Loading @@ -328,6 +487,7 @@ Deno.test("handleCollection()", async () => { }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -344,6 +504,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); let url = new URL("https://example.com/?cursor=0"); context = createRequestContext({ Loading @@ -368,6 +529,7 @@ Deno.test("handleCollection()", async () => { }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -387,6 +549,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); url = new URL("https://example.com/?cursor=2"); context = createRequestContext({ Loading @@ -411,6 +574,7 @@ Deno.test("handleCollection()", async () => { }, onNotFound, onNotAcceptable, onUnauthorized, }, ); assertEquals(response.status, 200); Loading @@ -430,6 +594,7 @@ Deno.test("handleCollection()", async () => { }); assertEquals(onNotFoundCalled, null); assertEquals(onNotAcceptableCalled, null); assertEquals(onUnauthorizedCalled, null); }); Deno.test("respondWithObject()", async () => { Loading
federation/handler.ts +33 −42 Original line number Diff line number Diff line Loading @@ -11,6 +11,7 @@ import { } from "../vocab/vocab.ts"; import type { ActorDispatcher, AuthorizePredicate, CollectionCounter, CollectionCursor, CollectionDispatcher, Loading @@ -35,6 +36,8 @@ export interface ActorHandlerParameters<TContextData> { handle: string; context: RequestContext<TContextData>; actorDispatcher?: ActorDispatcher<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; onUnauthorized(request: Request): Response | Promise<Response>; onNotFound(request: Request): Response | Promise<Response>; onNotAcceptable(request: Request): Response | Promise<Response>; } Loading @@ -45,8 +48,10 @@ export async function handleActor<TContextData>( handle, context, actorDispatcher, authorizePredicate, onNotFound, onNotAcceptable, onUnauthorized, }: ActorHandlerParameters<TContextData>, ): Promise<Response> { if (actorDispatcher == null) { Loading @@ -55,13 +60,13 @@ export async function handleActor<TContextData>( } const key = await context.getActorKey(handle); const actor = await actorDispatcher(context, handle, key); if (actor == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; if (actor == null) return await onNotFound(request); if (!acceptsJsonLd(request)) return await onNotAcceptable(request); if (authorizePredicate != null) { const key = await context.getSignedKey(); if (!await authorizePredicate(context, handle, key)) { return await onUnauthorized(request); } if (!acceptsJsonLd(request)) { const response = onNotAcceptable(request); return response instanceof Promise ? await response : response; } const jsonLd = await actor.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { Loading Loading @@ -95,12 +100,18 @@ export interface CollectionCallbacks<TItem, TContextData> { * A callback that returns the last cursor for a collection. */ lastCursor?: CollectionCursor<TContextData>; /** * A callback that determines if a request is authorized to access the collection. */ authorizePredicate?: AuthorizePredicate<TContextData>; } export interface CollectionHandlerParameters<TItem, TContextData> { handle: string; context: RequestContext<TContextData>; collectionCallbacks?: CollectionCallbacks<TItem, TContextData>; onUnauthorized(request: Request): Response | Promise<Response>; onNotFound(request: Request): Response | Promise<Response>; onNotAcceptable(request: Request): Response | Promise<Response>; } Loading @@ -114,51 +125,34 @@ export async function handleCollection< handle, context, collectionCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }: CollectionHandlerParameters<TItem, TContextData>, ): Promise<Response> { if (collectionCallbacks == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } if (collectionCallbacks == null) return await onNotFound(request); const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); let collection: OrderedCollection | OrderedCollectionPage; if (cursor == null) { const firstCursorPromise = collectionCallbacks.firstCursor?.( const firstCursor = await collectionCallbacks.firstCursor?.( context, handle, ); const firstCursor = firstCursorPromise instanceof Promise ? await firstCursorPromise : firstCursorPromise; const totalItemsPromise = collectionCallbacks.counter?.(context, handle); const totalItems = totalItemsPromise instanceof Promise ? await totalItemsPromise : totalItemsPromise; const totalItems = await collectionCallbacks.counter?.(context, handle); if (firstCursor == null) { const pagePromise = collectionCallbacks.dispatcher(context, handle, null); const page = pagePromise instanceof Promise ? await pagePromise : pagePromise; if (page == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const page = await collectionCallbacks.dispatcher(context, handle, null); if (page == null) return await onNotFound(request); const { items } = page; collection = new OrderedCollection({ totalItems: totalItems == null ? null : Number(totalItems), items, }); } else { const lastCursorPromise = collectionCallbacks.lastCursor?.( const lastCursor = await collectionCallbacks.lastCursor?.( context, handle, ); const lastCursor = lastCursorPromise instanceof Promise ? await lastCursorPromise : lastCursorPromise; const first = new URL(context.url); first.searchParams.set("cursor", firstCursor); let last = null; Loading @@ -173,14 +167,8 @@ export async function handleCollection< }); } } else { const pagePromise = collectionCallbacks.dispatcher(context, handle, cursor); const page = pagePromise instanceof Promise ? await pagePromise : pagePromise; if (page == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const page = await collectionCallbacks.dispatcher(context, handle, cursor); if (page == null) return await onNotFound(request); const { items, prevCursor, nextCursor } = page; let prev = null; if (prevCursor != null) { Loading @@ -196,9 +184,12 @@ export async function handleCollection< partOf.searchParams.delete("cursor"); collection = new OrderedCollectionPage({ prev, next, items, partOf }); } if (!acceptsJsonLd(request)) { const response = onNotAcceptable(request); return response instanceof Promise ? await response : response; if (!acceptsJsonLd(request)) return await onNotAcceptable(request); if (collectionCallbacks.authorizePredicate != null) { const key = await context.getSignedKey(); if (!await collectionCallbacks.authorizePredicate(context, handle, key)) { return await onUnauthorized(request); } } const jsonLd = await collection.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { Loading
federation/middleware.ts +85 −4 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, ActorKeyPairDispatcher, AuthorizePredicate, CollectionCounter, CollectionCursor, CollectionDispatcher, Loading Loading @@ -470,12 +471,14 @@ export class Federation<TContextData> { const callbacks: ActorCallbacks<TContextData> = { dispatcher }; this.#actorCallbacks = callbacks; const setters: ActorCallbackSetters<TContextData> = { setKeyPairDispatcher: ( dispatcher: ActorKeyPairDispatcher<TContextData>, ) => { setKeyPairDispatcher(dispatcher: ActorKeyPairDispatcher<TContextData>) { callbacks.keyPairDispatcher = dispatcher; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -533,6 +536,10 @@ export class Federation<TContextData> { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -578,6 +585,10 @@ export class Federation<TContextData> { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -623,6 +634,10 @@ export class Federation<TContextData> { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } Loading Loading @@ -801,11 +816,13 @@ export class Federation<TContextData> { { onNotFound, onNotAcceptable, onUnauthorized, contextData, }: FederationFetchOptions<TContextData>, ): Promise<Response> { onNotFound ??= notFound; onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); const route = this.#router.route(url.pathname); if (route == null) { Loading @@ -832,6 +849,8 @@ export class Federation<TContextData> { handle: route.values.handle, context, actorDispatcher: this.#actorCallbacks?.dispatcher, authorizePredicate: this.#actorCallbacks?.authorizePredicate, onUnauthorized, onNotFound, onNotAcceptable, }); Loading @@ -840,6 +859,7 @@ export class Federation<TContextData> { handle: route.values.handle, context, collectionCallbacks: this.#outboxCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); Loading Loading @@ -867,6 +887,7 @@ export class Federation<TContextData> { handle: route.values.handle, context, collectionCallbacks: this.#followingCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); Loading @@ -875,6 +896,7 @@ export class Federation<TContextData> { handle: route.values.handle, context, collectionCallbacks: this.#followersCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); Loading Loading @@ -913,11 +935,21 @@ export interface FederationFetchOptions<TContextData> { * @returns The response to the request. */ onNotAcceptable?: (request: Request) => Response | Promise<Response>; /** * A callback to handle a request when the request is unauthorized. * If not provided, a 401 response is returned. * @param request The request object. * @returns The response to the request. * @since 0.7.0 */ onUnauthorized?: (request: Request) => Response | Promise<Response>; } interface ActorCallbacks<TContextData> { dispatcher?: ActorDispatcher<TContextData>; keyPairDispatcher?: ActorKeyPairDispatcher<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; } /** Loading @@ -942,23 +974,58 @@ export interface ActorCallbackSetters<TContextData> { setKeyPairDispatcher( dispatcher: ActorKeyPairDispatcher<TContextData>, ): ActorCallbackSetters<TContextData>; /** * Specifies the conditions under which requests are authorized. * @param predicate A callback that returns whether a request is authorized. * @returns The setters object so that settings can be chained. * @since 0.7.0 */ authorize( predicate: AuthorizePredicate<TContextData>, ): ActorCallbackSetters<TContextData>; } /** * Additional settings for a collection dispatcher. */ export interface CollectionCallbackSetters<TContextData> { /** * Sets the counter for the collection. * @param counter A callback that returns the number of items in the collection. * @returns The setters object so that settings can be chained. */ setCounter( counter: CollectionCounter<TContextData>, ): CollectionCallbackSetters<TContextData>; /** * Sets the first cursor for the collection. * @param cursor The cursor for the first item in the collection. * @returns The setters object so that settings can be chained. */ setFirstCursor( cursor: CollectionCursor<TContextData>, ): CollectionCallbackSetters<TContextData>; /** * Sets the last cursor for the collection. * @param cursor The cursor for the last item in the collection. * @returns The setters object so that settings can be chained. */ setLastCursor( cursor: CollectionCursor<TContextData>, ): CollectionCallbackSetters<TContextData>; /** * Specifies the conditions under which requests are authorized. * @param predicate A callback that returns whether a request is authorized. * @returns The setters object so that settings can be chained. * @since 0.7.0 */ authorize( predicate: AuthorizePredicate<TContextData>, ): CollectionCallbackSetters<TContextData>; } /** Loading Loading @@ -995,5 +1062,19 @@ function notFound(_request: Request): Response { } function notAcceptable(_request: Request): Response { return new Response("Not Acceptable", { status: 406 }); return new Response("Not Acceptable", { status: 406, headers: { Vary: "Accept, Signature", }, }); } function unauthorized(_request: Request): Response { return new Response("Unauthorized", { status: 401, headers: { Vary: "Accept, Signature", }, }); }