From 276c412a6f70b7e98b6d4bfe7fe3e2ce4e3566cf Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 20 Sep 2024 17:39:32 +0900 Subject: [PATCH] Rename `handle` to `identifier` for consistency --- CHANGES.md | 66 ++ cli/inbox.tsx | 65 +- cli/init.ts | 8 +- docs/manual/access-control.md | 30 +- docs/manual/actor.md | 149 ++-- docs/manual/collections.md | 369 ++++++---- docs/manual/context.md | 37 +- docs/manual/inbox.md | 47 +- docs/manual/object.md | 20 +- docs/manual/pragmatics.md | 8 +- docs/manual/send.md | 109 ++- docs/package.json | 2 +- docs/pnpm-lock.yaml | 10 +- docs/tutorial/basics.md | 108 +-- docs/tutorial/microblog.md | 130 ++-- examples/blog/federation/mod.ts | 117 +-- examples/express/app.ts | 36 +- examples/hono-sample/main.ts | 8 +- .../app/[fedify]/[[...catchAll]]/route.ts | 40 +- src/federation/callback.ts | 49 +- src/federation/context.ts | 167 +++-- src/federation/federation.ts | 67 +- src/federation/handler.test.ts | 102 +-- src/federation/handler.ts | 66 +- src/federation/middleware.test.ts | 327 +++++++-- src/federation/middleware.ts | 680 +++++++++++++----- src/federation/queue.ts | 2 +- src/webfinger/handler.test.ts | 13 +- src/webfinger/handler.ts | 24 +- 29 files changed, 1910 insertions(+), 946 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ff86d73a..9923a63f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,72 @@ Version 1.0.0 To be released. + - The term `handle` for dispatching actors is deprecated in favor of + `identifier`. + + - The URI template for the following methods now accepts variable + `{identifier}` instead of `{handle}`: + + - `Federation.setActorDispatcher()` + - `Federation.setInboxDispatcher()` + - `Federation.setOutboxDispatcher()` + - `Federation.setFollowingDispatcher()` + - `Federation.setFollowersDispatcher()` + - `Federation.setLikedDispatcher()` + - `Federation.setFeaturedDispatcher()` + - `Federation.setFeaturedTagsDispatcher()` + - `Federation.setInboxListeners()` + + The `{handle}` variable is deprecated, and it will be removed in + the future. + - The type of `Federation.setActorDispatcher()` method's first parameter + became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setInboxDispatcher()` method's first parameter + became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setOutboxDispatcher()` method's first parameter + became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setFollowingDispatcher()` method's first + parameter became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setFollowersDispatcher()` method's first + parameter became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setLikedDispatcher()` method's first parameter + became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setFeaturedDispatcher()` method's first + parameter became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setFeaturedTagsDispatcher()` method's first + parameter became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Federation.setInboxListeners()` method's first parameter + became ```${string}{identifier}${string}` | + `${string}{handle}${string}``` (was ```${string}{handle}${string}```). + - The type of `Context.getDocumentLoader()` method's first parameter + became `{ identifier: string } | { username: string } | { handle: + string } | { keyId: URL; privateKey: CryptoKey }` (was `{ handle: + string } | { keyId: URL; privateKey: CryptoKey }`). + - Passing `{ handle: string }` to `Context.getDocumentLoader()` method is + deprecated in favor of `{ username: string }`. + - The type of `Context.sendActivity()` method's first parameter became + `SenderKeyPair | SenderKeyPair[] | { identifier: string } | { + username: string } | { handle: string }` (was `SenderKeyPair | SenderKeyPair[] | { handle: string }`). + - All properties of `ParseUriResult` type became readonly. + - Added `identifier` properties next to `handle` properties in + `ParseUriResult` type. + - The `handle` properties of `ParseUriResult` type are deprecated in favor + of `identifier` properties. + - The return type of `SharedInboxKeyDispatcher` callback type became + `SenderKeyPair | { identifier: string } | { username: string } | + { handle: string } | null | Promise` + (was `SenderKeyPair | { handle: string } | null | + Promise`). + - Fedify now supports [Linked Data Signatures], which is outdated but still widely used in the fediverse. diff --git a/cli/inbox.tsx b/cli/inbox.tsx index 63f67acf..c769f95b 100644 --- a/cli/inbox.tsx +++ b/cli/inbox.tsx @@ -96,7 +96,9 @@ export const command = new Command() } if (options.follow != null && options.follow.length > 0) { spinner.text = "Following actors..."; - const documentLoader = await fedCtx.getDocumentLoader({ handle: "i" }); + const documentLoader = await fedCtx.getDocumentLoader({ + identifier: "i", + }); for (const uri of options.follow) { spinner.text = `Following ${colors.green(uri)}...`; const actor = await lookupObject(uri, { documentLoader }); @@ -107,7 +109,7 @@ export const command = new Command() } if (actor.id != null) peers[actor.id?.href] = actor; await fedCtx.sendActivity( - { handle: "i" }, + { identifier: "i" }, actor, new Follow({ id: new URL(`#follows/${actor.id?.href}`, fedCtx.getActorUri("i")), @@ -132,34 +134,34 @@ const time = Temporal.Now.instant(); let actorKeyPairs: CryptoKeyPair[] | undefined = undefined; federation - .setActorDispatcher("/{handle}", async (ctx, handle) => { - if (handle !== "i") return null; + .setActorDispatcher("/{identifier}", async (ctx, identifier) => { + if (identifier !== "i") return null; return new Application({ - id: ctx.getActorUri(handle), - preferredUsername: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, name: "Fedify Ephemeral Inbox", summary: "An ephemeral ActivityPub inbox for testing purposes.", - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), - followers: ctx.getFollowersUri(handle), - following: ctx.getFollowingUri(handle), - outbox: ctx.getOutboxUri(handle), + followers: ctx.getFollowersUri(identifier), + following: ctx.getFollowingUri(identifier), + outbox: ctx.getOutboxUri(identifier), manuallyApprovesFollowers: true, published: time, icon: new Image({ url: new URL("https://fedify.dev/logo.png"), mediaType: "image/png", }), - publicKey: (await ctx.getActorKeyPairs(handle))[0].cryptographicKey, - assertionMethods: (await ctx.getActorKeyPairs(handle)) + publicKey: (await ctx.getActorKeyPairs(identifier))[0].cryptographicKey, + assertionMethods: (await ctx.getActorKeyPairs(identifier)) .map((pair) => pair.multikey), - url: ctx.getActorUri(handle), + url: ctx.getActorUri(identifier), }); }) - .setKeyPairsDispatcher(async (_ctxData, handle) => { - if (handle !== "i") return []; + .setKeyPairsDispatcher(async (_ctxData, identifier) => { + if (identifier !== "i") return []; if (actorKeyPairs == null) { actorKeyPairs = [ await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"), @@ -195,7 +197,7 @@ async function sendDeleteToPeers(server: TemporaryServer): Promise { const ctx = federation.createContext(server.url, -1); const actorId = ctx.getActorUri("i"); await ctx.sendActivity( - { handle: "i" }, + { identifier: "i" }, Object.values(peers), new Delete({ id: new URL(`#delete`, actorId), @@ -209,8 +211,8 @@ async function sendDeleteToPeers(server: TemporaryServer): Promise { const followers: Record = {}; federation - .setInboxListeners("/{handle}/inbox", "/inbox") - .setSharedKeyDispatcher((_) => ({ handle: "i" })) + .setInboxListeners("/{identifier}/inbox", "/inbox") + .setSharedKeyDispatcher((_) => ({ identifier: "i" })) .on(Activity, async (ctx, activity) => { activities[ctx.data].activity = activity; for await (const actor of activity.getActors()) { @@ -224,8 +226,8 @@ federation const objectId = activity.objectId; if (objectId == null) return; const parsed = ctx.parseUri(objectId); - if (parsed?.type !== "actor" || parsed.handle !== "i") return; - const { handle } = parsed; + if (parsed?.type !== "actor" || parsed.identifier !== "i") return; + const { identifier } = parsed; const follower = await activity.getActor(); if (!isActor(follower)) return; const accepts = await acceptsFollowFrom(follower); @@ -240,11 +242,11 @@ federation }); followers[activity.id.href] = follower; await ctx.sendActivity( - { handle }, + { identifier }, follower, new Accept({ id: new URL(`#accepts/${follower.id?.href}`, ctx.getActorUri("i")), - actor: ctx.getActorUri(handle), + actor: ctx.getActorUri(identifier), object: activity.id, }), ); @@ -252,8 +254,8 @@ federation }); federation - .setFollowersDispatcher("/{handle}/followers", (_ctx, handle) => { - if (handle !== "i") return null; + .setFollowersDispatcher("/{identifier}/followers", (_ctx, identifier) => { + if (identifier !== "i") return null; const items: Recipient[] = []; for (const follower of Object.values(followers)) { if (follower.id == null) continue; @@ -261,18 +263,21 @@ federation } return { items }; }) - .setCounter((_ctx, handle) => { - if (handle !== "i") return null; + .setCounter((_ctx, identifier) => { + if (identifier !== "i") return null; return Object.keys(followers).length; }); federation - .setFollowingDispatcher("/{handle}/following", (_ctx, _handle) => null) - .setCounter((_ctx, _handle) => 0); + .setFollowingDispatcher( + "/{identifier}/following", + (_ctx, _identifier) => null, + ) + .setCounter((_ctx, _identifier) => 0); federation - .setOutboxDispatcher("/{handle}/outbox", (_ctx, _handle) => null) - .setCounter((_ctx, _handle) => 0); + .setOutboxDispatcher("/{identifier}/outbox", (_ctx, _identifier) => null) + .setCounter((_ctx, _identifier) => 0); federation.setNodeInfoDispatcher("/nodeinfo/2.1", (_ctx) => { return { diff --git a/cli/init.ts b/cli/init.ts index 305a29bf..cf0d2bff 100644 --- a/cli/init.ts +++ b/cli/init.ts @@ -845,11 +845,11 @@ const federation = createFederation({ queue: ${mqDesc.object}, }); -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { return new Person({ - id: ctx.getActorUri(handle), - preferredUsername: handle, - name: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: identifier, }); }); diff --git a/docs/manual/access-control.md b/docs/manual/access-control.md index a6fe1bd1..e29f5057 100644 --- a/docs/manual/access-control.md +++ b/docs/manual/access-control.md @@ -42,11 +42,11 @@ import type { Actor, Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; /** * A hypothetical function that checks if the user blocks the actor. - * @param handle The handle of the user to check if the actor is blocked. + * @param userId The ID of the user to check if the actor is blocked. * @param signedKeyOwner The actor who signed the request. * @returns `true` if the actor is blocked; otherwise, `false`. */ -async function isBlocked(handle: string, signedKeyOwner: Actor): Promise { +async function isBlocked(userId: string, signedKeyOwner: Actor): Promise { return false; } // ---cut-before--- @@ -54,12 +54,12 @@ import { federation } from "./your-federation.ts"; import { isBlocked } from "./your-blocklist.ts"; federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { // Omitted for brevity; see the related section for details. }) - .authorize(async (ctx, handle, signedKey, signedKeyOwner) => { + .authorize(async (ctx, identifier, signedKey, signedKeyOwner) => { if (signedKeyOwner == null) return false; - return !await isBlocked(handle, signedKeyOwner); + return !await isBlocked(identifier, signedKeyOwner); }); ~~~~ @@ -74,11 +74,11 @@ import type { Actor, Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; /** * A hypothetical function that checks if the user blocks the actor. - * @param handle The handle of the user to check if the actor is blocked. + * @param userId The ID of the user to check if the actor is blocked. * @param signedKeyOwner The actor who signed the request. * @returns `true` if the actor is blocked; otherwise, `false`. */ -async function isBlocked(handle: string, signedKeyOwner: Actor): Promise { +async function isBlocked(userId: string, signedKeyOwner: Actor): Promise { return false; } // ---cut-before--- @@ -86,12 +86,12 @@ import { federation } from "./your-federation.ts"; import { isBlocked } from "./your-blocklist.ts"; federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier) => { // Omitted for brevity; see the related section for details. }) - .authorize(async (ctx, handle, signedKey, signedKeyOwner) => { + .authorize(async (ctx, identifier, signedKey, signedKeyOwner) => { if (signedKeyOwner == null) return false; - return !await isBlocked(handle, signedKeyOwner); + return !await isBlocked(identifier, signedKeyOwner); }); ~~~~ @@ -126,10 +126,10 @@ interface Post { } /** * A hypothetical function that gets posts from the database. - * @param handle The handle of the user to get posts. + * @param userId The ID of the user to get posts. * @returns The posts of the user. */ -async function getPosts(handle: string): Promise { +async function getPosts(userId: string): Promise { return []; } /** @@ -145,8 +145,8 @@ import { federation } from "./your-federation.ts"; import { getPosts, toCreate } from "./your-model.ts"; federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle) => { - const posts = await getPosts(handle); // Get posts from the database + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier) => { + const posts = await getPosts(identifier); // Get posts from the database const keyOwner = await ctx.getSignedKeyOwner(); // Get the actor who signed the request if (keyOwner == null) return { items: [] }; // Return an empty array if the actor is not found const items = posts @@ -155,3 +155,5 @@ federation return { items }; }); ~~~~ + + diff --git a/docs/manual/actor.md b/docs/manual/actor.md index 5e70f06d..f9663a1c 100644 --- a/docs/manual/actor.md +++ b/docs/manual/actor.md @@ -1,7 +1,7 @@ --- description: >- You can register an actor dispatcher so that Fedify can dispatch - an appropriate actor by its bare handle. This section explains + an appropriate actor by its identifier. This section explains how to register an actor dispatcher and the key properties of an actor. --- @@ -10,12 +10,11 @@ Actor dispatcher In ActivityPub, [actors] are entities that can perform [activities]. You can register an actor dispatcher so that Fedify can dispatch an appropriate actor -by its bare handle (i.e., handle without @ prefix and domain suffix). -Since the actor dispatcher is the most significant part of the Fedify, -it is the first thing you need to do to make Fedify work. +by its identifier. Since the actor dispatcher is the most significant part of +the Fedify, it is the first thing you need to do to make Fedify work. An actor dispatcher is a callback function that takes a `Context` object and -a bare handle, and returns an actor object. The actor object can be one of +an identifier, and returns an actor object. The actor object can be one of the following: - `Application` @@ -39,31 +38,83 @@ const federation = createFederation({ // Omitted for brevity; see the related section for details. }); -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - // Work with the database to find the actor by the handle. +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + // Work with the database to find the actor by the identifier. if (user == null) return null; // Return null if the actor is not found. return new Person({ - id: ctx.getActorUri(handle), - preferredUsername: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, // Many more properties; see the next section for details. }); }); ~~~~ In the above example, the `~Federation.setActorDispatcher()` method registers -an actor dispatcher for the `/users/{handle}` path. This pattern syntax +an actor dispatcher for the `/users/{identifier}` path. This pattern syntax follows the [URI Template] specification. > [!TIP] > By registering the actor dispatcher, `Federation.fetch()` automatically > deals with [WebFinger] requests for the actor. +> [!TIP] +> By default, Fedify assumes that the actor's identifier is the WebFinger +> username. If you want to decouple the WebFinger username from the actor's +> identifier, you can register an actor handle mapper through the +> `~ActorCallbackSetters.mapHandle()` method. +> +> See the [next section](#decoupling-actor-uris-from-webfinger-usernames) +> for details. + [actors]: https://www.w3.org/TR/activitystreams-core/#actors [activities]: https://www.w3.org/TR/activitystreams-core/#activities [URI Template]: https://datatracker.ietf.org/doc/html/rfc6570 [WebFinger]: https://datatracker.ietf.org/doc/html/rfc7033 +Actor identifier and WebFinger username +--------------------------------------- + +An actor *identifier* is a unique string that identifies the actor. It can be +a username, a UUID, or any other unique string. The actor identifier is used +as a URL parameter in the actor dispatcher and other dispatchers. It's usually +used to find the actor in your database, i.e., primary key. + +A WebFinger *username* is a string that comes before the domain part of the +fediverse handle, e.g., `hongminhee` in `@hongminhee@fosstodon.org`. +The WebFinger username is also used as the `preferredUsername` property of +the actor. It's usually displayed in the user interface, and used to find +the actor by the WebFinger protocol, i.e., looking up the fediverse handle +in the search box. It's also called the *bare handle*. + +By default, Fedify assumes that the actor's identifier is the WebFinger username, but you can decouple the WebFinger username from the actor's identifier if you want. You can think of the difference between these +two approaches as analogous to [natural key] vs. [surrogate key] in the database +design. + +There are pros and cons to using the WebFinger username as the actor's identifier (which is Fedify's default): + +Pros +: - The actor URI is more predictable and human-readable, + which makes debugging easier. + - The internal ID of the actor can be hidden from the public. + +Cons +: - Changing the WebFinger username may break the existing network. + Hence, the fediverse handle is immutable in practice. + - It's usually treated as an anti-pattern in the fediverse. + +You need to choose the best approach for you before implementing the actor +dispatcher. If you decided to use the WebFinger username as the actor's +identifier, there's nothing to do—Fedify assumes it by default. + +If you decided to decouple the WebFinger username from the actor's identifier, +see the [next section](#decoupling-actor-uris-from-webfinger-usernames) for +details. + +[natural key]: https://en.wikipedia.org/wiki/Natural_key +[surrogate key]: https://en.wikipedia.org/wiki/Surrogate_key + + Key properties of an `Actor` ---------------------------- @@ -76,13 +127,14 @@ the key properties of an `Actor` object: The `~Object.id` property is the URI of the actor. It is a required property in ActivityPub. You can use the `Context.getActorUri()` method to generate -the dereferenceable URI of the actor by its bare handle. +the dereferenceable URI of the actor by its identifier. ### `preferredUsername` -The `preferredUsername` property is the bare handle of the actor. For the most -cases, it is okay to set the `preferredUsername` property to the string taken -from the `handle` parameter of the actor dispatcher. +The `preferredUsername` property is the WebFinger username of the actor. +Unless [you decouple the WebFinger username from the actor's +identifier](#decoupling-actor-uris-from-webfinger-usernames), it is okay to +set the `preferredUsername` property to the actor's identifier. ### `name` @@ -183,7 +235,7 @@ a `CryptographicKey` instance, and the `assertionMethods` property contains an array of `Multikey` instances. Usually you don't have to create them manually. Instead, you can register a key pairs dispatcher through the `~ActorCallbackSetters.setKeyPairsDispatcher()` method so that Fedify can -dispatch appropriate key pairs by the actor's bare handle: +dispatch appropriate key pairs by the actor's identifier: ~~~~ typescript{4-6,10-14,17-26} twoslash import { type Federation, Person } from "@fedify/fedify"; @@ -195,15 +247,15 @@ const privateKey1 = null as unknown as CryptoKey; const publicKey2 = null as unknown as CryptoKey; const privateKey2 = null as unknown as CryptoKey; // ---cut-before--- -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - // Work with the database to find the actor by the handle. +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + // Work with the database to find the actor by the identifier. if (user == null) return null; // Return null if the actor is not found. // Context.getActorKeyPairs() method dispatches the key pairs of an actor - // by the handle, and returns an array of key pairs in various formats: - const keys = await ctx.getActorKeyPairs(handle); + // by the identifier, and returns an array of key pairs in various formats: + const keys = await ctx.getActorKeyPairs(identifier); return new Person({ - id: ctx.getActorUri(handle), - preferredUsername: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, // For the publicKey property, we only use first CryptographicKey: publicKey: keys[0].cryptographicKey, // For the assertionMethods property, we use all Multikey instances: @@ -211,8 +263,8 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { // Many more properties; see the previous section for details. }); }) - .setKeyPairsDispatcher(async (ctx, handle) => { - // Work with the database to find the key pair by the handle. + .setKeyPairsDispatcher(async (ctx, identifier) => { + // Work with the database to find the key pair by the identifier. if (user == null) return []; // Return null if the key pair is not found. // Return the loaded key pair. See the below example for details. return [ @@ -225,7 +277,7 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { In the above example, the `~ActorCallbackSetters.setKeyPairsDispatcher()` method registers a key pairs dispatcher. The key pairs dispatcher is a callback -function that takes context data and a bare handle, and returns an array of +function that takes context data and an identifier, and returns an array of [`CryptoKeyPair`] object which is defined in the Web Cryptography API. Usually, you need to generate key pairs for each actor when the actor is @@ -238,18 +290,18 @@ this document, but here's a simple example of how to generate key pairs and store them in a [Deno KV] database in form of JWK: ~~~~ typescript twoslash -const handle: string = ""; +const identifier: string = ""; // ---cut-before--- import { generateCryptoKeyPair, exportJwk } from "@fedify/fedify"; const kv = await Deno.openKv(); const rsaPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); const ed25519Pair = await generateCryptoKeyPair("Ed25519"); -await kv.set(["keypair", "rsa", handle], { +await kv.set(["keypair", "rsa", identifier], { privateKey: await exportJwk(rsaPair.privateKey), publicKey: await exportJwk(rsaPair.publicKey), }); -await kv.set(["keypair", "ed25519", handle], { +await kv.set(["keypair", "ed25519", identifier], { privateKey: await exportJwk(ed25519Pair.privateKey), publicKey: await exportJwk(ed25519Pair.publicKey), }); @@ -291,14 +343,14 @@ interface KeyPairEntry { } federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { // Omitted for brevity; see the previous example for details. }) - .setKeyPairsDispatcher(async (ctx, handle) => { + .setKeyPairsDispatcher(async (ctx, identifier) => { const kv = await Deno.openKv(); const result: CryptoKeyPair[] = []; const rsaPair = await kv.get( - ["keypair", "rsa", handle], + ["keypair", "rsa", identifier], ); if (rsaPair?.value != null) { result.push({ @@ -307,7 +359,7 @@ federation }); } const ed25519Pair = await kv.get( - ["keypair", "ed25519", handle], + ["keypair", "ed25519", identifier], ); if (ed25519Pair?.value != null) { result.push({ @@ -327,7 +379,7 @@ Constructing actor URIs ----------------------- To construct an actor URI, you can use the `Context.getActorUri()` method. -This method takes a bare handle and returns a dereferenceable URI of the actor. +This method takes an identifier and returns a dereferenceable URI of the actor. The below example shows how to construct an actor URI: @@ -339,13 +391,25 @@ ctx.getActorUri("john_doe") ~~~~ In the above example, the `Context.getActorUri()` method generates the -dereferenceable URI of the actor with the bare handle `"john_doe"`. +dereferenceable URI of the actor with the identifier `"john_doe"`. + +If you [decouple the WebFinger username from the actor's +identifier](#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in the actor dispatcher to +the `Context.getActorUri()` method, not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getActorUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ > [!NOTE] > > The `Context.getActorUri()` method does not guarantee that the actor > URI is always dereferenceable for every argument. Make sure that -> the argument is a valid bare handle before calling the method. +> the argument is a valid identifier before calling the method. Decoupling actor URIs from WebFinger usernames @@ -359,8 +423,8 @@ Decoupling actor URIs from WebFinger usernames > `acct:fedify@hollo.social` URI or the `@fedify@hollo.social` handle > is `fedify`. -By default, Fedify uses the bare handle as the WebFinger username. However, -you can decouple the WebFinger username from the bare handle by registering +By default, Fedify uses the identifier as the WebFinger username. However, +you can decouple the WebFinger username from the identifier by registering an actor handle mapper through the `~ActorCallbackSetters.mapHandle()` method: ~~~~ typescript twoslash @@ -382,21 +446,22 @@ function findUserByUuid(uuid: string): User; function findUserByUsername(username: string): User; // ---cut-before--- federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { - // Since we map a WebFinger handle to the corresponding user's UUID below, - // the `handle` parameter is the user's UUID, not the WebFinger username: - const user = await findUserByUuid(handle); + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + // Since we map a WebFinger username to the corresponding user's UUID below, + // the `identifier` parameter is the user's UUID, not the WebFinger + // username: + const user = await findUserByUuid(identifier); // Omitted for brevity; see the previous example for details. }) .mapHandle(async (ctx, username) => { - // Work with the database to find the WebFinger username by the handle. + // Work with the database to find the user's UUID by the WebFinger username. const user = await findUserByUsername(username); if (user == null) return null; // Return null if the actor is not found. return user.uuid; }); ~~~~ -Decoupling the WebFinger username from the bare handle is useful when you want +Decoupling the WebFinger username from the identifier is useful when you want to let users change their WebFinger username without breaking the existing network, because changing the WebFinger username does not affect the actor URI. diff --git a/docs/manual/collections.md b/docs/manual/collections.md index 8444c3d1..2bae6ba0 100644 --- a/docs/manual/collections.md +++ b/docs/manual/collections.md @@ -37,14 +37,14 @@ import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier) => { // Work with the database to find the activities that the actor has sent. // Omitted for brevity. See the next example for details. }); ~~~~ Each actor has its own outbox collection, so the URI pattern of the outbox -dispatcher should include the actor's bare `{handle}`. The URI pattern syntax +dispatcher should include the actor's `{identifier}`. The URI pattern syntax follows the [URI Template] specification. Since the outbox is a collection of activities, the outbox dispatcher should @@ -73,23 +73,23 @@ interface Post { } /** * A hypothetical function that returns the posts that an actor has sent. - * @param handle The actor's handle. + * @param identifier The actor's identifier. * @returns The posts that the actor has sent. */ -function getPostsByUserHandle(handle: string): Post[] { return []; } +function getPostsByUserId(userId: string): Post[] { return []; } // ---cut-before--- import { Article, Create } from "@fedify/fedify"; federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier) => { // Work with the database to find the activities that the actor has sent - // (the following `getPostsByUserHandle` is a hypothetical function): - const posts = await getPostsByUserHandle(handle); + // (the following `getPostsByUserId` is a hypothetical function): + const posts = await getPostsByUserId(identifier); // Turn the posts into `Create` activities: const items = posts.map(post => new Create({ id: new URL(`/posts/${post.id}#activity`, ctx.url), - actor: ctx.getActorUri(handle), + actor: ctx.getActorUri(identifier), object: new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, @@ -209,9 +209,9 @@ interface PostResultSet { } /** * A hypothetical type that represents the options for - * the `getPostsByUserHandle` function. + * the `getPostsByUserId` function. */ -interface GetPostsByUserHandleOptions { +interface GetPostsByUserIdOptions { /** * The cursor that represents the position of the current page. */ @@ -223,25 +223,25 @@ interface GetPostsByUserHandleOptions { } /** * A hypothetical function that returns the posts that an actor has sent. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The result set that contains the posts, the next cursor, and whether * the current page is the last page. */ -function getPostsByUserHandle( - handle: string, - options: GetPostsByUserHandleOptions, +function getPostsByUserId( + userId: string, + options: GetPostsByUserIdOptions, ): PostResultSet { return { posts: [], nextCursor: null, last: true }; } // ---cut-before--- federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle, cursor) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier, cursor) => { // If a whole collection is requested, returns nothing as we prefer // collection pages over the whole collection: if (cursor == null) return null; // Work with the database to find the activities that the actor has sent - // (the following `getPostsByUserHandle` is a hypothetical function): - const { posts, nextCursor, last } = await getPostsByUserHandle(handle, { + // (the following `getPostsByUserId` is a hypothetical function): + const { posts, nextCursor, last } = await getPostsByUserId(identifier, { cursor, limit: 10, }); @@ -249,7 +249,7 @@ federation const items = posts.map(post => new Create({ id: new URL(`/posts/${post.id}#activity`, ctx.url), - actor: ctx.getActorUri(handle), + actor: ctx.getActorUri(identifier), object: new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, @@ -265,7 +265,7 @@ federation }); ~~~~ -In the above example, the hypothetical `getPostsByUserHandle()` function returns +In the above example, the hypothetical `getPostsByUserId()` function returns the `nextCursor` along with the `items`. The `nextCursor` represents the position of the next page, which is provided by the database system. If the `last` is `true`, it means that the current page is the last page, so the @@ -321,9 +321,9 @@ interface PostResultSet { } /** * A hypothetical type that represents the options for - * the `getPostsByUserHandle` function. + * the `getPostsByUserId` function. */ -interface GetPostsByUserHandleOptions { +interface GetPostsByUserIdOptions { /** * The cursor that represents the position of the current page. */ @@ -335,13 +335,13 @@ interface GetPostsByUserHandleOptions { } /** * A hypothetical function that returns the posts that an actor has sent. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The result set that contains the posts, the next cursor, and whether * the current page is the last page. */ -function getPostsByUserHandle( - handle: string, - options: GetPostsByUserHandleOptions, +function getPostsByUserId( + userId: string, + options: GetPostsByUserIdOptions, ): PostResultSet { return { posts: [], nextCursor: null, last: true }; } @@ -350,18 +350,18 @@ function getPostsByUserHandle( const window = 10; federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle, cursor) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier, cursor) => { if (cursor == null) return null; - // The following `getPostsByUserHandle` is a hypothetical function: - const { posts, nextCursor, last } = await getPostsByUserHandle( - handle, + // The following `getPostsByUserId` is a hypothetical function: + const { posts, nextCursor, last } = await getPostsByUserId( + identifier, cursor === "" ? { limit: window } : { cursor, limit: window } ); // Turn the posts into `Create` activities: const items = posts.map(post => new Create({ id: new URL(`/posts/${post.id}#activity`, ctx.url), - actor: ctx.getActorUri(handle), + actor: ctx.getActorUri(identifier), object: new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, @@ -371,7 +371,7 @@ federation ); return { items, nextCursor: last ? null : nextCursor } }) - .setFirstCursor(async (ctx, handle) => { + .setFirstCursor(async (ctx, identifier) => { // Let's assume that an empty string represents the beginning of the // collection: return ""; // Note that it's not `null`. @@ -406,20 +406,20 @@ const federation = null as unknown as Federation; /** * A hypothetical function that counts the number of posts that an actor has * sent. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The number of posts that the actor has sent. */ -async function countPostsByUserHandle(handle: string): Promise { +async function countPostsByUserId(userId: string): Promise { return 0; } // ---cut-before--- federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle, cursor) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier, cursor) => { // Omitted for brevity. }) - .setCounter(async (ctx, handle) => { - // The following `countPostsByUserHandle` is a hypothetical function: - return await countPostsByUserHandle(handle); + .setCounter(async (ctx, identifier) => { + // The following `countPostsByUserId` is a hypothetical function: + return await countPostsByUserId(identifier); }); ~~~~ @@ -461,9 +461,9 @@ interface Post { } /** * A hypothetical type that represents the options for - * the `getPostsByUserHandle` function. + * the `getPostsByUserId` function. */ -interface GetPostsByUserHandleOptions { +interface GetPostsByUserIdOptions { /** * The offset of the current page. */ @@ -475,23 +475,23 @@ interface GetPostsByUserHandleOptions { } /** * A hypothetical function that returns the posts that an actor has sent. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The result set that contains the posts, the next cursor, and whether * the current page is the last page. */ -function getPostsByUserHandle( - handle: string, - options: GetPostsByUserHandleOptions, +function getPostsByUserId( + userId: string, + options: GetPostsByUserIdOptions, ): Post[] { return []; } /** * A hypothetical function that counts the number of posts that an actor has * sent. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The number of posts that the actor has sent. */ -async function countPostsByUserHandle(handle: string): Promise { +async function countPostsByUserId(userId: string): Promise { return 0; } // ---cut-before--- @@ -499,20 +499,20 @@ async function countPostsByUserHandle(handle: string): Promise { const window = 10; federation - .setOutboxDispatcher("/users/{handle}/outbox", async (ctx, handle, cursor) => { + .setOutboxDispatcher("/users/{identifier}/outbox", async (ctx, identifier, cursor) => { if (cursor == null) return null; // Here we use the offset numeric value as the cursor: const offset = parseInt(cursor); - // The following `getPostsByUserHandle` is a hypothetical function: - const posts = await getPostsByUserHandle( - handle, + // The following `getPostsByUserId` is a hypothetical function: + const posts = await getPostsByUserId( + identifier, { offset, limit: window } ); // Turn the posts into `Create` activities: const items = posts.map(post => new Create({ id: new URL(`/posts/${post.id}#activity`, ctx.url), - actor: ctx.getActorUri(handle), + actor: ctx.getActorUri(identifier), object: new Article({ id: new URL(`/posts/${post.id}`, ctx.url), summary: post.title, @@ -522,10 +522,10 @@ federation ); return { items, nextCursor: (offset + window).toString() } }) - .setFirstCursor(async (ctx, handle) => "0") - .setLastCursor(async (ctx, handle) => { - // The following `countPostsByUserHandle` is a hypothetical function: - const total = await countPostsByUserHandle(handle); + .setFirstCursor(async (ctx, identifier) => "0") + .setLastCursor(async (ctx, identifier) => { + // The following `countPostsByUserId` is a hypothetical function: + const total = await countPostsByUserId(identifier); // The last cursor is the offset of the last page: return (total - (total % window)).toString(); }); @@ -534,8 +534,8 @@ federation ### Constructing outbox collection URIs To construct an outbox collection URI, you can use the `Context.getOutboxUri()` -method. This method takes the actor's handle and returns the dereferenceable -URI of the outbox collection of the actor. +method. This method takes the actor's identifier and returns +the dereferenceable URI of the outbox collection of the actor. The following shows how to construct an outbox collection URI of an actor named `"alice"`: @@ -547,12 +547,25 @@ const ctx = null as unknown as Context; ctx.getOutboxUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getOutboxUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getOutboxUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > > The `Context.getOutboxUri()` method does not guarantee that the outbox > collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check if the -> handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check if +> the identifier is valid before calling the method. Inbox @@ -574,34 +587,34 @@ const federation = null as unknown as Federation; /** * A hypothetical function that returns the activities that an actor has * received. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The activities that the actor has received. */ -async function getInboxByUserHandle(handle: string): Promise { +async function getInboxByUserId(userId: string): Promise { return []; } /** * A hypothetical function that counts the number of activities that an actor * has received. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The number of activities that the actor has received. */ -async function countInboxByUserHandle(handle: string): Promise { +async function countInboxByUserId(userId: string): Promise { return 0; } // ---cut-before--- import { Activity } from "@fedify/fedify"; federation - .setInboxDispatcher("/users/{handle}/inbox", async (ctx, handle) => { + .setInboxDispatcher("/users/{identifier}/inbox", async (ctx, identifier) => { // 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); + // (the following `getInboxByUserId` is a hypothetical function): + const items: Activity[] = await getInboxByUserId(identifier); return { items }; }) - .setCounter(async (ctx, handle) => { - // The following `countInboxByUserHandle` is a hypothetical function: - return await countInboxByUserHandle(handle); + .setCounter(async (ctx, identifier) => { + // The following `countInboxByUserId` is a hypothetical function: + return await countInboxByUserId(identifier); }); ~~~~ @@ -612,8 +625,8 @@ federation ### Constructing inbox collection URIs To construct an inbox collection URI, you can use the `Context.getInboxUri()` -method. This method takes the actor's handle and returns the dereferenceable -URI of the inbox collection of the actor. +method. This method takes the actor's identifier and returns +the dereferenceable URI of the inbox collection of the actor. The following shows how to construct an inbox collection URI of an actor named `"alice"`: @@ -625,12 +638,25 @@ const ctx = null as unknown as Context; ctx.getInboxUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getInboxUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getInboxUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > > The `Context.getInboxUri()` method does not guarantee that the inbox > collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check if the -> handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check if +> the identifier is valid before calling the method. Following @@ -679,27 +705,27 @@ interface ResultSet { } /** * A hypothetical function that returns the actors that an actor is following. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @param options The options for the query. * @returns The actors that the actor is following, the next cursor, and whether * the current page is the last page. */ -async function getFollowingByUserHandle( - handle: string, +async function getFollowingByUserId( + identifier: string, options: { cursor?: string | null; limit: number }, ): Promise { return { users: [], nextCursor: null, last: true }; } // ---cut-before--- federation - .setFollowingDispatcher("/users/{handle}/following", async (ctx, handle, cursor) => { + .setFollowingDispatcher("/users/{identifier}/following", async (ctx, identifier, cursor) => { // If a whole collection is requested, returns nothing as we prefer // collection pages over the whole collection: if (cursor == null) return null; // Work with the database to find the actors that the actor is following - // (the below `getFollowingByUserHandle` is a hypothetical function): - const { users, nextCursor, last } = await getFollowingByUserHandle( - handle, + // (the below `getFollowingByUserId` is a hypothetical function): + const { users, nextCursor, last } = await getFollowingByUserId( + identifier, cursor === "" ? { limit: 10 } : { cursor, limit: 10 } ); // Turn the users into `URL` objects: @@ -707,14 +733,15 @@ federation return { items, nextCursor: last ? null : nextCursor } }) // The first cursor is an empty string: - .setFirstCursor(async (ctx, handle) => ""); + .setFirstCursor(async (ctx, identifier) => ""); ~~~~ ### Constructing following collection URIs To construct a following collection URI, you can use -the `Context.getFollowingUri()` method. This method takes the actor's handle -and returns the dereferenceable URI of the following collection of the actor. +the `Context.getFollowingUri()` method. This method takes the actor's +identifier and returns the dereferenceable URI of the following collection +of the actor. The following shows how to construct a following collection URI of an actor named `"alice"`: @@ -726,11 +753,24 @@ const ctx = null as unknown as Context; ctx.getFollowingUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getFollowingUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getFollowingUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > The `Context.getFollowingUri()` method does not guarantee that the following > collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check if the -> handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check if +> the identifier is valid before calling the method. Followers @@ -778,9 +818,9 @@ interface ResultSet { } /** * A hypothetical type that represents the options for - * the `getFollowersByUserHandle` function. + * the `getFollowersByUserId` function. */ -interface GetFollowersByUserHandleOptions { +interface GetFollowersByUserIdOptions { /** * The cursor that represents the position of the current page. */ @@ -792,29 +832,29 @@ interface GetFollowersByUserHandleOptions { } /** * A hypothetical function that returns the actors that are following an actor. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @param options The options for the query. * @returns The actors that are following the actor, the next cursor, and * whether the current page is the last page. */ -async function getFollowersByUserHandle( - handle: string, - options: GetFollowersByUserHandleOptions, +async function getFollowersByUserId( + userId: string, + options: GetFollowersByUserIdOptions, ): Promise { return { users: [], nextCursor: null, last: true }; } // ---cut-before--- federation .setFollowersDispatcher( - "/users/{handle}/followers", - async (ctx, handle, cursor) => { + "/users/{identifier}/followers", + async (ctx, identifier, cursor) => { // If a whole collection is requested, returns nothing as we prefer // collection pages over the whole collection: if (cursor == null) return null; // Work with the database to find the actors that are following the actor - // (the below `getFollowersByUserHandle` is a hypothetical function): - const { users, nextCursor, last } = await getFollowersByUserHandle( - handle, + // (the below `getFollowersByUserId` is a hypothetical function): + const { users, nextCursor, last } = await getFollowersByUserId( + identifier, cursor === "" ? { limit: 10 } : { cursor, limit: 10 } ); // Turn the users into `Recipient` objects: @@ -826,7 +866,7 @@ federation } ) // The first cursor is an empty string: - .setFirstCursor(async (ctx, handle) => ""); + .setFirstCursor(async (ctx, identifier) => ""); ~~~~ > [!TIP] @@ -876,20 +916,20 @@ interface User { } /** * A hypothetical function that returns the actors that are following an actor. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The actors that are following the actor. */ -async function getFollowersByUserHandle(handle: string): Promise { +async function getFollowersByUserId(userId: string): Promise { return []; } // ---cut-before--- federation .setFollowersDispatcher( - "/users/{handle}/followers", - async (ctx, handle, cursor, baseUri) => { + "/users/{identifier}/followers", + async (ctx, identifier, cursor, baseUri) => { // Work with the database to find the actors that are following the actor - // (the below `getFollowersByUserHandle` is a hypothetical function): - let users = await getFollowersByUserHandle(handle); + // (the below `getFollowersByUserId` is a hypothetical function): + let users = await getFollowersByUserId(identifier); // Filter the actors by the base URI: if (baseUri != null) { users = users.filter(actor => actor.uri.href.startsWith(baseUri.href)); @@ -912,8 +952,9 @@ federation ### Constructing followers collection URIs To construct a followers collection URI, you can use -the `Context.getFollowersUri()` method. This method takes the actor's handle -and returns the dereferenceable URI of the followers collection of the actor. +the `Context.getFollowersUri()` method. This method takes the actor's +identifier and returns the dereferenceable URI of the followers collection +of the actor. The following shows how to construct a followers collection URI of an actor named `"alice"`: @@ -925,12 +966,25 @@ const ctx = null as unknown as Context; ctx.getFollowersUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getFollowersUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getFollowersUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > > The `Context.getFollowersUri()` method does not guarantee that the followers > collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check if the -> handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check if +> the identifier is valid before calling the method. Liked @@ -952,20 +1006,20 @@ import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; /** * A hypothetical function that returns the objects that an actor has liked. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The objects that the actor has liked. */ -async function getLikedByUserHandle(handle: string): Promise { +async function getLikedByUserId(userId: string): Promise { return []; } // ---cut-before--- import type { Object } from "@fedify/fedify"; federation - .setLikedDispatcher("/users/{handle}/liked", async (ctx, handle, cursor) => { + .setLikedDispatcher("/users/{identifier}/liked", async (ctx, identifier, cursor) => { // Work with the database to find the objects that the actor has liked - // (the below `getLikedPostsByUserHandle` is a hypothetical function): - const items: Object[] = await getLikedByUserHandle(handle); + // (the below `getLikedPostsByUserId` is a hypothetical function): + const items: Object[] = await getLikedByUserId(identifier); return { items }; }); ~~~~ @@ -977,20 +1031,20 @@ import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; /** * A hypothetical function that returns the objects that an actor has liked. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The objects that the actor has liked. */ -async function getLikedByUserHandle(handle: string): Promise { +async function getLikedByUserId(userId: string): Promise { return []; } // ---cut-before--- import type { Object } from "@fedify/fedify"; federation - .setLikedDispatcher("/users/{handle}/liked", async (ctx, handle, cursor) => { + .setLikedDispatcher("/users/{identifier}/liked", async (ctx, identifier, cursor) => { // Work with the database to find the objects that the actor has liked - // (the below `getLikedPostsByUserHandle` is a hypothetical function): - const objects: Object[] = await getLikedByUserHandle(handle); + // (the below `getLikedPostsByUserId` is a hypothetical function): + const objects: Object[] = await getLikedByUserId(identifier); // Turn the objects into `URL` objects: const items: URL[] = objects.map(obj => obj.id).filter(id => id != null); return { items }; @@ -1001,8 +1055,8 @@ federation ### Constructing liked collection URIs To construct a liked collection URI, you can use the `Context.getLikedUri()` -method. This method takes the actor's handle and returns the dereferenceable -URI of the liked collection of the actor. +method. This method takes the actor's identifier and returns +the dereferenceable URI of the liked collection of the actor. The following shows how to construct a liked collection URI of an actor named `"alice"`: @@ -1014,12 +1068,25 @@ const ctx = null as unknown as Context; ctx.getLikedUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getLikedUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getLikedUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > > The `Context.getLikedUri()` method does not guarantee that the liked > collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check if the -> handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check if +> the identifier is valid before calling the method. Featured @@ -1042,18 +1109,18 @@ import type { Object, Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; /** * A hypothetical function that returns the objects that an actor has featured. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The objects that the actor has featured. */ -async function getFeaturedByUserHandle(handle: string): Promise { +async function getFeaturedByUserId(userId: string): Promise { return []; } // ---cut-before--- federation - .setFeaturedDispatcher("/users/{handle}/featured", async (ctx, handle, cursor) => { + .setFeaturedDispatcher("/users/{identifier}/featured", async (ctx, identifier, cursor) => { // Work with the database to find the objects that the actor has featured - // (the below `getFeaturedPostsByUserHandle` is a hypothetical function): - const items = await getFeaturedByUserHandle(handle); + // (the below `getFeaturedPostsByUserId` is a hypothetical function): + const items = await getFeaturedByUserId(identifier); return { items }; }); ~~~~ @@ -1061,7 +1128,7 @@ federation ### Constructing featured collection URIs To construct a featured collection URI, you can use -the `Context.getFeaturedUri()` method. This method takes the actor's handle +the `Context.getFeaturedUri()` method. This method takes the actor's identifier and returns the dereferenceable URI of the featured collection of the actor. The following shows how to construct a featured collection URI of an actor named @@ -1074,12 +1141,25 @@ const ctx = null as unknown as Context; ctx.getFeaturedUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getFeaturedUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getFeaturedUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > > The `Context.getFeaturedUri()` method does not guarantee that the featured > collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check if the -> handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check if +> the identifier is valid before calling the method. @@ -1103,18 +1183,18 @@ import { Hashtag, type Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; /** * A hypothetical function that returns the tags that an actor has featured. - * @param handle The actor's handle. + * @param userId The actor's identifier. * @returns The tags that the actor has featured. */ -async function getFeaturedTagsByUserHandle(handle: string): Promise { +async function getFeaturedTagsByUserId(userId: string): Promise { return []; } // ---cut-before--- federation - .setFeaturedTagsDispatcher("/users/{handle}/tags", async (ctx, handle, cursor) => { + .setFeaturedTagsDispatcher("/users/{identifier}/tags", async (ctx, identifier, cursor) => { // Work with the database to find the tags that the actor has featured - // (the below `getFeaturedTagsByUserHandle` is a hypothetical function): - const hashtags = await getFeaturedTagsByUserHandle(handle); + // (the below `getFeaturedTagsByUserId` is a hypothetical function): + const hashtags = await getFeaturedTagsByUserId(identifier); const items = hashtags.map(hashtag => new Hashtag({ href: new URL(`/tags/${encodeURIComponent(hashtag)}`, ctx.url), @@ -1128,9 +1208,9 @@ federation ### Constructing featured tags collection URIs To construct a featured tags collection URI, you can use -the `Context.getFeaturedTagsUri()` method. This method takes the actor's handle -and returns the dereferenceable URI of the featured tags collection of -the actor. +the `Context.getFeaturedTagsUri()` method. This method takes the actor's +identifier and returns the dereferenceable URI of the featured tags collection +of the actor. The following shows how to construct a featured tags collection URI of an actor named `"alice"`: @@ -1142,9 +1222,22 @@ const ctx = null as unknown as Context; ctx.getFeaturedTagsUri("alice") ~~~~ +If you [decouple the WebFinger username from the actor's +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +you should pass the identifier that is used in +the [actor dispatcher](./actor.md) to the `Context.getFeaturedTagsUri()` method, +not the WebFinger username: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +// ---cut-before--- +ctx.getFeaturedTagsUri("2bd304f9-36b3-44f0-bf0b-29124aafcbb4") +~~~~ + > [!NOTE] > > The `Context.getFeaturedTagsUri()` method does not guarantee that the featured > tags collection actually exists. It only constructs a URI based on the given -> handle, which may respond with `404 Not Found`. Make sure to check -> if the handle is valid before calling the method. +> identifier, which may respond with `404 Not Found`. Make sure to check +> if the identifier is valid before calling the method. diff --git a/docs/manual/context.md b/docs/manual/context.md index af967646..ceff8dff 100644 --- a/docs/manual/context.md +++ b/docs/manual/context.md @@ -107,19 +107,19 @@ const federation = null as unknown as Federation; interface User { } const user: User | null = true ? { } : null; // ---cut-before--- -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - // Work with the database to find the actor by the handle. +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + // Work with the database to find the actor by the identifier. if (user == null) return null; return new Person({ - id: ctx.getActorUri(handle), // [!code highlight] - preferredUsername: handle, + id: ctx.getActorUri(identifier), // [!code highlight] + preferredUsername: identifier, // Many more properties... }); }); ~~~~ On the other way around, you can use the `~Context.parseUri()` method to -determine the type of the URI and extract the handle or other values from +determine the type of the URI and extract the identifier or other values from the URI. @@ -137,16 +137,16 @@ const federation = null as unknown as Federation; import { Accept, Follow } from "@fedify/fedify"; federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { - // In order to send an activity, we need the bare handle of the sender: + // In order to send an activity, we need the identifier of the sender actor: if (follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor") return; const recipient = await follow.getActor(ctx); if (recipient == null) return; await ctx.sendActivity( - { handle: parsed.handle }, // sender + { identifier: parsed.identifier }, // sender recipient, new Accept({ actor: follow.objectId, object: follow }), ); @@ -188,13 +188,13 @@ the `RequestContext.getActor()` method: import { type Federation, Update } from "@fedify/fedify"; const federation = null as unknown as Federation; const request = new Request(""); -const handle: string = ""; +const identifier: string = ""; // ---cut-before--- const ctx = federation.createContext(request, undefined); -const actor = await ctx.getActor(handle); // [!code highlight] +const actor = await ctx.getActor(identifier); // [!code highlight] if (actor != null) { await ctx.sendActivity( - { handle }, + { identifier }, "followers", new Update({ actor: actor.id, object: actor }), ); @@ -213,11 +213,11 @@ an object from the URL arguments. The following shows an example: import { type Federation, Note } from "@fedify/fedify"; const federation = null as unknown as Federation; const request = new Request(""); -const handle: string = ""; +const identifier: string = ""; const id: string = ""; // ---cut-before--- const ctx = federation.createContext(request, undefined); -const note = await ctx.getObject(Note, { handle, id }); // [!code highlight] +const note = await ctx.getObject(Note, { identifier, id }); // [!code highlight] ~~~~ @@ -264,13 +264,16 @@ import { type Actor, type Context, Person } from "@fedify/fedify"; const ctx = null as unknown as Context; const actor = new Person({}) as Actor; // ---cut-before--- -const documentLoader = await ctx.getDocumentLoader({ handle: "john" }); +const documentLoader = await ctx.getDocumentLoader({ + identifier: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4", +}); const following = await actor.getFollowing({ documentLoader }); ~~~~ In the above example, the `getFollowing()` method takes the `documentLoader` -which is authenticated as the actor with a handle of `john`. -If the `actor` allows `john` to see the following collection, +which is authenticated as the actor with an identifier of +`2bd304f9-36b3-44f0-bf0b-29124aafcbb4`. If the `actor` allows +actor `2bd304f9-36b3-44f0-bf0b-29124aafcbb4` to see the following collection, the `getFollowing()` method returns the following collection. > [!TIP] @@ -393,7 +396,7 @@ const note = await ctx.lookupObject( > import { type Context } from "@fedify/fedify"; > const ctx = null as unknown as Context; > // ---cut-before--- -> const documentLoader = await ctx.getDocumentLoader({ handle: "john" }); +> const documentLoader = await ctx.getDocumentLoader({ identifier: "john" }); > const note = await ctx.lookupObject("...", { documentLoader }); > ~~~~ > diff --git a/docs/manual/inbox.md b/docs/manual/inbox.md index 50fe0e44..a683b5f5 100644 --- a/docs/manual/inbox.md +++ b/docs/manual/inbox.md @@ -36,7 +36,7 @@ const federation = createFederation({ }); federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); @@ -44,7 +44,7 @@ federation const recipient = await follow.getActor(ctx); if (recipient == null) return; await ctx.sendActivity( - { handle: parsed.handle }, + { identifier: parsed.identifier }, recipient, new Accept({ actor: follow.objectId, object: follow }), ); @@ -69,9 +69,9 @@ multiple inbox listeners for different activity types. > [!TIP] > You can get a personal or shared inbox URI by calling > the `~Context.getInboxUri()` method. It takes an optional parameter -> `handle` to get the personal inbox URI for the actor with the bare handle. -> If the `handle` parameter is not provided, the method returns the shared -> inbox URI. +> `identifier` to get the personal inbox URI for the actor with the given +> identifier. If the `identifier` parameter is not provided, the method +> returns the shared inbox URI. [shared inbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery @@ -117,16 +117,16 @@ const federation = null as unknown as Federation; import { Application, Person } from "@fedify/fedify"; federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/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" })); + .setSharedKeyDispatcher((_ctx) => ({ identifier: "~actor" })); federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle === "~actor") { + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier === "~actor") { // Returns an Application object for the instance actor: return new Application({ // ... @@ -141,7 +141,7 @@ federation ~~~~ Or you can manually configure the key pair instead of referring to an actor -by its handle: +by its identifier: ~~~~ typescript{11-18} twoslash // @noErrors: 2391 @@ -175,7 +175,7 @@ interface InstanceActor { } federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/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 @@ -259,7 +259,7 @@ import { type Federation, Follow } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { // Omitted for brevity }) @@ -301,14 +301,14 @@ The following shows an example of forwarding `Create` activities to followers: ~~~~ typescript twoslash import { Create, type Federation } from "@fedify/fedify"; const federation: Federation = null as unknown as Federation; -federation.setInboxListeners("/{handle}/inbox", "/inbox") +federation.setInboxListeners("/{identifier}/inbox", "/inbox") // ---cut-before--- .on(Create, async (ctx, create) => { if (create.toId == null) return; const to = ctx.parseUri(create.toId); if (to?.type !== "actor") return; - const forwarder = to.handle; - await ctx.forwardActivity({ handle: forwarder }, "followers"); + const forwarder = to.identifier; + await ctx.forwardActivity({ identifier: forwarder }, "followers"); }) ~~~~ @@ -329,7 +329,7 @@ federation.setInboxListeners("/{handle}/inbox", "/inbox") > const ctx = null as unknown as InboxContext; > // ---cut-before--- > await ctx.forwardActivity( -> { handle: "alice" }, +> { identifier: "alice" }, > "followers", > { skipIfUnsigned: true }, > ); @@ -345,23 +345,24 @@ Constructing inbox URIs ----------------------- To construct an inbox URI, you can use the `~Context.getInboxUri()` method. -This method optionally takes a handle of an actor and returns a dereferenceable -URI of the inbox of the actor. If no argument is provided, the method returns -the shared inbox URI. +This method optionally takes an identifier of an actor and returns +a dereferenceable URI of the inbox of the actor. If no argument is provided, +the method returns the shared inbox URI. -The following shows how to construct an inbox URI of an actor named `"alice"`: +The following shows how to construct an inbox URI of an actor identified by +`5fefc9bb-397d-4949-86bb-33487bf233fb`: ~~~~ typescript twoslash import type { Context } from "@fedify/fedify"; const ctx = null as unknown as Context; // ---cut-before--- -ctx.getInboxUri("alice") +ctx.getInboxUri("5fefc9bb-397d-4949-86bb-33487bf233fb") ~~~~ > [!NOTE] > The `~Context.getInboxUri()` method does not guarantee that the inbox -> actually exists. It only constructs a URI based on the given handle, -> which may respond with `404 Not Found`. Make sure to check if the handle +> actually exists. It only constructs a URI based on the given identifier, +> which may respond with `404 Not Found`. Make sure to check if the identifier > is valid before calling the method. The following shows how to construct a shared inbox URI: diff --git a/docs/manual/object.md b/docs/manual/object.md index bbceeadf..a750fea1 100644 --- a/docs/manual/object.md +++ b/docs/manual/object.md @@ -19,7 +19,7 @@ An object dispatcher is a callback function that takes a `Context` object and URL arguments, and returns an object. Every object dispatcher has one or more URL parameters that are used to dispatch the object. The URL parameters are specified in the path pattern of the object dispatcher, e.g., `/notes/{id}`, -`/users/{handle}/articles/{id}`. +`/users/{userId}/articles/{articleId}`. The below example shows how to register an object dispatcher: @@ -35,12 +35,12 @@ const federation = createFederation({ federation.setObjectDispatcher( Note, - "/users/{handle}/notes/{id}", - async (ctx, { handle, id }) => { - // Work with the database to find the note by the author's handle and the note ID. + "/users/{userId}/notes/{noteId}", + async (ctx, { userId, noteId }) => { + // Work with the database to find the note by the author ID and the note ID. if (note == null) return null; // Return null if the note is not found. return new Note({ - id: ctx.getObjectUri(Note, { handle, id }), + id: ctx.getObjectUri(Note, { userId, noteId }), content: note.content, // Many more properties... }); @@ -49,8 +49,9 @@ federation.setObjectDispatcher( ~~~~ In the above example, the `~Federation.setObjectDispatcher()` method registers -an object dispatcher for the `Note` class and the `/users/{handle}/notes/{id}` -path. This pattern syntax follows the [URI Template] specification. +an object dispatcher for the `Note` class and +the `/users/{userId}/notes/{noteId}` path. This pattern syntax follows +the [URI Template] specification. [objects]: https://www.w3.org/TR/activitystreams-core/#object [URI Template]: https://datatracker.ietf.org/doc/html/rfc6570 @@ -69,7 +70,10 @@ The below example shows how to construct an object URI: import { type Context, Note } from "@fedify/fedify"; const ctx = null as unknown as Context; // ---cut-before--- -ctx.getObjectUri(Note, { handle: "alice", id: "123" }); +ctx.getObjectUri(Note, { + userId: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4", + noteId: "9f60274d-f6c2-4e3f-8eae-447f4416c0fb", +}) ~~~~ > [!NOTE] diff --git a/docs/manual/pragmatics.md b/docs/manual/pragmatics.md index 3a7eb903..ecd4a373 100644 --- a/docs/manual/pragmatics.md +++ b/docs/manual/pragmatics.md @@ -355,7 +355,7 @@ const federation = null as unknown as Federation; // ---cut-before--- federation .setFollowingDispatcher( - "/users/{handle}/following", async (ctx, handle, cursor) => { + "/users/{identifier}/following", async (ctx, identifier, cursor) => { // Loads the list of actors that the actor follows... return { items: [ @@ -366,7 +366,7 @@ federation }; } ) - .setCounter((ctx, handle) => 123); + .setCounter((ctx, identifier) => 123); ~~~~ For example, the above following collection is displayed like the below @@ -392,7 +392,7 @@ const federation = null as unknown as Federation; // ---cut-before--- federation .setFollowersDispatcher( - "/users/{handle}/followers", async (ctx, handle, cursor) => { + "/users/{identifier}/followers", async (ctx, identifier, cursor) => { // Loads the list of actors that follow the actor... return { items: [ @@ -405,7 +405,7 @@ federation }; } ) - .setCounter((ctx, handle) => 456); + .setCounter((ctx, identifier) => 456); ~~~~ For example, the above followers collection is displayed like the below diff --git a/docs/manual/send.md b/docs/manual/send.md index 5d38a627..2813b018 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -37,14 +37,14 @@ import { type Context, Follow, type Recipient } from "@fedify/fedify"; async function sendFollow( ctx: Context, - senderHandle: string, + senderId: string, recipient: Recipient, ) { await ctx.sendActivity( - { handle: senderHandle }, + { identifier: senderId }, recipient, new Follow({ - actor: ctx.getActorUri(senderHandle), + actor: ctx.getActorUri(senderId), object: recipient.id, }), ); @@ -57,6 +57,79 @@ async function sendFollow( > the *Context* section. +Specifying a sender +------------------- + +The first argument of the `~Context.sendActivity()` method is the sender +of the activity. It can be three types of values: + +### `{ identifier: string }` + +If you specify an object with the `identifier` property, the sender is +the actor with the given identifier. The identifier is used to find the +actor's key pairs to sign the activity: + +~~~~ typescript twoslash +import { Activity, type Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +const activity = new Activity({}); +// ---cut-before--- +await ctx.sendActivity( + { identifier: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4" }, // [!code highlight] + "followers", + activity, +); +~~~~ + +### `{ username: string }` + +If you specify an object with the `username` property, the sender is +the actor with the given WebFinger username. The username is used to find +the actor's key pairs to sign the activity: + +~~~~ typescript twoslash +import { Activity, type Context } from "@fedify/fedify"; +const ctx = null as unknown as Context; +const activity = new Activity({}); +// ---cut-before--- +await ctx.sendActivity( + { username: "john" }, // [!code highlight] + "followers", + activity, +); +~~~~ + +If you don't [decouple the username from the +identifier](./actor.md#decoupling-actor-uris-from-webfinger-usernames), +this is the same as the `{ identifier: string }` case. + +### `SenderKeyPair | SenderKeyPair[]` + +If you specify a `SenderKeyPair` object or an array of `SenderKeyPair` objects, +the sender is the set of the given key pairs: + +~~~~ typescript twoslash +import { + Activity, + type Actor, + type Context, + SenderKeyPair, +} from "@fedify/fedify"; +const ctx = null as unknown as Context; +const activity = new Activity({}); +const recipients: Actor[] = []; +// ---cut-before--- +await ctx.sendActivity( + await ctx.getActorKeyPairs("2bd304f9-36b3-44f0-bf0b-29124aafcbb4"), // [!code highlight] + recipients, // You need to specify the recipients manually + activity, +); +~~~~ + +However, you probably don't want to use this option directly; instead, +you should use above two options to specify the sender. + + Enqueuing an outgoing activity ------------------------------ @@ -114,14 +187,14 @@ import { type Context, Follow, type Recipient } from "@fedify/fedify"; async function sendFollow( ctx: Context, - senderHandle: string, + senderId: string, recipient: Recipient, ) { await ctx.sendActivity( - { handle: senderHandle }, + { identifier: senderId }, recipient, new Follow({ - actor: ctx.getActorUri(senderHandle), + actor: ctx.getActorUri(senderId), object: recipient.id, }), { immediate: true }, // [!code highlight] @@ -151,17 +224,17 @@ import { async function sendNote( ctx: Context, - senderHandle: string, + senderId: string, recipient: Recipient, ) { await ctx.sendActivity( - { handle: senderHandle }, + { identifier: senderId }, recipient, new Create({ - actor: ctx.getActorUri(senderHandle), + actor: ctx.getActorUri(senderId), to: PUBLIC_COLLECTION, object: new Note({ - attribution: ctx.getActorUri(senderHandle), + attribution: ctx.getActorUri(senderId), to: PUBLIC_COLLECTION, }), }), @@ -213,17 +286,17 @@ the `"followers"` string: ~~~~ typescript twoslash import { type Context, Create, Note } from "@fedify/fedify"; const ctx = null as unknown as Context; -const senderHandle: string = ""; +const senderId : string = ""; // ---cut-before--- await ctx.sendActivity( - { handle: senderHandle }, + { identifier: senderId }, "followers", // [!code highlight] new Create({ - actor: ctx.getActorUri(senderHandle), - to: ctx.getFollowersUri(senderHandle), + actor: ctx.getActorUri(senderId), + to: ctx.getFollowersUri(senderId), object: new Note({ - attribution: ctx.getActorUri(senderHandle), - to: ctx.getFollowersUri(senderHandle), + attribution: ctx.getActorUri(senderId), + to: ctx.getFollowersUri(senderId), }), }), { preferSharedInbox: true }, // [!code highlight] @@ -263,11 +336,11 @@ to the `~Context.sendActivity()` method: ~~~~ typescript twoslash import { Activity, type Context } from "@fedify/fedify"; const ctx = null as unknown as Context; -const senderHandle: string = ""; +const senderId: string = ""; const activity = new Activity({}); // ---cut-before--- await ctx.sendActivity( - { handle: senderHandle }, + { identifier: senderId }, "followers", activity, { excludeBaseUris: [ctx.getInboxUri()] }, // [!code highlight] diff --git a/docs/package.json b/docs/package.json index 33d61f38..886b90d4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { "devDependencies": { "@deno/kv": "^0.8.2", - "@fedify/fedify": "^1.0.0-dev.398", + "@fedify/fedify": "^1.0.0-dev.404", "@fedify/redis": "^0.1.1", "@hono/node-server": "^1.12.2", "@js-temporal/polyfill": "^0.4.4", diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index cdae4e6e..8deb7417 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.8.2 version: 0.8.2 '@fedify/fedify': - specifier: ^1.0.0-dev.398 - version: 1.0.0-dev.398(web-streams-polyfill@3.3.3) + specifier: ^1.0.0-dev.404 + version: 1.0.0-dev.404(web-streams-polyfill@3.3.3) '@fedify/redis': specifier: ^0.1.1 version: 0.1.1(web-streams-polyfill@3.3.3) @@ -370,8 +370,8 @@ packages: '@fedify/fedify@0.10.2': resolution: {integrity: sha512-GKxm+NZ1zNPJ9HTbTW+Y2o/HT3Rk6ly+j/GJfjXBFiwm391Ni56VYC8ON/KQsFdFYIp+eNMn1hWr0RbFtMwoOg==} - '@fedify/fedify@1.0.0-dev.398': - resolution: {integrity: sha512-3e2MDIIqZ0vv2UA6Wc/EjzdGvoKh+uaofMI0DEaN5szb4JLjjl1Y4WycJ3U60oOvHx6drZFKDCUc0DdcmW6jrw==} + '@fedify/fedify@1.0.0-dev.404': + resolution: {integrity: sha512-WJhEzWzJ4ZpvdRP9yQz7jYm+bbkWOVnpzL0LmrQg/1cf2CdaXpReiEkcw8ORJMwWgbXJ5qQE/QUyJdDB7qZbQg==} '@fedify/redis@0.1.1': resolution: {integrity: sha512-oKhOVYLRwkRf/tuePoT3CnwXUKm5rTE7hDg8/qfVo/txmydPPptXzPG10kL24zCv8t18FwmyaE0LlcrUrxd6FQ==} @@ -1974,7 +1974,7 @@ snapshots: transitivePeerDependencies: - web-streams-polyfill - '@fedify/fedify@1.0.0-dev.398(web-streams-polyfill@3.3.3)': + '@fedify/fedify@1.0.0-dev.404(web-streams-polyfill@3.3.3)': dependencies: '@deno/shim-crypto': 0.3.1 '@deno/shim-deno': 0.18.2 diff --git a/docs/tutorial/basics.md b/docs/tutorial/basics.md index 5f86b001..ff11bc48 100644 --- a/docs/tutorial/basics.md +++ b/docs/tutorial/basics.md @@ -362,8 +362,8 @@ from other servers. The actor dispatcher is a function that is called when an incoming activity is addressed to an actor on the server. As mentioned earlier, there will be only one actor (i.e., account) on -the server. We will name its handle as *me* (you can choose any handle you -like). +the server. We will name its identifier as *me* (you can choose any identifier +you like). Let's create an actor dispatcher for our server: @@ -376,13 +376,13 @@ const federation = createFederation({ kv: new MemoryKvStore(), }); -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; // Other than "me" is not found. +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio - preferredUsername: handle, // Bare handle + preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); @@ -401,13 +401,13 @@ const federation = createFederation({ kv: new MemoryKvStore(), }); -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; // Other than "me" is not found. +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio - preferredUsername: handle, // Bare handle + preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); @@ -428,13 +428,13 @@ const federation = createFederation({ kv: new MemoryKvStore(), }); -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; // Other than "me" is not found. +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio - preferredUsername: handle, // Bare handle + preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); @@ -452,9 +452,9 @@ serve({ In the above code, we use the `Federation.setActorDispatcher()` method to set an actor dispatcher for the server. The first argument is the path pattern for the actor, and the second argument is a callback function that takes -a `Context` object and the actor's handle. The callback function should return -an `Actor` object or `null` if the actor is not found. In this case, we return -a `Person` object for the actor *me*. +a `Context` object and the actor's identifier. The callback function should +return an `Actor` object or `null` if the actor is not found. In this case, +we return a `Person` object for the actor *me*. Alright, we have an actor on the server. Let's see if it works by querying WebFinger for the actor. Run the server by executing the following command: @@ -732,13 +732,13 @@ import { type Federation, Follow } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.id == null || follow.actorId == null || follow.objectId == null) { return; } const parsed = ctx.parseUri(follow.objectId); - if (parsed?.type !== "actor" || parsed.handle !== "me") return; + if (parsed?.type !== "actor" || parsed.identifier !== "me") return; const follower = await follow.getActor(ctx); console.debug(follower); }); @@ -757,15 +757,15 @@ URI: import { type Federation, Person } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", - preferredUsername: handle, + preferredUsername: identifier, url: new URL("/", ctx.url), - inbox: ctx.getInboxUri(handle), // Inbox URI // [!code highlight] + inbox: ctx.getInboxUri(identifier), // Inbox URI // [!code highlight] }); }); ~~~~ @@ -844,23 +844,23 @@ const federation = null as unknown as Federation; const kv = await Deno.openKv(); // Open the key-value store federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", - preferredUsername: handle, + preferredUsername: identifier, url: new URL("/", ctx.url), - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), // The public keys of the actor; they are provided by the key pairs // dispatcher we define below: - publicKeys: (await ctx.getActorKeyPairs(handle)) + publicKeys: (await ctx.getActorKeyPairs(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) - .setKeyPairsDispatcher(async (ctx, handle) => { - if (handle != "me") return []; // Other than "me" is not found. + .setKeyPairsDispatcher(async (ctx, identifier) => { + if (identifier != "me") return []; // Other than "me" is not found. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; @@ -903,23 +903,23 @@ import { openKv } from "@deno/kv"; const kv = await openKv("kv.db", { encodeV8, decodeV8 }); federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", - preferredUsername: handle, + preferredUsername: identifier, url: new URL("/", ctx.url), - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), // The public keys of the actor; they are provided by the key pairs // dispatcher we define below: - publicKeys: (await ctx.getActorKeyPairs(handle)) + publicKeys: (await ctx.getActorKeyPairs(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) - .setKeyPairsDispatcher(async (ctx, handle) => { - if (handle != "me") return []; // Other than "me" is not found. + .setKeyPairsDispatcher(async (ctx, identifier) => { + if (identifier != "me") return []; // Other than "me" is not found. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; @@ -960,23 +960,23 @@ import { openKv } from "@deno/kv"; const kv = await openKv("kv.db"); // Open the key-value store federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle !== "me") return null; + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier !== "me") return null; return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", - preferredUsername: handle, + preferredUsername: identifier, url: new URL("/", ctx.url), - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), // The public keys of the actor; they are provided by the key pairs // dispatcher we define below: - publicKeys: (await ctx.getActorKeyPairs(handle)) + publicKeys: (await ctx.getActorKeyPairs(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) - .setKeyPairsDispatcher(async (ctx, handle) => { - if (handle != "me") return []; // Other than "me" is not found. + .setKeyPairsDispatcher(async (ctx, identifier) => { + if (identifier != "me") return []; // Other than "me" is not found. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; @@ -1089,20 +1089,20 @@ import { Accept, type Federation, Follow } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.id == null || follow.actorId == null || follow.objectId == null) { return; } const parsed = ctx.parseUri(follow.objectId); - if (parsed?.type !== "actor" || parsed.handle !== "me") return; + if (parsed?.type !== "actor" || parsed.identifier !== "me") return; const follower = await follow.getActor(ctx); if (follower == null) return; // Note that if a server receives a `Follow` activity, it should reply // with either an `Accept` or a `Reject` activity. In this case, the // server automatically accepts the follow request: await ctx.sendActivity( - { handle: parsed.handle }, + { identifier: parsed.identifier }, follower, new Accept({ actor: follow.objectId, object: follow }), ); @@ -1131,17 +1131,17 @@ const federation = null as unknown as Federation; const kv = await Deno.openKv(); // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.id == null || follow.actorId == null || follow.objectId == null) { return; } const parsed = ctx.parseUri(follow.objectId); - if (parsed?.type !== "actor" || parsed.handle !== "me") return; + if (parsed?.type !== "actor" || parsed.identifier !== "me") return; const follower = await follow.getActor(ctx); if (follower == null) return; await ctx.sendActivity( - { handle: parsed.handle }, + { identifier: parsed.identifier }, follower, new Accept({ actor: follow.objectId, object: follow }), ); diff --git a/docs/tutorial/microblog.md b/docs/tutorial/microblog.md index 129edcea..df4b8fcc 100644 --- a/docs/tutorial/microblog.md +++ b/docs/tutorial/microblog.md @@ -956,11 +956,11 @@ const federation = createFederation({ queue: new InProcessMessageQueue(), }); -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { return new Person({ - id: ctx.getActorUri(handle), - preferredUsername: handle, - name: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: identifier, }); }); @@ -970,7 +970,7 @@ export default federation; The part we should focus on is the `~Federation.setActorDispatcher()` method. This method defines the URL and behavior that other ActivityPub software will use when querying an actor on our server. For example, if we query -*/users/johndoe* as we did earlier, the `handle` parameter of the callback +*/users/johndoe* as we did earlier, the `identifier` parameter of the callback function will receive the string value `"johndoe"`. And the callback function returns an instance of the `Person` class to convey the information of the queried actor. @@ -978,12 +978,12 @@ the queried actor. The `ctx` parameter receives a `Context` object, which contains various functions related to the ActivityPub protocol. For example, the `~Context.getActorUri()` method used in the above code returns the unique -URI of the actor with the `handle` passed as a parameter. This URI is being used -as the unique identifier of the `Person` object. +URI of the actor with the `identifier` passed as a parameter. This URI is +being used as the unique identifier of the `Person` object. As you can see from the implementation code, currently it's *making up* actor -information and returning it for any handle that comes after the */users/* path. -But what we want is to only allow queries for accounts that are actually +information and returning it for any identifier that comes after the */users/* +path. But what we want is to only allow queries for accounts that are actually registered. Let's modify this part to only return for accounts in the database. ### Table creation @@ -1258,7 +1258,7 @@ the actor dispatcher: import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- -federation.setInboxListeners("/users/{handle}/inbox", "/inbox"); +federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ~~~~ Don't worry about the `~Federation.setInboxListeners()` method for now. @@ -1293,7 +1293,7 @@ interface User {} interface Actor { name: string; } const federation = null as unknown as Federation; // ---cut-before--- -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { +federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = db .prepare( ` @@ -1302,18 +1302,18 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { WHERE users.username = ? `, ) - .get(handle); + .get(identifier); if (user == null) return null; return new Person({ - id: ctx.getActorUri(handle), - preferredUsername: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, name: user.name, - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), - url: ctx.getActorUri(handle), + url: ctx.getActorUri(identifier), }); }); ~~~~ @@ -1333,8 +1333,8 @@ profile URL match. > [!TIP] > Sharp-eyed readers may have noticed that we're defining overlapping handlers -> for `GET /users/{handle}` on both Hono and Fedify sides. So what happens when -> an actual request is sent to this path? The answer is that it depends on +> for `GET /users/{identifier}` on both Hono and Fedify sides. So what happens +> when an actual request is sent to this path? The answer is that it depends on > the Accept header of the request. If a request is sent with > the `Accept: text/html` header, the request handler on the Hono side responds. > If a request is sent with the `Accept: application/activity+json` header, @@ -1516,7 +1516,7 @@ interface Key { } // ---cut-before--- federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = db .prepare( ` @@ -1525,27 +1525,27 @@ federation WHERE users.username = ? `, ) - .get(handle); + .get(identifier); if (user == null) return null; - const keys = await ctx.getActorKeyPairs(handle); + const keys = await ctx.getActorKeyPairs(identifier); return new Person({ - id: ctx.getActorUri(handle), - preferredUsername: handle, + id: ctx.getActorUri(identifier), + preferredUsername: identifier, name: user.name, - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), - url: ctx.getActorUri(handle), + url: ctx.getActorUri(identifier), publicKey: keys[0].cryptographicKey, assertionMethods: keys.map((k) => k.multikey), }); }) - .setKeyPairsDispatcher(async (ctx, handle) => { + .setKeyPairsDispatcher(async (ctx, identifier) => { const user = db .prepare("SELECT * FROM users WHERE username = ?") - .get(handle); + .get(identifier); if (user == null) return []; const rows = db .prepare("SELECT * FROM keys WHERE keys.user_id = ?") @@ -1560,8 +1560,8 @@ federation for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) { if (keys[keyType] == null) { logger.debug( - "The user {handle} does not have an {keyType} key; creating one...", - { handle, keyType }, + "The user {identifier} does not have an {keyType} key; creating one...", + { identifier, keyType }, ); const { privateKey, publicKey } = await generateCryptoKeyPair(keyType); db.prepare( @@ -1853,7 +1853,7 @@ the following code in the *src/federation.ts* file earlier: import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- -federation.setInboxListeners("/users/{handle}/inbox", "/inbox"); +federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ~~~~ Before modifying this code, let's `import` the `Accept` and `Follow` classes @@ -1896,7 +1896,7 @@ const db = new Database(""); interface Actor { id: number; } // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.objectId == null) { logger.debug("The Follow object does not have an object: {follow}", { @@ -1926,7 +1926,7 @@ federation WHERE users.username = ? `, ) - .get(object.handle)?.id; + .get(object.identifier)?.id; if (followingId == null) { logger.debug( "Failed to find the actor to follow in the database: {object}", @@ -2134,7 +2134,7 @@ import Database from "better-sqlite3"; const db = new Database(""); // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { // ... omitted ... }) @@ -2154,7 +2154,7 @@ federation WHERE users.username = ? ) AND follower_id = (SELECT id FROM actors WHERE uri = ?) `, - ).run(parsed.handle, undo.actorId.href); + ).run(parsed.identifier, undo.actorId.href); }); ~~~~ @@ -2482,8 +2482,8 @@ interface Actor { // ---cut-before--- federation .setFollowersDispatcher( - "/users/{handle}/followers", - (ctx, handle, cursor) => { + "/users/{identifier}/followers", + (ctx, identifier, cursor) => { const followers = db .prepare( ` @@ -2496,7 +2496,7 @@ federation ORDER BY follows.created DESC `, ) - .all(handle); + .all(identifier); const items: Recipient[] = followers.map((f) => ({ id: new URL(f.uri), inboxId: new URL(f.inbox_url), @@ -2508,7 +2508,7 @@ federation return { items }; }, ) - .setCounter((ctx, handle) => { + .setCounter((ctx, identifier) => { const result = db .prepare( ` @@ -2519,16 +2519,16 @@ federation WHERE users.username = ? `, ) - .get(handle); + .get(identifier); return result == null ? 0 : result.cnt; }); ~~~~ The `~Federation.setFollowersDispatcher()` method creates a followers -collection object to respond to when a `GET /users/{handle}/followers` request -comes in. Although the SQL is a bit long, it essentially gets the list of -actors following the actor with the `handle` parameter. The `items` contains -`Recipient` objects, and the `Recipient` type looks like this: +collection object to respond to when a `GET /users/{identifier}/followers` +request comes in. Although the SQL is a bit long, it essentially gets the list +of actors following the actor with the `identifier` parameter. The `items` +contains `Recipient` objects, and the `Recipient` type looks like this: ~~~~ typescript twoslash export interface Recipient { @@ -2547,7 +2547,7 @@ our `actors` table, we can fill the `items` array with that information. The `~CollectionCallbackSetters.setCounter()` method gets the total number of the followers collection. Here too, the SQL is a bit complex, but in summary, -it's counting the number of actors following the actor with the `handle` +it's counting the number of actors following the actor with the `identifier` parameter. Now, let's check if the followers collection is working properly by using @@ -2576,11 +2576,11 @@ import { type Federation, Person } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { // ... omitted ... return new Person({ // ... omitted ... - followers: ctx.getFollowersUri(handle), // [!code highlight] + followers: ctx.getFollowersUri(identifier), // [!code highlight] }); }) ~~~~ @@ -2818,7 +2818,7 @@ const federation = null as unknown as Federation; // ---cut-before--- federation.setObjectDispatcher( Note, - "/users/{handle}/posts/{id}", + "/users/{identifier}/posts/{id}", (ctx, values) => { return null; }, @@ -2895,7 +2895,7 @@ app.post("/users/:username/posts", async (c) => { .get(actor.id, stringifyEntities(content, { escapeOnly: true })); if (post == null) return null; const url = ctx.getObjectUri(Note, { - handle: username, + identifier: username, id: post.id.toString(), }).href; db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run( @@ -3159,7 +3159,7 @@ const federation = null as unknown as Federation; // ---cut-before--- federation.setObjectDispatcher( Note, - "/users/{handle}/posts/{id}", + "/users/{identifier}/posts/{id}", (ctx, values) => { return null; }, @@ -3178,7 +3178,7 @@ interface Post { id: number; content: string; created: string; } // ---cut-before--- federation.setObjectDispatcher( Note, - "/users/{handle}/posts/{id}", + "/users/{identifier}/posts/{id}", (ctx, values) => { const post = db .prepare( @@ -3190,13 +3190,13 @@ federation.setObjectDispatcher( WHERE users.username = ? AND posts.id = ? `, ) - .get(values.handle, values.id); + .get(values.identifier, values.id); if (post == null) return null; return new Note({ id: ctx.getObjectUri(Note, values), - attribution: ctx.getActorUri(values.handle), + attribution: ctx.getActorUri(values.identifier), to: PUBLIC_COLLECTION, - cc: ctx.getFollowersUri(values.handle), + cc: ctx.getFollowersUri(values.identifier), content: post.content, mediaType: "text/html", published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`), @@ -3209,15 +3209,15 @@ federation.setObjectDispatcher( The property values filled when creating the `Note` object have the following roles: - - Putting `ctx.getActorUri(values.handle)` in the `attribution` property + - Putting `ctx.getActorUri(values.identifier)` in the `attribution` property indicates that the author of this post is the actor we created. - Putting `PUBLIC_COLLECTION` in the `to` property indicates that this post is a public post. - - Putting `ctx.getFollowersUri(values.handle)` in the `cc` property indicates - that this post is delivered to followers, but this itself doesn't have much - meaning. + - Putting `ctx.getFollowersUri(values.identifier)` in the `cc` property + indicates that this post is delivered to followers, but this itself doesn't + have much meaning. Now, let's try entering the post's permalink (replace with your @@ -3275,7 +3275,7 @@ app.post("/users/:username/posts", async (c) => { .get(actor.id, stringifyEntities(content, { escapeOnly: true })); if (post == null) return null; const url = ctx.getObjectUri(Note, { - handle: username, + identifier: username, id: post.id.toString(), }).href; db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run( @@ -3286,10 +3286,10 @@ app.post("/users/:username/posts", async (c) => { return post; })(); if (post == null) return c.text("Failed to create post", 500); - const noteArgs = { handle: username, id: post.id.toString() }; + const noteArgs = { identifier: username, id: post.id.toString() }; const note = await ctx.getObject(Note, noteArgs); await ctx.sendActivity( - { handle: username }, + { identifier: username }, "followers", new Create({ id: new URL("#activity", note?.id ?? undefined), @@ -3525,7 +3525,7 @@ app.post("/users/:username/following", async (c) => { } const ctx = fedi.createContext(c.req.raw, undefined); await ctx.sendActivity( - { handle: username }, + { identifier: username }, actor, new Follow({ actor: ctx.getActorUri(username), @@ -3689,7 +3689,7 @@ const followingId = 0 as number; const follower = {} as unknown as APActor; // ---cut-before--- federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { // ... omitted ... if (followingId == null) { @@ -3725,7 +3725,7 @@ async function persistActor(actor: APActor): Promise { return null; } federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut-before--- .on(Accept, async (ctx, accept) => { const follow = await accept.getObject(); @@ -3751,7 +3751,7 @@ federation ) ) `, - ).run(followingId, parsed.handle); + ).run(followingId, parsed.identifier); }); ~~~~ @@ -4148,7 +4148,7 @@ async function persistActor(actor: APActor): Promise { return null; } federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut-before--- .on(Create, async (ctx, create) => { const object = await create.getObject(); diff --git a/examples/blog/federation/mod.ts b/examples/blog/federation/mod.ts index dd958c4f..c39b3033 100644 --- a/examples/blog/federation/mod.ts +++ b/examples/blog/federation/mod.ts @@ -45,45 +45,48 @@ export const federation = createFederation({ // `Actor` object (`Person` in this case) for a given actor URI. // The actor dispatch is not only used for the actor URI, but also for // the WebFinger resource: -federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { - const blog = await getBlog(); - if (blog == null) return null; - else if (blog.handle !== handle) return null; - // A `Context` object has several purposes, and one of - // them is to provide a way to get the key pairs for the actor in various - // formats: - const keyPairs = await ctx.getActorKeyPairs(handle); - return new Person({ - id: ctx.getActorUri(handle), - name: blog.title, - summary: blog.description, - preferredUsername: handle, - url: new URL("/", ctx.url), - published: blog.published, - discoverable: true, - suspended: false, - indexable: true, - memorial: false, +federation.setActorDispatcher( + "/users/{identifier}", + async (ctx, identifier) => { + const blog = await getBlog(); + if (blog == null) return null; + else if (blog.handle !== identifier) return null; // A `Context` object has several purposes, and one of - // them is to provide a way to generate URIs for the dispatchers and - // the collections: - outbox: ctx.getOutboxUri(handle), - inbox: ctx.getInboxUri(handle), - endpoints: new Endpoints({ - sharedInbox: ctx.getInboxUri(), - }), - following: ctx.getFollowingUri(handle), - followers: ctx.getFollowersUri(handle), - // The `publicKey` and `assertionMethods` are used by peer servers - // to verify the signature of the actor: - publicKey: keyPairs[0].cryptographicKey, - assertionMethods: keyPairs.map((keyPair) => keyPair.multikey), - }); -}) - .setKeyPairsDispatcher(async (_ctx, handle) => { + // them is to provide a way to get the key pairs for the actor in various + // formats: + const keyPairs = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + name: blog.title, + summary: blog.description, + preferredUsername: identifier, + url: new URL("/", ctx.url), + published: blog.published, + discoverable: true, + suspended: false, + indexable: true, + memorial: false, + // A `Context` object has several purposes, and one of + // them is to provide a way to generate URIs for the dispatchers and + // the collections: + outbox: ctx.getOutboxUri(identifier), + inbox: ctx.getInboxUri(identifier), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + following: ctx.getFollowingUri(identifier), + followers: ctx.getFollowersUri(identifier), + // The `publicKey` and `assertionMethods` are used by peer servers + // to verify the signature of the actor: + publicKey: keyPairs[0].cryptographicKey, + assertionMethods: keyPairs.map((keyPair) => keyPair.multikey), + }); + }, +) + .setKeyPairsDispatcher(async (_ctx, identifier) => { const blog = await getBlog(); if (blog == null) return []; - else if (blog.handle !== handle) return []; + else if (blog.handle !== identifier) return []; return [ { publicKey: blog.publicKey, @@ -114,12 +117,12 @@ federation.setObjectDispatcher( // Registers the outbox dispatcher, which is responsible for listing // activities in the outbox: federation.setOutboxDispatcher( - "/users/{handle}/outbox", - async (ctx, handle, cursor) => { + "/users/{identifier}/outbox", + async (ctx, identifier, cursor) => { if (cursor == null) return null; const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; const activities: Activity[] = []; const { posts, nextCursor } = await getPosts( undefined, @@ -130,7 +133,7 @@ federation.setOutboxDispatcher( const comments = await getComments(post.uuid); const activity = new Create({ id: new URL(`/posts/${post.uuid}#activity`, ctx.request.url), - actor: ctx.getActorUri(handle), + actor: ctx.getActorUri(identifier), to: new URL("https://www.w3.org/ns/activitystreams#Public"), object: toArticle(ctx, blog, post, comments), }); @@ -144,25 +147,25 @@ federation.setOutboxDispatcher( ) // Registers the outbox counter, which is responsible for counting the // total number of activities in the outbox: - .setCounter(async (_ctx, handle) => { + .setCounter(async (_ctx, identifier) => { const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; return countPosts(); }) // Registers the first cursor. The cursor value here is arbitrary, but // it must be parsable by the outbox dispatcher: - .setFirstCursor(async (_ctx, handle) => { + .setFirstCursor(async (_ctx, identifier) => { const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; // Treat the empty string as the first cursor: return ""; }); // Registers the inbox listeners, which are responsible for handling // incoming activities in the inbox: -federation.setInboxListeners("/users/{handle}/inbox", "/inbox") +federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") // The `Follow` activity is handled by adding the follower to the // follower list: .on(Follow, async (ctx, follow) => { @@ -170,7 +173,7 @@ federation.setInboxListeners("/users/{handle}/inbox", "/inbox") if (blog == null) return; if (follow.id == null || follow.objectId == null) return; const parsed = ctx.parseUri(follow.objectId); - if (parsed?.type !== "actor" || parsed.handle !== blog.handle) return; + if (parsed?.type !== "actor" || parsed.identifier !== blog.handle) return; const recipient = await follow.getActor(ctx); if ( recipient == null || recipient.id == null || @@ -192,7 +195,7 @@ federation.setInboxListeners("/users/{handle}/inbox", "/inbox") // with either an `Accept` or a `Reject` activity. In this case, the // server automatically accepts the follow request: await ctx.sendActivity( - { handle: blog.handle }, + { identifier: blog.handle }, recipient, new Accept({ id: new URL(`#accept/${handle}`, ctx.getActorUri(blog.handle)), @@ -257,11 +260,11 @@ federation.setInboxListeners("/users/{handle}/inbox", "/inbox") // Since the blog does not follow anyone, the following dispatcher is // implemented to return just an empty list: federation.setFollowingDispatcher( - "/users/{handle}/following", - async (_ctx, handle, _cursor) => { + "/users/{identifier}/following", + async (_ctx, identifier, _cursor) => { const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; return { items: [] }; }, ); @@ -270,11 +273,11 @@ federation.setFollowingDispatcher( // listing the followers of the blog: federation .setFollowersDispatcher( - "/users/{handle}/followers", - async (_ctx, handle, cursor) => { + "/users/{identifier}/followers", + async (_ctx, identifier, cursor) => { const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; if (cursor == null) return null; const { followers, nextCursor } = await getFollowers( undefined, @@ -289,18 +292,18 @@ federation ) // Registers the followers counter, which is responsible for counting // the total number of followers: - .setCounter(async (_ctx, handle) => { + .setCounter(async (_ctx, identifier) => { const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; return await countFollowers(); }) // Registers the first cursor. The cursor value here is arbitrary, but // it must be parsable by the followers collection dispatcher: - .setFirstCursor(async (_ctx, handle) => { + .setFirstCursor(async (_ctx, identifier) => { const blog = await getBlog(); if (blog == null) return null; - else if (blog.handle !== handle) return null; + else if (blog.handle !== identifier) return null; // Treat the empty string as the first cursor: return ""; }); diff --git a/examples/express/app.ts b/examples/express/app.ts index d4b05e57..324b2b2e 100644 --- a/examples/express/app.ts +++ b/examples/express/app.ts @@ -2,13 +2,13 @@ import express from "express"; import { integrateFederation } from "@fedify/express"; import { Accept, + createFederation, Endpoints, Follow, - Person, - Undo, - createFederation, generateCryptoKeyPair, MemoryKvStore, + Person, + Undo, } from "@fedify/fedify"; import { configure, getConsoleSink } from "@logtape/logtape"; @@ -35,18 +35,18 @@ const federation = createFederation({ }); federation - .setActorDispatcher("/users/{handle}", async (ctx, handle) => { - if (handle != "demo") { + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + if (identifier != "demo") { return null; } - const keyPairs = await ctx.getActorKeyPairs(handle); + const keyPairs = await ctx.getActorKeyPairs(identifier); return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Fedify Demo", summary: "This is a Fedify Demo account.", - preferredUsername: handle, + preferredUsername: identifier, url: new URL("/", ctx.url), - inbox: ctx.getInboxUri(handle), + inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), @@ -54,21 +54,21 @@ federation assertionMethods: keyPairs.map((keyPair) => keyPair.multikey), }); }) - .setKeyPairsDispatcher(async (_, handle) => { - if (handle != "demo") { + .setKeyPairsDispatcher(async (_, identifier) => { + if (identifier != "demo") { return []; } - const keyPairs = keyPairsStore.get(handle); + const keyPairs = keyPairsStore.get(identifier); if (keyPairs) { return keyPairs; } const { privateKey, publicKey } = await generateCryptoKeyPair(); - keyPairsStore.set(handle, [{ privateKey, publicKey }]); + keyPairsStore.set(identifier, [{ privateKey, publicKey }]); return [{ privateKey, publicKey }]; }); federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (context, follow) => { if ( follow.id == null || @@ -78,7 +78,7 @@ federation return; } const result = context.parseUri(follow.objectId); - if (result?.type !== "actor" || result.handle !== "demo") { + if (result?.type !== "actor" || result.identifier !== "demo") { return; } const follower = await follow.getActor(context); @@ -86,16 +86,16 @@ federation throw new Error("follower is null"); } await context.sendActivity( - { handle: result.handle }, + { identifier: result.identifier }, follower, new Accept({ id: new URL( `#accepts/${follower.id.href}`, - context.getActorUri("demo") + context.getActorUri("demo"), ), actor: follow.objectId, object: follow, - }) + }), ); relationStore.set(follower.id.href, follow.actorId.href); }) diff --git a/examples/hono-sample/main.ts b/examples/hono-sample/main.ts index edb776ea..b7292d1e 100644 --- a/examples/hono-sample/main.ts +++ b/examples/hono-sample/main.ts @@ -7,12 +7,12 @@ const fedi = createFederation({ kv: new MemoryKvStore(), }); -fedi.setActorDispatcher("/{handle}", (ctx, handle, _key) => { - if (handle !== "sample") return null; +fedi.setActorDispatcher("/{identifier}", (ctx, identifier) => { + if (identifier !== "sample") return null; return new Person({ - id: ctx.getActorUri(handle), + id: ctx.getActorUri(identifier), name: "Sample", - preferredUsername: handle, + preferredUsername: identifier, }); }); diff --git a/examples/next-app-router/app/[fedify]/[[...catchAll]]/route.ts b/examples/next-app-router/app/[fedify]/[[...catchAll]]/route.ts index 30c1affe..d2798614 100644 --- a/examples/next-app-router/app/[fedify]/[[...catchAll]]/route.ts +++ b/examples/next-app-router/app/[fedify]/[[...catchAll]]/route.ts @@ -1,12 +1,12 @@ import { Accept, + createFederation, Endpoints, Follow, - Person, - Undo, - createFederation, generateCryptoKeyPair, MemoryKvStore, + Person, + Undo, } from "@fedify/fedify"; import { keyPairsStore, relationStore } from "~/data/store"; import { integrateFederation } from "~/shared/integrate-fedify"; @@ -18,26 +18,26 @@ const federation = createFederation({ const requestHanlder = integrateFederation(federation, () => {}); export { + requestHanlder as DELETE, requestHanlder as GET, + requestHanlder as PATCH, requestHanlder as POST, requestHanlder as PUT, - requestHanlder as PATCH, - requestHanlder as DELETE, }; federation - .setActorDispatcher("/users/{handle}", async (context, handle) => { - if (handle != "demo") { + .setActorDispatcher("/users/{identifier}", async (context, identifier) => { + if (identifier != "demo") { return null; } - const keyPairs = await context.getActorKeyPairs(handle); + const keyPairs = await context.getActorKeyPairs(identifier); return new Person({ - id: context.getActorUri(handle), + id: context.getActorUri(identifier), name: "Fedify Demo", summary: "This is a Fedify Demo account.", - preferredUsername: handle, + preferredUsername: identifier, url: new URL("/", context.url), - inbox: context.getInboxUri(handle), + inbox: context.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: context.getInboxUri(), }), @@ -45,21 +45,21 @@ federation assertionMethods: keyPairs.map((keyPair) => keyPair.multikey), }); }) - .setKeyPairsDispatcher(async (_, handle) => { - if (handle != "demo") { + .setKeyPairsDispatcher(async (_, identifier) => { + if (identifier != "demo") { return []; } - const keyPairs = keyPairsStore.get(handle); + const keyPairs = keyPairsStore.get(identifier); if (keyPairs) { return keyPairs; } const { privateKey, publicKey } = await generateCryptoKeyPair(); - keyPairsStore.set(handle, [{ privateKey, publicKey }]); + keyPairsStore.set(identifier, [{ privateKey, publicKey }]); return [{ privateKey, publicKey }]; }); federation - .setInboxListeners("/users/{handle}/inbox", "/inbox") + .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (context, follow) => { if ( follow.id == null || @@ -69,7 +69,7 @@ federation return; } const result = context.parseUri(follow.objectId); - if (result?.type !== "actor" || result.handle !== "demo") { + if (result?.type !== "actor" || result.identifier !== "demo") { return; } const follower = await follow.getActor(context); @@ -77,16 +77,16 @@ federation throw new Error("follower is null"); } await context.sendActivity( - { handle: result.handle }, + { identifier: result.identifier }, follower, new Accept({ id: new URL( `#accepts/${follower.id.href}`, - context.getActorUri("demo") + context.getActorUri("demo"), ), actor: follow.objectId, object: follow, - }) + }), ); relationStore.set(follower.id.href, follow.actorId.href); }) diff --git a/src/federation/callback.ts b/src/federation/callback.ts index 6ef78149..d307c11d 100644 --- a/src/federation/callback.ts +++ b/src/federation/callback.ts @@ -20,11 +20,11 @@ export type NodeInfoDispatcher = ( * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The request context. - * @param handle The actor's handle. + * @param identifier The actor's internal identifier or username. */ export type ActorDispatcher = ( context: RequestContext, - handle: string, + identifier: string, ) => Actor | null | Promise; /** @@ -32,21 +32,23 @@ export type ActorDispatcher = ( * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The context. - * @param handle The actor's handle. + * @param identifier The actor's internal identifier or username. * @returns The key pairs. * @since 0.10.0 */ export type ActorKeyPairsDispatcher = ( context: Context, - handle: string, + identifier: string, ) => CryptoKeyPair[] | Promise; /** * A callback that maps a WebFinger username to the corresponding actor's - * internal handle, or `null` if the username is not found. + * internal identifier, or `null` if the username is not found. * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The context. * @param username The WebFinger username. + * @returns The actor's internal identifier, or `null` if the username is not + * found. * @since 0.15.0 */ export type ActorHandleMapper = ( @@ -80,7 +82,8 @@ export type ObjectDispatcher< * @typeParam TContextData The context data to pass to the `TContext`. * @typeParam TFilter The type of the filter, if any. * @param context The context. - * @param handle The handle of the collection owner. + * @param identifier The internal identifier or the username of the collection + * owner. * @param cursor The cursor to start the collection from, or `null` to dispatch * the entire collection without pagination. * @param filter The filter to apply to the collection, if any. @@ -92,7 +95,7 @@ export type CollectionDispatcher< TFilter, > = ( context: TContext, - handle: string, + identifier: string, cursor: string | null, filter?: TFilter, ) => PageItems | null | Promise | null>; @@ -101,10 +104,14 @@ export type CollectionDispatcher< * A callback that counts the number of items in a collection. * * @typeParam TContextData The context data to pass to the {@link Context}. + * @param context The context. + * @param identifier The internal identifier or the username of the collection + * owner. + * @param filter The filter to apply to the collection, if any. */ export type CollectionCounter = ( context: RequestContext, - handle: string, + identifier: string, filter?: TFilter, ) => number | bigint | null | Promise; @@ -116,7 +123,8 @@ export type CollectionCounter = ( * @typeParam TContextData The context data to pass to the {@link Context}. * @typeParam TFilter The type of the filter, if any. * @param context The context. - * @param handle The handle of the collection owner. + * @param identifier The internal identifier or the username of the collection + * owner. * @param filter The filter to apply to the collection, if any. */ export type CollectionCursor< @@ -125,7 +133,7 @@ export type CollectionCursor< TFilter, > = ( context: TContext, - handle: string, + identifier: string, filter?: TFilter, ) => string | null | Promise; @@ -159,18 +167,27 @@ export type InboxErrorHandler = ( * * @typeParam TContextData The context data to pass to the {@link Context}. * @param context The 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. If `null` is returned, the request is not authorized. + * @returns The username or the internal identifier of the actor or the key pair + * for the authenticated document loader of the {@link Context} passed + * to the shared inbox listener. If `null` is returned, the request is + * not authorized. * @since 0.11.0 */ export type SharedInboxKeyDispatcher = ( context: Context, ) => | SenderKeyPair + | { identifier: string } + | { username: string } | { handle: string } | null - | Promise; + | Promise< + | SenderKeyPair + | { identifier: string } + | { username: string } + | { handle: string } + | null + >; /** * A callback that handles errors during outbox processing. @@ -190,7 +207,7 @@ export type OutboxErrorHandler = ( * * @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 identifier The internal identifier 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. * @param signedKeyOwner The actor that owns the key that was used to sign the @@ -202,7 +219,7 @@ export type OutboxErrorHandler = ( */ export type AuthorizePredicate = ( context: RequestContext, - handle: string, + identifier: string, signedKey: CryptographicKey | null, signedKeyOwner: Actor | null, ) => boolean | Promise; diff --git a/src/federation/context.ts b/src/federation/context.ts index bbd43d16..30d236fc 100644 --- a/src/federation/context.ts +++ b/src/federation/context.ts @@ -59,12 +59,12 @@ export interface Context { getNodeInfoUri(): URL; /** - * Builds the URI of an actor with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor with the given identifier. + * @param identifier The actor's identifier. * @returns The actor's URI. * @throws {RouterError} If no actor dispatcher is available. */ - getActorUri(handle: string): URL; + getActorUri(identifier: string): URL; /** * Builds the URI of an object with the given class and values. @@ -82,12 +82,12 @@ export interface Context { ): URL; /** - * Builds the URI of an actor's outbox with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor's outbox with the given identifier. + * @param identifier The actor's identifier. * @returns The actor's outbox URI. * @throws {RouterError} If no outbox dispatcher is available. */ - getOutboxUri(handle: string): URL; + getOutboxUri(identifier: string): URL; /** * Builds the URI of the shared inbox. @@ -97,56 +97,58 @@ export interface Context { getInboxUri(): URL; /** - * Builds the URI of an actor's inbox with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor's inbox with the given identifier. + * @param identifier The actor's identifier. * @returns The actor's inbox URI. * @throws {RouterError} If no inbox listener is available. */ - getInboxUri(handle: string): URL; + getInboxUri(identifier: string): URL; /** - * Builds the URI of an actor's following collection with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor's following collection with the given + * identifier. + * @param identifier The actor's identifier. * @returns The actor's following collection URI. * @throws {RouterError} If no following collection is available. */ - getFollowingUri(handle: string): URL; + getFollowingUri(identifier: string): URL; /** - * Builds the URI of an actor's followers collection with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor's followers collection with the given + * identifier. + * @param identifier The actor's identifier. * @returns The actor's followers collection URI. * @throws {RouterError} If no followers collection is available. */ - getFollowersUri(handle: string): URL; + getFollowersUri(identifier: string): URL; /** - * Builds the URI of an actor's liked collection with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor's liked collection with the given identifier. + * @param identifier The actor's identifier. * @returns The actor's liked collection URI. * @throws {RouterError} If no liked collection is available. * @since 0.11.0 */ - getLikedUri(handle: string): URL; + getLikedUri(identifier: string): URL; /** - * Builds the URI of an actor's featured collection with the given handle. - * @param handle The actor's handle. + * Builds the URI of an actor's featured collection with the given identifier. + * @param identifier The actor's identifier. * @returns The actor's featured collection URI. * @throws {RouterError} If no featured collection is available. * @since 0.11.0 */ - getFeaturedUri(handle: string): URL; + getFeaturedUri(identifier: string): URL; /** * Builds the URI of an actor's featured tags collection with the given - * handle. - * @param handle The actor's handle. + * identifier. + * @param identifier The actor's identifier. * @returns The actor's featured tags collection URI. * @throws {RouterError} If no featured tags collection is available. * @since 0.11.0 */ - getFeaturedTagsUri(handle: string): URL; + getFeaturedTagsUri(identifier: string): URL; /** * Determines the type of the URI and extracts the associated data. @@ -159,24 +161,29 @@ export interface Context { /** * Gets the key pairs for an actor. - * @param handle The actor's handle. + * @param identifier The actor's identifier. * @returns An async iterable of the actor's key pairs. It can be empty. * @since 0.10.0 */ - getActorKeyPairs(handle: string): Promise; + getActorKeyPairs(identifier: string): Promise; /** * Gets an authenticated {@link DocumentLoader} for the given identity. * Note that an authenticated document loader intentionally does not cache * the fetched documents. * @param identity The identity to get the document loader for. - * The actor's handle. + * The actor's identifier or username. * @returns The authenticated document loader. * @throws {Error} If the identity is not valid. * @throws {TypeError} If the key is invalid or unsupported. * @since 0.4.0 */ - getDocumentLoader(identity: { handle: string }): Promise; + getDocumentLoader( + identity: + | { identifier: string } + | { username: string } + | { handle: string }, + ): Promise; /** * Gets an authenticated {@link DocumentLoader} for the given identity. @@ -236,13 +243,19 @@ export interface Context { /** * Sends an activity to recipients' inboxes. - * @param sender The sender's handle or the sender's key pair(s). + * @param sender The sender's identifier or the sender's username or + * the sender's key pair(s). * @param recipients The recipients of the activity. * @param activity The activity to send. * @param options Options for sending the activity. */ sendActivity( - sender: SenderKeyPair | SenderKeyPair[] | { handle: string }, + sender: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string } + | { handle: string }, recipients: Recipient | Recipient[], activity: Activity, options?: SendActivityOptions, @@ -250,7 +263,7 @@ export interface Context { /** * Sends an activity to the outboxes of the sender's followers. - * @param sender The sender's handle. + * @param sender The sender's identifier or the sender's username. * @param recipients In this case, it must be `"followers"`. * @param activity The activity to send. * @param options Options for sending the activity. @@ -258,7 +271,7 @@ export interface Context { * @since 0.14.0 */ sendActivity( - sender: { handle: string }, + sender: { identifier: string } | { username: string } | { handle: string }, recipients: "followers", activity: Activity, options?: SendActivityOptions, @@ -280,13 +293,13 @@ export interface RequestContext extends Context { readonly url: URL; /** - * Gets an {@link Actor} object for the given handle. - * @param handle The actor's handle. + * Gets an {@link Actor} object for the given identifier. + * @param identifier The actor's identifier. * @returns The actor object, or `null` if the actor is not found. * @throws {Error} If no actor dispatcher is available. * @since 0.7.0 */ - getActor(handle: string): Promise; + getActor(identifier: string): Promise; /** * Gets an object of the given class with the given values. @@ -346,13 +359,19 @@ export interface InboxContext extends Context { * Integrity Proofs will not be added. Therefore, if the activity is not * signed (i.e., it has neither Linked Data Signatures nor Object Integrity * Proofs), the recipient probably will not trust the activity. - * @param forwarder The forwarder's handle or the forwarder's key pair(s). + * @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 1.0.0 */ forwardActivity( - forwarder: SenderKeyPair | SenderKeyPair[] | { handle: string }, + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string } + | { handle: string }, recipients: Recipient | Recipient[], options?: ForwardActivityOptions, ): Promise; @@ -364,13 +383,16 @@ export interface InboxContext extends Context { * Integrity Proofs will not be added. Therefore, if the activity is not * signed (i.e., it has neither Linked Data Signatures nor Object Integrity * Proofs), the recipient probably will not trust the activity. - * @param forwarder The forwarder's handle. + * @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 1.0.0 */ forwardActivity( - forwarder: { handle: string }, + forwarder: + | { identifier: string } + | { username: string } + | { handle: string }, recipients: "followers", options?: ForwardActivityOptions, ): Promise; @@ -383,49 +405,88 @@ export type ParseUriResult = /** * The case of an actor URI. */ - | { type: "actor"; handle: string } + | { + readonly type: "actor"; + readonly identifier: string; + readonly handle: string; + } /** * The case of an object URI. */ | { - type: "object"; + readonly type: "object"; // deno-lint-ignore no-explicit-any - class: (new (...args: any[]) => Object) & { typeId: URL }; - typeId: URL; - values: Record; + readonly class: (new (...args: any[]) => Object) & { typeId: URL }; + readonly typeId: URL; + readonly values: Record; + } + /** + * The case of an shared inbox URI. + */ + | { + readonly type: "inbox"; + readonly identifier: undefined; + readonly handle: undefined; } /** - * The case of an inbox URI. If `handle` is `undefined`, - * it is a shared inbox. + * The case of an personal inbox URI. */ - | { type: "inbox"; handle?: string } + | { + readonly type: "inbox"; + readonly identifier: string; + readonly handle: string; + } /** * The case of an outbox collection URI. */ - | { type: "outbox"; handle: string } + | { + readonly type: "outbox"; + readonly identifier: string; + readonly handle: string; + } /** * The case of a following collection URI. */ - | { type: "following"; handle: string } + | { + readonly type: "following"; + readonly identifier: string; + readonly handle: string; + } /** * The case of a followers collection URI. */ - | { type: "followers"; handle: string } + | { + readonly type: "followers"; + readonly identifier: string; + readonly handle: string; + } /** * The case of a liked collection URI. * @since 0.11.0 */ - | { type: "liked"; handle: string } + | { + readonly type: "liked"; + readonly identifier: string; + readonly handle: string; + } /** * The case of a featured collection URI. * @since 0.11.0 */ - | { type: "featured"; handle: string } + | { + readonly type: "featured"; + readonly identifier: string; + readonly handle: string; + } /** * The case of a featured tags collection URI. * @since 0.11.0 */ - | { type: "featuredTags"; handle: string }; + | { + readonly type: "featuredTags"; + readonly identifier: string; + readonly handle: string; + }; /** * Options for {@link Context.sendActivity} method. diff --git a/src/federation/federation.ts b/src/federation/federation.ts index a1f5f5ff..a18823a4 100644 --- a/src/federation/federation.ts +++ b/src/federation/federation.ts @@ -76,11 +76,10 @@ export interface Federation { * @example * ``` typescript * federation.setActorDispatcher( - * "/users/{handle}", - * async (ctx, handle) => { + * "/users/{identifier}", + * async (ctx, identifier) => { * return new Person({ - * id: ctx.getActorUri(handle), - * preferredUsername: handle, + * id: ctx.getActorUri(identifier), * // ... * }); * } @@ -90,13 +89,13 @@ export interface Federation { * @param path The URI path pattern for the actor dispatcher. The syntax is * based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path - * must have one variable: `{handle}`. + * must have one variable: `{identifier}`. * @param dispatcher An actor dispatcher callback to register. * @returns An object with methods to set other actor dispatcher callbacks. * @throws {RouterError} Thrown if the path pattern is invalid. */ setActorDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: ActorDispatcher, ): ActorCallbackSetters; @@ -227,16 +226,16 @@ export interface Federation { /** * Registers an inbox dispatcher. * - * @param path The URI path pattern for the outbox dispatcher. The syntax is + * @param path The URI path pattern for the inbox 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. + * must have one variable: `{identifier}`, 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. */ setInboxDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext, @@ -255,8 +254,8 @@ export interface Federation { * @example * ``` typescript * federation.setOutboxDispatcher( - * "/users/{handle}/outbox", - * async (ctx, handle, options) => { + * "/users/{identifier}/outbox", + * async (ctx, identifier, options) => { * let items: Activity[]; * let nextCursor: string; * // ... @@ -268,12 +267,12 @@ export interface Federation { * @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}`. + * must have one variable: `{identifier}`. * @param dispatcher An outbox dispatcher callback to register. * @throws {@link RouterError} Thrown if the path pattern is invalid. */ setOutboxDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext, @@ -291,14 +290,14 @@ export interface Federation { * @param path The URI path pattern for the following collection. The syntax * is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path - * must have one variable: `{handle}`. + * must have one variable: `{identifier}`. * @param dispatcher A following collection callback to register. * @returns An object with methods to set other following collection * callbacks. * @throws {RouterError} Thrown if the path pattern is invalid. */ setFollowingDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Actor | URL, RequestContext, @@ -316,14 +315,14 @@ export interface Federation { * @param path The URI path pattern for the followers collection. The syntax * is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path - * must have one variable: `{handle}`. + * must have one variable: `{identifier}`. * @param dispatcher A followers collection callback to register. * @returns An object with methods to set other followers collection * callbacks. * @throws {@link RouterError} Thrown if the path pattern is invalid. */ setFollowersDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Recipient, Context, @@ -337,14 +336,14 @@ export interface Federation { * @param path The URI path pattern for the liked collection. The syntax * is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path - * must have one variable: `{handle}`. + * must have one variable: `{identifier}`. * @param dispatcher A liked collection callback to register. * @returns An object with methods to set other liked collection * callbacks. * @throws {@link RouterError} Thrown if the path pattern is invalid. */ setLikedDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Object | URL, RequestContext, @@ -362,14 +361,14 @@ export interface Federation { * @param path The URI path pattern for the featured collection. The syntax * is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path - * must have one variable: `{handle}`. + * must have one variable: `{identifier}`. * @param dispatcher A featured collection callback to register. * @returns An object with methods to set other featured collection * callbacks. * @throws {@link RouterError} Thrown if the path pattern is invalid. */ setFeaturedDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Object, RequestContext, @@ -387,14 +386,14 @@ export interface Federation { * @param path The URI path pattern for the featured tags collection. * The syntax is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path - * must have one variable: `{handle}`. + * must have one variable: `{identifier}`. * @param dispatcher A featured tags collection callback to register. * @returns An object with methods to set other featured tags collection * callbacks. * @throws {@link RouterError} Thrown if the path pattern is invalid. */ setFeaturedTagsDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Hashtag, RequestContext, @@ -413,12 +412,11 @@ export interface Federation { * @example * ``` typescript * federation - * .setInboxListeners("/users/{handle/inbox", "/inbox") + * .setInboxListeners("/users/{identifier}/inbox", "/inbox") * .on(Follow, async (ctx, follow) => { * const from = await follow.getActor(ctx); * if (!isActor(from)) return; * // ... - * await ctx.sendActivity({ }) * }) * .on(Undo, async (ctx, undo) => { * // ... @@ -428,7 +426,7 @@ export interface Federation { * @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}`, and must + * The path must have one variable: `{identifier}`, 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 @@ -438,7 +436,7 @@ export interface Federation { * @throws {RouteError} Thrown if the path pattern is invalid. */ setInboxListeners( - inboxPath: `${string}{handle}${string}`, + inboxPath: `${string}{identifier}${string}` | `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetters; @@ -464,11 +462,12 @@ export interface Federation { * * ``` typescript * const federation = createFederation({ ... }); - * federation.setActorDispatcher("/users/{handle}", async (ctx, handle, key) => { - * ... - * }) - * .setKeyPairsDispatcher(async (ctxData, handle) => { - * ... + * federation + * .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + * // ... + * }) + * .setKeyPairsDispatcher(async (ctxData, identifier) => { + * // ... * }); * ``` */ @@ -485,7 +484,7 @@ export interface ActorCallbackSetters { /** * Sets the callback function that maps a WebFinger username to - * the corresponding actor's internal handle. If it's omitted, the handle + * the corresponding actor's identifier. If it's omitted, the identifier * is assumed to be the same as the WebFinger username, which makes your * actors have the immutable handles. If you want to let your actors change * their fediverse handles, you should set this dispatcher. diff --git a/src/federation/handler.test.ts b/src/federation/handler.test.ts index 0be31d20..e3aff6b8 100644 --- a/src/federation/handler.test.ts +++ b/src/federation/handler.test.ts @@ -71,8 +71,8 @@ test("handleActor()", async () => { let context = createRequestContext({ data: undefined, url: new URL("https://example.com/"), - getActorUri(handle) { - return new URL(`https://example.com/users/${handle}`); + getActorUri(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, }); const actorDispatcher: ActorDispatcher = (ctx, handle) => { @@ -101,7 +101,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "someone", + identifier: "someone", onNotFound, onNotAcceptable, onUnauthorized, @@ -123,7 +123,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "someone", + identifier: "someone", actorDispatcher, onNotFound, onNotAcceptable, @@ -140,7 +140,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "no-one", + identifier: "no-one", actorDispatcher, onNotFound, onNotAcceptable, @@ -165,7 +165,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "someone", + identifier: "someone", actorDispatcher, onNotFound, onNotAcceptable, @@ -216,7 +216,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "no-one", + identifier: "no-one", actorDispatcher, onNotFound, onNotAcceptable, @@ -233,7 +233,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "someone", + identifier: "someone", actorDispatcher, authorizePredicate: (_ctx, _handle, signedKey, signedKeyOwner) => signedKey != null && signedKeyOwner != null, @@ -257,7 +257,7 @@ test("handleActor()", async () => { context.request, { context, - handle: "someone", + identifier: "someone", actorDispatcher, authorizePredicate: (_ctx, _handle, signedKey, signedKeyOwner) => signedKey != null && signedKeyOwner != null, @@ -562,8 +562,8 @@ test("handleCollection()", async () => { let context = createRequestContext({ data: undefined, url: new URL("https://example.com/"), - getActorUri(handle) { - return new URL(`https://example.com/users/${handle}`); + getActorUri(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, }); const dispatcher: CollectionDispatcher< @@ -622,9 +622,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, onNotFound, onNotAcceptable, @@ -642,9 +642,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, @@ -663,9 +663,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "no-one", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "no-one", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, @@ -692,9 +692,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "no-one", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "no-one", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, @@ -713,9 +713,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher }, onNotFound, @@ -786,9 +786,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, @@ -816,9 +816,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, @@ -875,9 +875,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, @@ -931,9 +931,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, @@ -991,9 +991,9 @@ test("handleCollection()", async () => { { context, name: "collection", - handle: "someone", - uriGetter(handle) { - return new URL(`https://example.com/users/${handle}`); + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, collectionCallbacks: { dispatcher, @@ -1061,8 +1061,8 @@ test("handleInbox()", async () => { onNotFoundCalled = request; return new Response("Not found", { status: 404 }); }; - const actorDispatcher: ActorDispatcher = (_ctx, handle) => { - if (handle !== "someone") return null; + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "someone") return null; return new Person({ name: "Someone" }); }; const inboxOptions = { @@ -1077,7 +1077,7 @@ test("handleInbox()", async () => { skipSignatureVerification: false, } as const; let response = await handleInbox(unsignedRequest, { - handle: null, + identifier: null, context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1090,7 +1090,7 @@ test("handleInbox()", async () => { onNotFoundCalled = null; response = await handleInbox(unsignedRequest, { - handle: "nobody", + identifier: "nobody", context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1102,7 +1102,7 @@ test("handleInbox()", async () => { onNotFoundCalled = null; response = await handleInbox(unsignedRequest, { - handle: null, + identifier: null, context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1113,7 +1113,7 @@ test("handleInbox()", async () => { assertEquals(response.status, 401); response = await handleInbox(unsignedRequest, { - handle: "someone", + identifier: "someone", context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1136,7 +1136,7 @@ test("handleInbox()", async () => { documentLoader: mockDocumentLoader, }); response = await handleInbox(signedRequest, { - handle: null, + identifier: null, context: signedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1147,7 +1147,7 @@ test("handleInbox()", async () => { assertEquals(response.status, 202); response = await handleInbox(signedRequest, { - handle: "someone", + identifier: "someone", context: signedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1158,7 +1158,7 @@ test("handleInbox()", async () => { assertEquals(response.status, 202); response = await handleInbox(unsignedRequest, { - handle: null, + identifier: null, context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); @@ -1170,7 +1170,7 @@ test("handleInbox()", async () => { assertEquals(response.status, 202); response = await handleInbox(unsignedRequest, { - handle: "someone", + identifier: "someone", context: unsignedContext, inboxContextFactory(_activity) { return createInboxContext(unsignedContext); diff --git a/src/federation/handler.ts b/src/federation/handler.ts index 0499e85d..fb639cf7 100644 --- a/src/federation/handler.ts +++ b/src/federation/handler.ts @@ -44,7 +44,7 @@ export function acceptsJsonLd(request: Request): boolean { } export interface ActorHandlerParameters { - handle: string; + identifier: string; context: RequestContext; actorDispatcher?: ActorDispatcher; authorizePredicate?: AuthorizePredicate; @@ -56,7 +56,7 @@ export interface ActorHandlerParameters { export async function handleActor( request: Request, { - handle, + identifier, context, actorDispatcher, authorizePredicate, @@ -67,19 +67,19 @@ export async function handleActor( ): Promise { const logger = getLogger(["fedify", "federation", "actor"]); if (actorDispatcher == null) { - logger.debug("Actor dispatcher is not set.", { handle }); + logger.debug("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); } - const actor = await actorDispatcher(context, handle); + const actor = await actorDispatcher(context, identifier); if (actor == null) { - logger.debug("Actor {handle} not found.", { handle }); + logger.debug("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } if (!acceptsJsonLd(request)) return await onNotAcceptable(request); if (authorizePredicate != null) { const key = await context.getSignedKey(); const keyOwner = await context.getSignedKeyOwner(); - if (!await authorizePredicate(context, handle, key, keyOwner)) { + if (!await authorizePredicate(context, identifier, key, keyOwner)) { return await onUnauthorized(request); } } @@ -176,7 +176,7 @@ export interface CollectionHandlerParameters< TFilter, > { name: string; - handle: string; + identifier: string; uriGetter: (handle: string) => URL; filter?: TFilter; filterPredicate?: (item: TItem) => boolean; @@ -201,7 +201,7 @@ export async function handleCollection< request: Request, { name, - handle, + identifier, uriGetter, filter, filterPredicate, @@ -216,17 +216,17 @@ export async function handleCollection< const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); let collection: OrderedCollection | OrderedCollectionPage; - const baseUri = uriGetter(handle); + const baseUri = uriGetter(identifier); if (cursor == null) { const firstCursor = await collectionCallbacks.firstCursor?.( context, - handle, + identifier, ); - const totalItems = await collectionCallbacks.counter?.(context, handle); + const totalItems = await collectionCallbacks.counter?.(context, identifier); if (firstCursor == null) { const page = await collectionCallbacks.dispatcher( context, - handle, + identifier, null, filter, ); @@ -240,7 +240,7 @@ export async function handleCollection< } else { const lastCursor = await collectionCallbacks.lastCursor?.( context, - handle, + identifier, ); const first = new URL(context.url); first.searchParams.set("cursor", firstCursor); @@ -261,7 +261,7 @@ export async function handleCollection< uri.searchParams.set("cursor", cursor); const page = await collectionCallbacks.dispatcher( context, - handle, + identifier, cursor, filter, ); @@ -294,7 +294,7 @@ export async function handleCollection< if ( !await collectionCallbacks.authorizePredicate( context, - handle, + identifier, key, keyOwner, ) @@ -341,7 +341,7 @@ function filterCollectionItems( } export interface InboxHandlerParameters { - handle: string | null; + identifier: string | null; context: RequestContext; inboxContextFactory( activity: unknown, @@ -363,7 +363,7 @@ export interface InboxHandlerParameters { export async function handleInbox( request: Request, { - handle, + identifier, context, inboxContextFactory, kv, @@ -379,12 +379,12 @@ export async function handleInbox( ): Promise { const logger = getLogger(["fedify", "federation", "inbox"]); if (actorDispatcher == null) { - logger.error("Actor dispatcher is not set.", { handle }); + logger.error("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); - } else if (handle != null) { - const actor = await actorDispatcher(context, handle); + } else if (identifier != null) { + const actor = await actorDispatcher(context, identifier); if (actor == null) { - logger.error("Actor {handle} not found.", { handle }); + logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } } @@ -392,7 +392,7 @@ export async function handleInbox( try { json = await request.clone().json(); } catch (error) { - logger.error("Failed to parse JSON:\n{error}", { handle, error }); + logger.error("Failed to parse JSON:\n{error}", { identifier, error }); try { await inboxErrorHandler?.(context, error); } catch (error) { @@ -437,10 +437,13 @@ export async function handleInbox( const jsonWithoutSig = detachSignature(json); let activity: Activity | null = null; if (ldSigVerified) { - logger.debug("Linked Data Signatures are verified.", { handle, json }); + logger.debug("Linked Data Signatures are verified.", { identifier, json }); activity = await Activity.fromJsonLd(jsonWithoutSig, context); } else { - logger.debug("Linked Data Signatures are not verified.", { handle, json }); + logger.debug( + "Linked Data Signatures are not verified.", + { identifier, json }, + ); try { activity = await verifyObject(Activity, jsonWithoutSig, { contextLoader: context.contextLoader, @@ -449,7 +452,7 @@ export async function handleInbox( }); } catch (error) { logger.error("Failed to parse activity:\n{error}", { - handle, + identifier, json, error, }); @@ -469,10 +472,13 @@ export async function handleInbox( if (activity == null) { logger.debug( "Object Integrity Proofs are not verified.", - { handle, json }, + { identifier, json }, ); } else { - logger.debug("Object Integrity Proofs are verified.", { handle, json }); + logger.debug( + "Object Integrity Proofs are verified.", + { identifier, json }, + ); } } let httpSigKey: CryptographicKey | null = null; @@ -487,7 +493,7 @@ export async function handleInbox( if (key == null) { logger.error( "Failed to verify the request's HTTP Signatures.", - { handle }, + { identifier }, ); const response = new Response( "Failed to verify the request signature.", @@ -498,7 +504,7 @@ export async function handleInbox( ); return response; } else { - logger.debug("HTTP Signatures are verified.", { handle }); + logger.debug("HTTP Signatures are verified.", { identifier }); } httpSigKey = key; } @@ -554,7 +560,7 @@ export async function handleInbox( type: "inbox", baseUrl: request.url, activity: json, - handle, + identifier, attempt: 0, started: new Date().toISOString(), } satisfies InboxMessage, diff --git a/src/federation/middleware.test.ts b/src/federation/middleware.test.ts index 8a34d7ce..332f5b9d 100644 --- a/src/federation/middleware.test.ts +++ b/src/federation/middleware.test.ts @@ -13,6 +13,7 @@ import { getAuthenticatedDocumentLoader, } from "../runtime/docloader.ts"; import { signRequest, verifyRequest } from "../sig/http.ts"; +import type { KeyCache } from "../sig/key.ts"; import { detachSignature, signJsonLd, verifyJsonLd } from "../sig/ld.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { signObject, verifyObject } from "../sig/proof.ts"; @@ -31,6 +32,7 @@ import { lookupObject } from "../vocab/lookup.ts"; import { Activity, Create, + type CryptographicKey, Multikey, Note, Object, @@ -39,6 +41,7 @@ import { import type { Context } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; import { + ContextImpl, createFederation, FederationImpl, InboxContextImpl, @@ -122,13 +125,13 @@ test("Federation.createContext()", async (t) => { assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals(ctx.parseUri(null), null); assertEquals(await ctx.getActorKeyPairs("handle"), []); - assertRejects( - () => ctx.getDocumentLoader({ handle: "handle" }), + await assertRejects( + () => ctx.getDocumentLoader({ identifier: "handle" }), Error, "No actor key pairs dispatcher registered", ); - assertRejects( - () => ctx.sendActivity({ handle: "handle" }, [], new Create({})), + await assertRejects( + () => ctx.sendActivity({ identifier: "handle" }, [], new Create({})), Error, "No actor key pairs dispatcher registered", ); @@ -152,7 +155,7 @@ test("Federation.createContext()", async (t) => { ); federation - .setActorDispatcher("/users/{handle}", () => new Person({})) + .setActorDispatcher("/users/{identifier}", () => new Person({})) .setKeyPairsDispatcher(() => [ { privateKey: rsaPrivateKey2, @@ -162,7 +165,8 @@ test("Federation.createContext()", async (t) => { privateKey: ed25519PrivateKey, publicKey: ed25519PublicKey.publicKey!, }, - ]); + ]) + .mapHandle((_, username) => username === "HANDLE" ? "handle" : null); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getActorUri("handle"), @@ -171,7 +175,7 @@ test("Federation.createContext()", async (t) => { assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle")), - { type: "actor", handle: "handle" }, + { type: "actor", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); assertEquals( @@ -207,29 +211,35 @@ test("Federation.createContext()", async (t) => { }, ], ); - const loader = await ctx.getDocumentLoader({ handle: "handle" }); + const loader = await ctx.getDocumentLoader({ identifier: "handle" }); assertEquals(await loader("https://example.com/object"), { contextUrl: null, documentUrl: "https://example.com/object", document: true, }); - const loader2 = ctx.getDocumentLoader({ + const loader2 = await ctx.getDocumentLoader({ username: "HANDLE" }); + assertEquals(await loader2("https://example.com/object"), { + contextUrl: null, + documentUrl: "https://example.com/object", + document: true, + }); + const loader3 = ctx.getDocumentLoader({ keyId: new URL("https://example.com/key2"), privateKey: rsaPrivateKey2, }); - assertEquals(await loader2("https://example.com/object"), { + assertEquals(await loader3("https://example.com/object"), { contextUrl: null, documentUrl: "https://example.com/object", document: true, }); assertEquals(await ctx.lookupObject("https://example.com/object"), null); - assertRejects( - () => ctx.sendActivity({ handle: "handle" }, [], new Create({})), + await assertRejects( + () => ctx.sendActivity({ identifier: "handle" }, [], new Create({})), TypeError, "The activity to send must have at least one actor property.", ); await ctx.sendActivity( - { handle: "handle" }, + { identifier: "handle" }, [], new Create({ actor: new URL("https://example.com/users/handle"), @@ -255,16 +265,16 @@ test("Federation.createContext()", async (t) => { federation.setObjectDispatcher( Note, - "/users/{handle}/notes/{id}", + "/users/{identifier}/notes/{id}", (_ctx, values) => { return new Note({ - summary: `Note ${values.id} by ${values.handle}`, + summary: `Note ${values.id} by ${values.identifier}`, }); }, ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( - ctx.getObjectUri(Note, { handle: "john", id: "123" }), + ctx.getObjectUri(Note, { identifier: "john", id: "123" }), new URL("https://example.com/users/john/notes/123"), ); assertEquals( @@ -273,12 +283,12 @@ test("Federation.createContext()", async (t) => { type: "object", class: Note, typeId: new URL("https://www.w3.org/ns/activitystreams#Note"), - values: { handle: "john", id: "123" }, + values: { identifier: "john", id: "123" }, }, ); assertEquals(ctx.parseUri(null), null); - federation.setInboxListeners("/users/{handle}/inbox", "/inbox"); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals(ctx.getInboxUri(), new URL("https://example.com/inbox")); assertEquals( @@ -287,16 +297,16 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/inbox")), - { type: "inbox" }, + { type: "inbox", identifier: undefined, handle: undefined }, ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/inbox")), - { type: "inbox", handle: "handle" }, + { type: "inbox", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setOutboxDispatcher( - "/users/{handle}/outbox", + "/users/{identifier}/outbox", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); @@ -306,12 +316,12 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/outbox")), - { type: "outbox", handle: "handle" }, + { type: "outbox", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFollowingDispatcher( - "/users/{handle}/following", + "/users/{identifier}/following", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); @@ -321,12 +331,12 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/following")), - { type: "following", handle: "handle" }, + { type: "following", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFollowersDispatcher( - "/users/{handle}/followers", + "/users/{identifier}/followers", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); @@ -336,12 +346,12 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/followers")), - { type: "followers", handle: "handle" }, + { type: "followers", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setLikedDispatcher( - "/users/{handle}/liked", + "/users/{identifier}/liked", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); @@ -351,12 +361,12 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/liked")), - { type: "liked", handle: "handle" }, + { type: "liked", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFeaturedDispatcher( - "/users/{handle}/featured", + "/users/{identifier}/featured", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); @@ -366,12 +376,12 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/featured")), - { type: "featured", handle: "handle" }, + { type: "featured", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); federation.setFeaturedTagsDispatcher( - "/users/{handle}/tags", + "/users/{identifier}/tags", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); @@ -381,7 +391,7 @@ test("Federation.createContext()", async (t) => { ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/tags")), - { type: "featuredTags", handle: "handle" }, + { type: "featuredTags", identifier: "handle", handle: "handle" }, ); assertEquals(ctx.parseUri(null), null); }); @@ -399,11 +409,11 @@ test("Federation.createContext()", async (t) => { assertEquals(ctx.host, "example.com"); assertEquals(ctx.hostname, "example.com"); assertEquals(ctx.data, 123); - assertRejects( + await assertRejects( () => ctx.getActor("someone"), Error, ); - assertRejects( + await assertRejects( () => ctx.getObject(Note, { handle: "someone", id: "123" }), Error, ); @@ -412,7 +422,7 @@ test("Federation.createContext()", async (t) => { // Multiple calls should return the same result: assertEquals(await ctx.getSignedKey(), null); assertEquals(await ctx.getSignedKeyOwner(), null); - assertRejects( + await assertRejects( () => ctx.getActor("someone"), Error, "No actor dispatcher registered", @@ -453,8 +463,8 @@ test("Federation.createContext()", async (t) => { assertEquals(await signedCtx2.getSignedKeyOwner(), expectedOwner); federation.setActorDispatcher( - "/users/{handle}", - (_ctx, handle) => new Person({ preferredUsername: handle }), + "/users/{identifier}", + (_ctx, identifier) => new Person({ preferredUsername: identifier }), ); const ctx2 = federation.createContext(req, 789); assertEquals(ctx2.request, req); @@ -467,10 +477,10 @@ test("Federation.createContext()", async (t) => { federation.setObjectDispatcher( Note, - "/users/{handle}/notes/{id}", + "/users/{identifier}/notes/{id}", (_ctx, values) => { return new Note({ - summary: `Note ${values.id} by ${values.handle}`, + summary: `Note ${values.id} by ${values.identifier}`, }); }, ); @@ -479,7 +489,7 @@ test("Federation.createContext()", async (t) => { assertEquals(ctx3.url, new URL("https://example.com/")); assertEquals(ctx3.data, 123); assertEquals( - await ctx2.getObject(Note, { handle: "john", id: "123" }), + await ctx2.getObject(Note, { identifier: "john", id: "123" }), new Note({ summary: "Note 123 by john" }), ); }); @@ -537,11 +547,11 @@ test("Federation.setInboxListeners()", async (t) => { documentLoader: mockDocumentLoader, }); federation.setInboxDispatcher( - "/users/{handle}/inbox", + "/users/{identifier}/inbox", () => ({ items: [] }), ); assertThrows( - () => federation.setInboxListeners("/users/{handle}/inbox2"), + () => federation.setInboxListeners("/users/{identifier}/inbox2"), RouterError, ); }); @@ -554,18 +564,22 @@ test("Federation.setInboxListeners()", async (t) => { assertThrows( () => federation.setInboxListeners( - "/users/inbox" as `${string}{handle}${string}`, + "/users/inbox" as `${string}{identifier}${string}`, ), RouterError, ); assertThrows( - () => federation.setInboxListeners("/users/{handle}/inbox/{handle2}"), + () => federation.setInboxListeners("/users/{identifier}/inbox/{id2}"), + RouterError, + ); + assertThrows( + () => federation.setInboxListeners("/users/{identifier}/inbox/{handle}"), RouterError, ); assertThrows( () => federation.setInboxListeners( - "/users/{handle2}/inbox" as `${string}{handle}${string}`, + "/users/{identifier2}/inbox" as `${string}{identifier}${string}`, ), RouterError, ); @@ -587,7 +601,7 @@ test("Federation.setInboxListeners()", async (t) => { }, }); const inbox: [Context, Create][] = []; - federation.setInboxListeners("/users/{handle}/inbox", "/inbox") + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Create, (ctx, create) => { inbox.push([ctx, create]); }); @@ -601,8 +615,8 @@ test("Federation.setInboxListeners()", async (t) => { federation .setActorDispatcher( - "/users/{handle}", - (_, handle) => handle === "john" ? new Person({}) : null, + "/users/{identifier}", + (_, identifier) => identifier === "john" ? new Person({}) : null, ) .setKeyPairsDispatcher(() => [{ privateKey: rsaPrivateKey2, @@ -737,8 +751,8 @@ test("Federation.setInboxListeners()", async (t) => { }); federation .setActorDispatcher( - "/users/{handle}", - (_, handle) => handle === "john" ? new Person({}) : null, + "/users/{identifier}", + (_, identifier) => identifier === "john" ? new Person({}) : null, ) .setKeyPairsDispatcher(() => [{ privateKey: rsaPrivateKey2, @@ -746,7 +760,7 @@ test("Federation.setInboxListeners()", async (t) => { }]); const error = new Error("test"); const errors: unknown[] = []; - federation.setInboxListeners("/users/{handle}/inbox", "/inbox") + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Create, () => { throw error; }) @@ -788,11 +802,11 @@ test("Federation.setInboxDispatcher()", async (t) => { kv, documentLoader: mockDocumentLoader, }); - federation.setInboxListeners("/users/{handle}/inbox"); + federation.setInboxListeners("/users/{identifier}/inbox"); assertThrows( () => federation.setInboxDispatcher( - "/users/{handle}/inbox2", + "/users/{identifier}/inbox2", () => ({ items: [] }), ), RouterError, @@ -804,9 +818,9 @@ test("Federation.setInboxDispatcher()", async (t) => { kv, documentLoader: mockDocumentLoader, }); - federation.setInboxListeners("/users/{handle}/inbox"); + federation.setInboxListeners("/users/{identifier}/inbox"); federation.setInboxDispatcher( - "/users/{handle}/inbox", + "/users/{identifier}/inbox", () => ({ items: [] }), ); }); @@ -819,7 +833,7 @@ test("Federation.setInboxDispatcher()", async (t) => { assertThrows( () => federation.setInboxDispatcher( - "/users/inbox" as `${string}{handle}${string}`, + "/users/inbox" as `${string}{identifier}${string}`, () => ({ items: [] }), ), RouterError, @@ -827,7 +841,7 @@ test("Federation.setInboxDispatcher()", async (t) => { assertThrows( () => federation.setInboxDispatcher( - "/users/{handle}/inbox/{handle2}", + "/users/{identifier}/inbox/{identifier2}", () => ({ items: [] }), ), RouterError, @@ -835,7 +849,7 @@ test("Federation.setInboxDispatcher()", async (t) => { assertThrows( () => federation.setInboxDispatcher( - "/users/{handle2}/inbox" as `${string}{handle}${string}`, + "/users/{identifier2}/inbox" as `${string}{identifier}${string}`, () => ({ items: [] }), ), RouterError, @@ -964,6 +978,203 @@ test("FederationImpl.sendActivity()", async (t) => { mf.uninstall(); }); +test("ContextImpl.sendActivity()", async (t) => { + mf.install(); + + let verified: ("http" | "ld" | "proof")[] | null = null; + let request: Request | null = null; + mf.mock("POST@/inbox", async (req) => { + verified = []; + request = req.clone(); + const options = { + async documentLoader(url: string) { + const response = await federation.fetch( + new Request(url), + { contextData: undefined }, + ); + if (response.ok) { + return { + contextUrl: null, + document: await response.json(), + documentUrl: response.url, + }; + } + return await mockDocumentLoader(url); + }, + contextLoader: mockDocumentLoader, + keyCache: { + async get(keyId: URL) { + const ctx = await federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const keys = await ctx.getActorKeyPairs("1"); + for (const key of keys) { + if (key.keyId.href === keyId.href) { + if (key.publicKey.algorithm.name === "Ed25519") { + return key.multikey; + } else return key.cryptographicKey; + } + } + return null; + }, + async set(_keyId: URL, _key: CryptographicKey | Multikey) { + }, + } satisfies KeyCache, + }; + let json = await req.json(); + if (await verifyJsonLd(json, options)) verified.push("ld"); + json = detachSignature(json); + let activity = await verifyObject(Activity, json, options); + if (activity == null) { + activity = await Activity.fromJsonLd(json, options); + } else { + verified.push("proof"); + } + const key = await verifyRequest(request, options); + if (key != null && await doesActorOwnKey(activity, key, options)) { + verified.push("http"); + } + if (verified.length > 0) return new Response(null, { status: 202 }); + return new Response(null, { status: 401 }); + }); + + const kv = new MemoryKvStore(); + const federation = new FederationImpl({ + kv, + contextLoader: mockDocumentLoader, + }); + + federation + .setActorDispatcher("/{identifier}", async (ctx, identifier) => { + if (identifier !== "1") return null; + const keys = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: "john", + publicKey: keys[0].cryptographicKey, + assertionMethods: keys.map((k) => k.multikey), + }); + }) + .setKeyPairsDispatcher((_ctx, identifier) => { + if (identifier !== "1") return []; + return [ + { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! }, + { + privateKey: ed25519PrivateKey, + publicKey: ed25519PublicKey.publicKey!, + }, + ]; + }) + .mapHandle((_ctx, username) => username === "john" ? "1" : null); + + await t.step("success", async () => { + const activity = new Create({ + actor: new URL("https://example.com/person"), + }); + const ctx = new ContextImpl({ + data: undefined, + federation, + url: new URL("https://example.com/"), + documentLoader: fetchDocumentLoader, + }); + await ctx.sendActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity, + ); + assertEquals(verified, ["http"]); + assertInstanceOf(request, Request); + assertEquals(request?.method, "POST"); + assertEquals(request?.url, "https://example.com/inbox"); + assertEquals( + request?.headers.get("Content-Type"), + "application/activity+json", + ); + + verified = null; + await ctx.sendActivity( + [{ privateKey: rsaPrivateKey3, keyId: rsaPublicKey3.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity.clone({ + actor: new URL("https://example.com/person2"), + }), + ); + assertEquals(verified, ["ld", "http"]); + assertInstanceOf(request, Request); + assertEquals(request?.method, "POST"); + assertEquals(request?.url, "https://example.com/inbox"); + assertEquals( + request?.headers.get("Content-Type"), + "application/activity+json", + ); + + verified = null; + await ctx.sendActivity( + { identifier: "1" }, + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity.clone({ actor: ctx.getActorUri("1") }), + ); + assertEquals(verified, ["ld", "proof", "http"]); + assertInstanceOf(request, Request); + assertEquals(request?.method, "POST"); + assertEquals(request?.url, "https://example.com/inbox"); + assertEquals( + request?.headers.get("Content-Type"), + "application/activity+json", + ); + + verified = null; + await ctx.sendActivity( + { username: "john" }, + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity.clone({ actor: ctx.getActorUri("1") }), + ); + assertEquals(verified, ["ld", "proof", "http"]); + assertInstanceOf(request, Request); + assertEquals(request?.method, "POST"); + assertEquals(request?.url, "https://example.com/inbox"); + assertEquals( + request?.headers.get("Content-Type"), + "application/activity+json", + ); + + await assertRejects(() => + ctx.sendActivity( + { identifier: "not-found" }, + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity.clone({ actor: ctx.getActorUri("1") }), + ) + ); + + await assertRejects(() => + ctx.sendActivity( + { username: "not-found" }, + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + activity.clone({ actor: ctx.getActorUri("1") }), + ) + ); + }); +}); + test("InboxContextImpl.forwardActivity()", async (t) => { mf.install(); diff --git a/src/federation/middleware.ts b/src/federation/middleware.ts index 9e761980..e9a9b255 100644 --- a/src/federation/middleware.ts +++ b/src/federation/middleware.ts @@ -478,17 +478,18 @@ export class FederationImpl implements Federation { const logger = getLogger(["fedify", "federation", "inbox"]); const baseUrl = new URL(message.baseUrl); let context = this.#createContext(baseUrl, ctxData); - if (message.handle) { + if (message.identifier != null) { context = this.#createContext(baseUrl, ctxData, { documentLoader: await context.getDocumentLoader({ - handle: message.handle, + identifier: message.identifier, }), }); } else if (this.sharedInboxKeyDispatcher != null) { const identity = await this.sharedInboxKeyDispatcher(context); if (identity != null) { context = this.#createContext(baseUrl, ctxData, { - documentLoader: "handle" in identity + documentLoader: "identifier" in identity || "username" in identity || + "handle" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity), }); @@ -611,7 +612,7 @@ export class FederationImpl implements Federation { contextData: TContextData, opts?: { documentLoader?: DocumentLoader; - invokedFromActorDispatcher?: { handle: string }; + invokedFromActorDispatcher?: { identifier: string }; invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; @@ -625,7 +626,7 @@ export class FederationImpl implements Federation { contextData: TContextData, opts: { documentLoader?: DocumentLoader; - invokedFromActorDispatcher?: { handle: string }; + invokedFromActorDispatcher?: { identifier: string }; invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; @@ -674,33 +675,42 @@ export class FederationImpl implements Federation { } setActorDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: ActorDispatcher, ): ActorCallbackSetters { if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } const variables = this.router.add(path, "actor"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for actor dispatcher must have one variable: {handle}", + "Path for actor dispatcher must have one variable: {identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "actor"]).warn( + "The {handle} variable in the actor dispatcher path is deprecated. " + + "Use {identifier} instead.", ); } const callbacks: ActorCallbacks = { - dispatcher: async (context, handle) => { - const actor = await dispatcher(context, handle); + dispatcher: async (context, identifier) => { + const actor = await dispatcher(context, identifier); if (actor == null) return null; const logger = getLogger(["fedify", "federation", "actor"]); if (actor.id == null) { logger.warn( "Actor dispatcher returned an actor without an id property. " + - "Set the property with Context.getActorUri(handle).", + "Set the property with Context.getActorUri(identifier).", ); - } else if (actor.id.href != context.getActorUri(handle).href) { + } else if (actor.id.href != context.getActorUri(identifier).href) { logger.warn( "Actor dispatcher returned an actor with an id property that " + "does not match the actor URI. Set the property with " + - "Context.getActorUri(handle).", + "Context.getActorUri(identifier).", ); } if ( @@ -711,16 +721,16 @@ export class FederationImpl implements Federation { logger.warn( "You configured a following collection dispatcher, but the " + "actor does not have a following property. Set the property " + - "with Context.getFollowingUri(handle).", + "with Context.getFollowingUri(identifier).", ); } else if ( - actor.followingId.href != context.getFollowingUri(handle).href + actor.followingId.href != context.getFollowingUri(identifier).href ) { logger.warn( "You configured a following collection dispatcher, but the " + "actor's following property does not match the following " + "collection URI. Set the property with " + - "Context.getFollowingUri(handle).", + "Context.getFollowingUri(identifier).", ); } } @@ -732,16 +742,16 @@ export class FederationImpl implements Federation { logger.warn( "You configured a followers collection dispatcher, but the " + "actor does not have a followers property. Set the property " + - "with Context.getFollowersUri(handle).", + "with Context.getFollowersUri(identifier).", ); } else if ( - actor.followersId.href != context.getFollowersUri(handle).href + actor.followersId.href != context.getFollowersUri(identifier).href ) { logger.warn( "You configured a followers collection dispatcher, but the " + "actor's followers property does not match the followers " + "collection URI. Set the property with " + - "Context.getFollowersUri(handle).", + "Context.getFollowersUri(identifier).", ); } } @@ -753,13 +763,15 @@ export class FederationImpl implements Federation { logger.warn( "You configured an outbox collection dispatcher, but the " + "actor does not have an outbox property. Set the property " + - "with Context.getOutboxUri(handle).", + "with Context.getOutboxUri(identifier).", ); - } else if (actor.outboxId.href != context.getOutboxUri(handle).href) { + } else if ( + actor.outboxId.href != context.getOutboxUri(identifier).href + ) { logger.warn( "You configured an outbox collection dispatcher, but the " + "actor's outbox property does not match the outbox collection " + - "URI. Set the property with Context.getOutboxUri(handle).", + "URI. Set the property with Context.getOutboxUri(identifier).", ); } } @@ -771,13 +783,15 @@ export class FederationImpl implements Federation { logger.warn( "You configured a liked collection dispatcher, but the " + "actor does not have a liked property. Set the property " + - "with Context.getLikedUri(handle).", + "with Context.getLikedUri(identifier).", ); - } else if (actor.likedId.href != context.getLikedUri(handle).href) { + } else if ( + actor.likedId.href != context.getLikedUri(identifier).href + ) { logger.warn( "You configured a liked collection dispatcher, but the " + "actor's liked property does not match the liked collection " + - "URI. Set the property with Context.getLikedUri(handle).", + "URI. Set the property with Context.getLikedUri(identifier).", ); } } @@ -789,15 +803,15 @@ export class FederationImpl implements Federation { logger.warn( "You configured a featured collection dispatcher, but the " + "actor does not have a featured property. Set the property " + - "with Context.getFeaturedUri(handle).", + "with Context.getFeaturedUri(identifier).", ); } else if ( - actor.featuredId.href != context.getFeaturedUri(handle).href + actor.featuredId.href != context.getFeaturedUri(identifier).href ) { logger.warn( "You configured a featured collection dispatcher, but the " + "actor's featured property does not match the featured collection " + - "URI. Set the property with Context.getFeaturedUri(handle).", + "URI. Set the property with Context.getFeaturedUri(identifier).", ); } } @@ -809,16 +823,17 @@ export class FederationImpl implements Federation { logger.warn( "You configured a featured tags collection dispatcher, but the " + "actor does not have a featuredTags property. Set the property " + - "with Context.getFeaturedTagsUri(handle).", + "with Context.getFeaturedTagsUri(identifier).", ); } else if ( - actor.featuredTagsId.href != context.getFeaturedTagsUri(handle).href + actor.featuredTagsId.href != + context.getFeaturedTagsUri(identifier).href ) { logger.warn( "You configured a featured tags collection dispatcher, but the " + "actor's featuredTags property does not match the featured tags " + "collection URI. Set the property with " + - "Context.getFeaturedTagsUri(handle).", + "Context.getFeaturedTagsUri(identifier).", ); } } @@ -827,13 +842,15 @@ export class FederationImpl implements Federation { logger.warn( "You configured inbox listeners, but the actor does not " + "have an inbox property. Set the property with " + - "Context.getInboxUri(handle).", + "Context.getInboxUri(identifier).", ); - } else if (actor.inboxId.href != context.getInboxUri(handle).href) { + } else if ( + actor.inboxId.href != context.getInboxUri(identifier).href + ) { logger.warn( "You configured inbox listeners, but the actor's inbox " + "property does not match the inbox URI. Set the property " + - "with Context.getInboxUri(handle).", + "with Context.getInboxUri(identifier).", ); } if (actor.endpoints == null || actor.endpoints.sharedInbox == null) { @@ -857,14 +874,14 @@ export class FederationImpl implements Federation { logger.warn( "You configured a key pairs dispatcher, but the actor does " + "not have a publicKey property. Set the property with " + - "Context.getActorKeyPairs(handle).", + "Context.getActorKeyPairs(identifier).", ); } if (actor.assertionMethodId == null) { logger.warn( "You configured a key pairs dispatcher, but the actor does " + "not have an assertionMethod property. Set the property " + - "with Context.getActorKeyPairs(handle).", + "with Context.getActorKeyPairs(identifier).", ); } } @@ -961,7 +978,7 @@ export class FederationImpl implements Federation { } setInboxDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext, @@ -984,9 +1001,18 @@ export class FederationImpl implements Federation { } } else { const variables = this.router.add(path, "inbox"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for inbox dispatcher must have one variable: {handle}", + "Path for inbox dispatcher must have one variable: {identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "inbox"]).warn( + "The {handle} variable in the inbox dispatcher path is deprecated. " + + "Use {identifier} instead.", ); } this.inboxPath = path; @@ -1036,7 +1062,7 @@ export class FederationImpl implements Federation { } setOutboxDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext, @@ -1052,9 +1078,18 @@ export class FederationImpl implements Federation { throw new RouterError("Outbox dispatcher already set."); } const variables = this.router.add(path, "outbox"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for outbox dispatcher must have one variable: {handle}", + "Path for outbox dispatcher must have one variable: {identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "outbox"]).warn( + "The {handle} variable in the outbox dispatcher path is deprecated. " + + "Use {identifier} instead.", ); } const callbacks: CollectionCallbacks< @@ -1102,7 +1137,7 @@ export class FederationImpl implements Federation { } setFollowingDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Actor | URL, RequestContext, @@ -1118,9 +1153,19 @@ export class FederationImpl implements Federation { throw new RouterError("Following collection dispatcher already set."); } const variables = this.router.add(path, "following"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for following collection dispatcher must have one variable: {handle}", + "Path for following collection dispatcher must have one variable: " + + "{identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "collection"]).warn( + "The {handle} variable in the following collection dispatcher path " + + "is deprecated. Use {identifier} instead.", ); } const callbacks: CollectionCallbacks< @@ -1168,7 +1213,7 @@ export class FederationImpl implements Federation { } setFollowersDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Recipient, Context, @@ -1180,9 +1225,19 @@ export class FederationImpl implements Federation { throw new RouterError("Followers collection dispatcher already set."); } const variables = this.router.add(path, "followers"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for followers collection dispatcher must have one variable: {handle}", + "Path for followers collection dispatcher must have one variable: " + + "{identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "collection"]).warn( + "The {handle} variable in the followers collection dispatcher path " + + "is deprecated. Use {identifier} instead.", ); } const callbacks: CollectionCallbacks< @@ -1222,7 +1277,7 @@ export class FederationImpl implements Federation { } setLikedDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Like, RequestContext, @@ -1238,9 +1293,19 @@ export class FederationImpl implements Federation { throw new RouterError("Liked collection dispatcher already set."); } const variables = this.router.add(path, "liked"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for liked collection dispatcher must have one variable: {handle}", + "Path for liked collection dispatcher must have one variable: " + + "{identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "collection"]).warn( + "The {handle} variable in the liked collection dispatcher path " + + "is deprecated. Use {identifier} instead.", ); } const callbacks: CollectionCallbacks< @@ -1288,7 +1353,7 @@ export class FederationImpl implements Federation { } setFeaturedDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Object, RequestContext, @@ -1304,9 +1369,19 @@ export class FederationImpl implements Federation { throw new RouterError("Featured collection dispatcher already set."); } const variables = this.router.add(path, "featured"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for featured collection dispatcher must have one variable: {handle}", + "Path for featured collection dispatcher must have one variable: " + + "{identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "collection"]).warn( + "The {handle} variable in the featured collection dispatcher path " + + "is deprecated. Use {identifier} instead.", ); } const callbacks: CollectionCallbacks< @@ -1354,7 +1429,7 @@ export class FederationImpl implements Federation { } setFeaturedTagsDispatcher( - path: `${string}{handle}${string}`, + path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Hashtag, RequestContext, @@ -1370,10 +1445,19 @@ export class FederationImpl implements Federation { throw new RouterError("Featured tags collection dispatcher already set."); } const variables = this.router.add(path, "featuredTags"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( "Path for featured tags collection dispatcher must have one " + - "variable: {handle}", + "variable: {identifier}", + ); + } + if (variables.has("handle")) { + getLogger(["fedify", "federation", "collection"]).warn( + "The {handle} variable in the featured tags collection dispatcher " + + "path is deprecated. Use {identifier} instead.", ); } const callbacks: CollectionCallbacks< @@ -1421,7 +1505,7 @@ export class FederationImpl implements Federation { } setInboxListeners( - inboxPath: `${string}{handle}${string}`, + inboxPath: `${string}{identifier}${string}` | `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetters { if (this.inboxListeners != null) { @@ -1435,12 +1519,21 @@ export class FederationImpl implements Federation { } } else { const variables = this.router.add(inboxPath, "inbox"); - if (variables.size !== 1 || !variables.has("handle")) { + if ( + variables.size !== 1 || + !(variables.has("identifier") || variables.has("handle")) + ) { throw new RouterError( - "Path for inbox must have one variable: {handle}", + "Path for inbox must have one variable: {identifier}", ); } this.inboxPath = inboxPath; + if (variables.has("handle")) { + getLogger(["fedify", "federation", "inbox"]).warn( + "The {handle} variable in the inbox path is deprecated. " + + "Use {identifier} instead.", + ); + } } if (sharedInboxPath != null) { const siVars = this.router.add(sharedInboxPath, "sharedInbox"); @@ -1705,10 +1798,12 @@ export class FederationImpl implements Federation { }); case "actor": context = this.#createContext(request, contextData, { - invokedFromActorDispatcher: { handle: route.values.handle }, + invokedFromActorDispatcher: { + identifier: route.values.identifier ?? route.values.handle, + }, }); return await handleActor(request, { - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, context, actorDispatcher: this.actorCallbacks?.dispatcher, authorizePredicate: this.actorCallbacks?.authorizePredicate, @@ -1736,7 +1831,7 @@ export class FederationImpl implements Federation { case "outbox": return await handleCollection(request, { name: "outbox", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getOutboxUri.bind(context), context, collectionCallbacks: this.outboxCallbacks, @@ -1748,7 +1843,7 @@ export class FederationImpl implements Federation { if (request.method !== "POST") { return await handleCollection(request, { name: "inbox", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getInboxUri.bind(context), context, collectionCallbacks: this.inboxCallbacks, @@ -1759,7 +1854,7 @@ export class FederationImpl implements Federation { } context = this.#createContext(request, contextData, { documentLoader: await context.getDocumentLoader({ - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, }), }); // falls through @@ -1768,15 +1863,17 @@ export class FederationImpl implements Federation { const identity = await this.sharedInboxKeyDispatcher(context); if (identity != null) { context = this.#createContext(request, contextData, { - documentLoader: "handle" in identity - ? await context.getDocumentLoader(identity) - : context.getDocumentLoader(identity), + documentLoader: + "identifier" in identity || "username" in identity || + "handle" in identity + ? await context.getDocumentLoader(identity) + : context.getDocumentLoader(identity), }); } } if (!this.manuallyStartQueue) this.#startQueue(contextData); return await handleInbox(request, { - handle: route.values.handle ?? null, + identifier: route.values.identifier ?? route.values.handle ?? null, context, inboxContextFactory: context.toInboxContext.bind(context), kv: this.kv, @@ -1792,7 +1889,7 @@ export class FederationImpl implements Federation { case "following": return await handleCollection(request, { name: "following", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFollowingUri.bind(context), context, collectionCallbacks: this.followingCallbacks, @@ -1808,7 +1905,7 @@ export class FederationImpl implements Federation { } return await handleCollection(request, { name: "followers", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFollowersUri.bind(context), context, filter: baseUrl != null ? new URL(baseUrl) : undefined, @@ -1827,7 +1924,7 @@ export class FederationImpl implements Federation { case "liked": return await handleCollection(request, { name: "liked", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getLikedUri.bind(context), context, collectionCallbacks: this.likedCallbacks, @@ -1838,7 +1935,7 @@ export class FederationImpl implements Federation { case "featured": return await handleCollection(request, { name: "featured", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFeaturedUri.bind(context), context, collectionCallbacks: this.featuredCallbacks, @@ -1849,7 +1946,7 @@ export class FederationImpl implements Federation { case "featuredTags": return await handleCollection(request, { name: "featured tags", - handle: route.values.handle, + identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFeaturedTagsUri.bind(context), context, collectionCallbacks: this.featuredTagsCallbacks, @@ -1870,15 +1967,15 @@ interface ContextOptions { federation: FederationImpl; data: TContextData; documentLoader: DocumentLoader; - invokedFromActorKeyPairsDispatcher?: { handle: string }; + invokedFromActorKeyPairsDispatcher?: { identifier: string }; } -class ContextImpl implements Context { +export class ContextImpl implements Context { readonly url: URL; readonly federation: FederationImpl; readonly data: TContextData; readonly documentLoader: DocumentLoader; - readonly invokedFromActorKeyPairsDispatcher?: { handle: string }; + readonly invokedFromActorKeyPairsDispatcher?: { identifier: string }; constructor( { @@ -1932,8 +2029,11 @@ class ContextImpl implements Context { return new URL(path, this.url); } - getActorUri(handle: string): URL { - const path = this.federation.router.build("actor", { handle }); + getActorUri(identifier: string): URL { + const path = this.federation.router.build( + "actor", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No actor dispatcher registered."); } @@ -1964,8 +2064,11 @@ class ContextImpl implements Context { return new URL(path, this.url); } - getOutboxUri(handle: string): URL { - const path = this.federation.router.build("outbox", { handle }); + getOutboxUri(identifier: string): URL { + const path = this.federation.router.build( + "outbox", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No outbox dispatcher registered."); } @@ -1973,56 +2076,74 @@ class ContextImpl implements Context { } getInboxUri(): URL; - getInboxUri(handle: string): URL; - getInboxUri(handle?: string): URL { - if (handle == null) { + getInboxUri(identifier: string): URL; + getInboxUri(identifier?: string): URL { + if (identifier == null) { const path = this.federation.router.build("sharedInbox", {}); if (path == null) { throw new RouterError("No shared inbox path registered."); } return new URL(path, this.url); } - const path = this.federation.router.build("inbox", { handle }); + const path = this.federation.router.build( + "inbox", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No inbox path registered."); } return new URL(path, this.url); } - getFollowingUri(handle: string): URL { - const path = this.federation.router.build("following", { handle }); + getFollowingUri(identifier: string): URL { + const path = this.federation.router.build( + "following", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No following collection path registered."); } return new URL(path, this.url); } - getFollowersUri(handle: string): URL { - const path = this.federation.router.build("followers", { handle }); + getFollowersUri(identifier: string): URL { + const path = this.federation.router.build( + "followers", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No followers collection path registered."); } return new URL(path, this.url); } - getLikedUri(handle: string): URL { - const path = this.federation.router.build("liked", { handle }); + getLikedUri(identifier: string): URL { + const path = this.federation.router.build( + "liked", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No liked collection path registered."); } return new URL(path, this.url); } - getFeaturedUri(handle: string): URL { - const path = this.federation.router.build("featured", { handle }); + getFeaturedUri(identifier: string): URL { + const path = this.federation.router.build( + "featured", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No featured collection path registered."); } return new URL(path, this.url); } - getFeaturedTagsUri(handle: string): URL { - const path = this.federation.router.build("featuredTags", { handle }); + getFeaturedTagsUri(identifier: string): URL { + const path = this.federation.router.build( + "featuredTags", + { identifier, handle: identifier }, + ); if (path == null) { throw new RouterError("No featured tags collection path registered."); } @@ -2033,9 +2154,36 @@ class ContextImpl implements Context { if (uri == null) return null; if (uri.origin !== this.url.origin) return null; const route = this.federation.router.route(uri.pathname); + const logger = getLogger(["fedify", "federation"]); if (route == null) return null; - else if (route.name === "actor") { - return { type: "actor", handle: route.values.handle }; + else if (route.name === "sharedInbox") { + return { + type: "inbox", + identifier: undefined, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return undefined; + }, + }; + } + const identifier = "identifier" in route.values + ? route.values.identifier + : route.values.handle; + if (route.name === "actor") { + return { + type: "actor", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name.startsWith("object:")) { const typeId = route.name.replace(/^object:/, ""); return { @@ -2045,47 +2193,116 @@ class ContextImpl implements Context { values: route.values, }; } else if (route.name === "inbox") { - return { type: "inbox", handle: route.values.handle }; - } else if (route.name === "sharedInbox") { - return { type: "inbox" }; + return { + type: "inbox", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name === "outbox") { - return { type: "outbox", handle: route.values.handle }; + return { + type: "outbox", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name === "following") { - return { type: "following", handle: route.values.handle }; + return { + type: "following", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name === "followers") { - return { type: "followers", handle: route.values.handle }; + return { + type: "followers", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name === "liked") { - return { type: "liked", handle: route.values.handle }; + return { + type: "liked", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name === "featured") { - return { type: "featured", handle: route.values.handle }; + return { + type: "featured", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } else if (route.name === "featuredTags") { - return { type: "featuredTags", handle: route.values.handle }; + return { + type: "featuredTags", + identifier, + get handle() { + logger.warn( + "The ParseUriResult.handle property is deprecated; " + + "use ParseUriResult.identifier instead.", + ); + return identifier; + }, + }; } return null; } - async getActorKeyPairs(handle: string): Promise { + async getActorKeyPairs(identifier: string): Promise { const logger = getLogger(["fedify", "federation", "actor"]); if (this.invokedFromActorKeyPairsDispatcher != null) { logger.warn( - "Context.getActorKeyPairs({getActorKeyPairsHandle}) method is " + + "Context.getActorKeyPairs({getActorKeyPairsIdentifier}) method is " + "invoked from the actor key pairs dispatcher " + - "({actorKeyPairsDispatcherHandle}); this may cause an infinite loop.", + "({actorKeyPairsDispatcherIdentifier}); this may cause " + + "an infinite loop.", { - getActorKeyPairsHandle: handle, - actorKeyPairsDispatcherHandle: - this.invokedFromActorKeyPairsDispatcher.handle, + getActorKeyPairsIdentifier: identifier, + actorKeyPairsDispatcherIdentifier: + this.invokedFromActorKeyPairsDispatcher.identifier, }, ); } let keyPairs: (CryptoKeyPair & { keyId: URL })[]; try { - keyPairs = await this.getKeyPairsFromHandle(handle); + keyPairs = await this.getKeyPairsFromIdentifier(identifier); } catch (_) { logger.warn("No actor key pairs dispatcher registered."); return []; } - const owner = this.getActorUri(handle); + const owner = this.getActorUri(identifier); const result = []; for (const keyPair of keyPairs) { const newPair: ActorKeyPair = { @@ -2106,14 +2323,17 @@ class ContextImpl implements Context { return result; } - protected async getKeyPairsFromHandle( - handle: string, + protected async getKeyPairsFromIdentifier( + identifier: string, ): Promise<(CryptoKeyPair & { keyId: URL })[]> { const logger = getLogger(["fedify", "federation", "actor"]); if (this.federation.actorCallbacks?.keyPairsDispatcher == null) { throw new Error("No actor key pairs dispatcher registered."); } - const path = this.federation.router.build("actor", { handle }); + const path = this.federation.router.build( + "actor", + { identifier, handle: identifier }, + ); if (path == null) { logger.warn("No actor dispatcher registered."); return []; @@ -2122,12 +2342,12 @@ class ContextImpl implements Context { const keyPairs = await this.federation.actorCallbacks?.keyPairsDispatcher( new ContextImpl({ ...this, - invokedFromActorKeyPairsDispatcher: { handle }, + invokedFromActorKeyPairsDispatcher: { identifier }, }), - handle, + identifier, ); if (keyPairs.length < 1) { - logger.warn("No key pairs found for actor {handle}.", { handle }); + logger.warn("No key pairs found for actor {identifier}.", { identifier }); } let i = 0; const result = []; @@ -2145,10 +2365,10 @@ class ContextImpl implements Context { return result; } - protected async getRsaKeyPairFromHandle( - handle: string, + protected async getRsaKeyPairFromIdentifier( + identifier: string, ): Promise { - const keyPairs = await this.getKeyPairsFromHandle(handle); + const keyPairs = await this.getKeyPairsFromIdentifier(identifier); for (const keyPair of keyPairs) { const { privateKey } = keyPair; if ( @@ -2161,24 +2381,63 @@ class ContextImpl implements Context { } } getLogger(["fedify", "federation", "actor"]).warn( - "No RSA-PKCS#1-v1.5 SHA-256 key found for actor {handle}.", - { handle }, + "No RSA-PKCS#1-v1.5 SHA-256 key found for actor {identifier}.", + { identifier }, ); return null; } - getDocumentLoader(identity: { handle: string }): Promise; + getDocumentLoader( + identity: + | { identifier: string } + | { username: string } + | { handle: string }, + ): Promise; getDocumentLoader(identity: SenderKeyPair): DocumentLoader; getDocumentLoader( - identity: SenderKeyPair | { handle: string }, + identity: + | SenderKeyPair + | { identifier: string } + | { username: string } + | { handle: string }, ): DocumentLoader | Promise { - if ("handle" in identity) { - const keyPair = this.getRsaKeyPairFromHandle(identity.handle); - return keyPair.then((pair) => - pair == null - ? this.documentLoader - : this.federation.authenticatedDocumentLoaderFactory(pair) - ); + if ( + "identifier" in identity || "username" in identity || "handle" in identity + ) { + let identifierPromise: Promise; + if ("username" in identity || "handle" in identity) { + let username: string; + if ("username" in identity) { + username = identity.username; + } else { + username = identity.handle; + getLogger(["fedify", "runtime", "docloader"]).warn( + 'The "handle" property is deprecated; use "identifier" or ' + + '"username" instead.', + { identity }, + ); + } + const mapper = this.federation.actorCallbacks?.handleMapper; + if (mapper == null) { + identifierPromise = Promise.resolve(username); + } else { + const identifier = mapper(this, username); + identifierPromise = identifier instanceof Promise + ? identifier + : Promise.resolve(identifier); + } + } else { + identifierPromise = Promise.resolve(identity.identifier); + } + return identifierPromise.then((identifier) => { + if (identifier == null) return this.documentLoader; + const keyPair = this.getRsaKeyPairFromIdentifier(identifier); + return keyPair.then((pair) => + pair == null + ? this.documentLoader + : this.federation.authenticatedDocumentLoaderFactory(pair) + ); + }); } return this.federation.authenticatedDocumentLoaderFactory(identity); } @@ -2194,17 +2453,54 @@ class ContextImpl implements Context { } async sendActivity( - sender: SenderKeyPair | SenderKeyPair[] | { handle: string }, + sender: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string } + | { handle: string }, recipients: Recipient | Recipient[] | "followers", activity: Activity, options: SendActivityOptions = {}, ): Promise { let keys: SenderKeyPair[]; - if ("handle" in sender) { - keys = await this.getKeyPairsFromHandle(sender.handle); + let identifier: string | null = null; + if ("identifier" in sender || "username" in sender || "handle" in sender) { + if ("identifier" in sender) { + identifier = sender.identifier; + } else { + let username: string; + if ("username" in sender) { + username = sender.username; + } else { + username = sender.handle; + getLogger(["fedify", "federation", "outbox"]).warn( + 'The "handle" property for the sender parameter is deprecated; ' + + 'use "identifier" or "username" instead.', + { sender }, + ); + } + 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; + } + } + keys = await this.getKeyPairsFromIdentifier(identifier); if (keys.length < 1) { throw new Error( - `No key pair found for actor ${JSON.stringify(sender.handle)}.`, + `No key pair found for actor ${JSON.stringify(identifier)}.`, ); } } else if (Array.isArray(sender)) { @@ -2223,18 +2519,22 @@ class ContextImpl implements Context { if (Array.isArray(recipients)) { expandedRecipients = recipients; } else if (recipients === "followers") { - if (!("handle" in sender)) { + if (identifier == null) { throw new Error( - "If recipients is 'followers', sender must be an actor handle.", + 'If recipients is "followers", ' + + "sender must be an actor identifier or username.", ); } expandedRecipients = []; for await ( - const recipient of this.getFollowers(sender.handle) + const recipient of this.getFollowers(identifier) ) { expandedRecipients.push(recipient); } - const collectionId = this.federation.router.build("followers", sender); + const collectionId = this.federation.router.build( + "followers", + { identifier, handle: identifier }, + ); opts.collectionSync = collectionId == null ? undefined : new URL(collectionId, this.url).href; @@ -2249,13 +2549,13 @@ class ContextImpl implements Context { ); } - async *getFollowers(handle: string): AsyncIterable { + async *getFollowers(identifier: string): AsyncIterable { if (this.federation.followersCallbacks == null) { throw new Error("No followers collection dispatcher registered."); } const result = await this.federation.followersCallbacks.dispatcher( this, - handle, + identifier, null, ); if (result != null) { @@ -2269,12 +2569,12 @@ class ContextImpl implements Context { } let cursor = await this.federation.followersCallbacks.firstCursor( this, - handle, + identifier, ); while (cursor != null) { const result = await this.federation.followersCallbacks.dispatcher( this, - handle, + identifier, cursor, ); if (result == null) break; @@ -2287,7 +2587,7 @@ class ContextImpl implements Context { interface RequestContextOptions extends ContextOptions { request: Request; - invokedFromActorDispatcher?: { handle: string }; + invokedFromActorDispatcher?: { identifier: string }; invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; @@ -2297,7 +2597,7 @@ interface RequestContextOptions class RequestContextImpl extends ContextImpl implements RequestContext { - readonly #invokedFromActorDispatcher?: { handle: string }; + readonly #invokedFromActorDispatcher?: { identifier: string }; readonly #invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; @@ -2314,7 +2614,7 @@ class RequestContextImpl extends ContextImpl this.url = options.url; } - async getActor(handle: string): Promise { + async getActor(identifier: string): Promise { if ( this.federation.actorCallbacks == null || this.federation.actorCallbacks.dispatcher == null @@ -2323,21 +2623,22 @@ class RequestContextImpl extends ContextImpl } if (this.#invokedFromActorDispatcher != null) { getLogger(["fedify", "federation", "actor"]).warn( - "RequestContext.getActor({getActorHandle}) is invoked from " + - "the actor dispatcher ({actorDispatcherHandle}); " + + "RequestContext.getActor({getActorIdentifier}) is invoked from " + + "the actor dispatcher ({actorDispatcherIdentifier}); " + "this may cause an infinite loop.", { - getActorHandle: handle, - actorDispatcherHandle: this.#invokedFromActorDispatcher.handle, + getActorIdentifier: identifier, + actorDispatcherIdentifier: + this.#invokedFromActorDispatcher.identifier, }, ); } return await this.federation.actorCallbacks.dispatcher( new RequestContextImpl({ ...this, - invokedFromActorDispatcher: { handle }, + invokedFromActorDispatcher: { identifier }, }), - handle, + identifier, ); } @@ -2409,27 +2710,75 @@ export class InboxContextImpl extends ContextImpl } forwardActivity( - forwarder: SenderKeyPair | SenderKeyPair[] | { handle: string }, + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string } + | { handle: string }, recipients: Recipient | Recipient[], options?: ForwardActivityOptions, ): Promise; forwardActivity( - forwarder: { handle: string }, + forwarder: + | { identifier: string } + | { username: string } + | { handle: string }, recipients: "followers", options?: ForwardActivityOptions, ): Promise; async forwardActivity( - forwarder: SenderKeyPair | SenderKeyPair[] | { handle: string }, + forwarder: + | SenderKeyPair + | SenderKeyPair[] + | { identifier: string } + | { username: string } + | { handle: string }, recipients: Recipient | Recipient[] | "followers", options?: ForwardActivityOptions, ): Promise { const logger = getLogger(["fedify", "federation", "inbox"]); let keys: SenderKeyPair[]; - if ("handle" in forwarder) { - keys = await this.getKeyPairsFromHandle(forwarder.handle); + let identifier: string | null = null; + if ( + "identifier" in forwarder || "username" in forwarder || + "handle" in forwarder + ) { + if ("identifier" in forwarder) { + identifier = forwarder.identifier; + } else { + let username: string; + if ("username" in forwarder) { + username = forwarder.username; + } else { + username = forwarder.handle; + logger.warn( + 'The "handle" property for the forwarder parameter is deprecated; ' + + 'use "identifier" or "username" instead.', + { forwarder }, + ); + } + 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; + } + } + keys = await this.getKeyPairsFromIdentifier(identifier); if (keys.length < 1) { throw new Error( - `No key pair found for actor ${JSON.stringify(forwarder.handle)}.`, + `No key pair found for actor ${JSON.stringify(identifier)}.`, ); } } else if (Array.isArray(forwarder)) { @@ -2471,15 +2820,14 @@ export class InboxContextImpl extends ContextImpl : undefined; } if (recipients === "followers") { - if (!("handle" in forwarder)) { + if (identifier == null) { throw new Error( - "If recipients is 'followers', forwarder must be an actor handle.", + 'If recipients is "followers", ' + + "forwarder must be an actor identifier or username.", ); } const followers: Recipient[] = []; - for await ( - const recipient of this.getFollowers(forwarder.handle) - ) { + for await (const recipient of this.getFollowers(identifier)) { followers.push(recipient); } recipients = followers; diff --git a/src/federation/queue.ts b/src/federation/queue.ts index 24fb3d3a..05152ef8 100644 --- a/src/federation/queue.ts +++ b/src/federation/queue.ts @@ -22,5 +22,5 @@ export interface InboxMessage { activity: unknown; started: string; attempt: number; - handle: string | null; + identifier: string | null; } diff --git a/src/webfinger/handler.test.ts b/src/webfinger/handler.test.ts index 90251683..a452c318 100644 --- a/src/webfinger/handler.test.ts +++ b/src/webfinger/handler.test.ts @@ -14,8 +14,8 @@ test("handleWebFinger()", async () => { const context = createRequestContext({ url, data: undefined, - getActorUri(handle) { - return new URL(`https://example.com/users/${handle}`); + getActorUri(identifier) { + return new URL(`https://example.com/users/${identifier}`); }, async getActor(handle): Promise { return await actorDispatcher( @@ -27,7 +27,14 @@ test("handleWebFinger()", async () => { if (uri == null) return null; if (uri.protocol === "acct:") return null; const paths = uri.pathname.split("/"); - return { type: "actor", handle: paths[paths.length - 1] }; + const identifier = paths[paths.length - 1]; + return { + type: "actor", + identifier, + get handle(): string { + throw new Error("ParseUriResult.handle is deprecated!"); + }, + }; }, }); const actorDispatcher: ActorDispatcher = (ctx, handle) => { diff --git a/src/webfinger/handler.ts b/src/webfinger/handler.ts index 64c5ca57..b0403d35 100644 --- a/src/webfinger/handler.ts +++ b/src/webfinger/handler.ts @@ -38,7 +38,7 @@ export interface WebFingerHandlerParameters { /** * Handles a WebFinger request. You would not typically call this function - * directly, but instead use {@link Federation.handle} method. + * directly, but instead use {@link Federation.fetch} method. * @param request The WebFinger request to handle. * @param parameters The parameters for handling the request. * @returns The response to the request. @@ -70,7 +70,7 @@ export async function handleWebFinger( logger.error("Actor dispatcher is not set."); return await onNotFound(request); } - let handle: string | null; + let identifier: string | null; const uriParsed = context.parseUri(resourceUrl); if (uriParsed?.type != "actor") { const match = /^acct:([^@]+)@([^@]+)$/.exec(resource); @@ -81,30 +81,30 @@ export async function handleWebFinger( if (actorHandleMapper == null) { logger.error( "No actor handle mapper is set; use the WebFinger username {username}" + - " as the actor's internal handle.", + " as the actor's internal identifier.", { username }, ); - handle = username; + identifier = username; } else { - handle = await actorHandleMapper(context, username); - if (handle == null) { + identifier = await actorHandleMapper(context, username); + if (identifier == null) { logger.error("Actor {username} not found.", { username }); return await onNotFound(request); } } resourceUrl = new URL(`acct:${username}@${context.url.host}`); } else { - handle = uriParsed.handle; + identifier = uriParsed.identifier; } - const actor = await actorDispatcher(context, handle); + const actor = await actorDispatcher(context, identifier); if (actor == null) { - logger.error("Actor {handle} not found.", { handle }); + logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } const links: Link[] = [ { rel: "self", - href: context.getActorUri(handle).href, + href: context.getActorUri(identifier).href, type: "application/activity+json", }, ]; @@ -133,11 +133,11 @@ export async function handleWebFinger( } const jrd: ResourceDescriptor = { subject: resourceUrl.href, - aliases: resourceUrl.href === context.getActorUri(handle).href + aliases: resourceUrl.href === context.getActorUri(identifier).href ? (actor.preferredUsername == null ? [] : [`acct:${actor.preferredUsername}@${context.url.host}`]) - : [context.getActorUri(handle).href], + : [context.getActorUri(identifier).href], links, }; return new Response(JSON.stringify(jrd), {