diff --git a/CHANGES.md b/CHANGES.md index f4ff61c7a..d8b60339d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -280,6 +280,12 @@ To be released. `temporal-polyfill`, while type declarations rely on the standard `esnext.temporal` lib reference. [[#823], [#925]] + - Fixed the relay documentation to use the canonical actor and shared inbox + URIs, distinguish Mastodon-style and LitePub-style subscription behavior, + and explain which deployment responsibilities remain with applications. + [[#899]] + +[#899]: https://github.com/fedify-dev/fedify/issues/899 ### @fedify/sqlite diff --git a/changes.d/relay/clarify-relay-contract.md b/changes.d/relay/clarify-relay-contract.md new file mode 100644 index 000000000..a2de67db6 --- /dev/null +++ b/changes.d/relay/clarify-relay-contract.md @@ -0,0 +1,4 @@ + - Fixed the relay documentation to use the canonical actor and shared inbox + URIs, distinguish Mastodon-style and LitePub-style subscription behavior, + and explain which deployment responsibilities remain with applications. + [[#899]] diff --git a/docs/manual/relay.md b/docs/manual/relay.md index cf78e750b..a029c746c 100644 --- a/docs/manual/relay.md +++ b/docs/manual/relay.md @@ -140,28 +140,29 @@ Configuration options `queue` : A [`MessageQueue`](./mq.md) for background activity processing. Recommended - for production: - - ~~~~ typescript twoslash - import { createRelay } from "@fedify/relay"; - import { MemoryKvStore, InProcessMessageQueue } from "@fedify/fedify"; - // ---cut-before--- - const relay = createRelay("mastodon", { - kv: new MemoryKvStore(), - origin: "https://relay.example.com", - queue: new InProcessMessageQueue(), - subscriptionHandler: async (ctx, actor) => true, - }); - ~~~~ + for production. - > [!NOTE] - > For production, use [`RedisMessageQueue`], [`PostgresMessageQueue`], - > or [`MysqlMessageQueue`]. +~~~~ typescript twoslash +import { createRelay } from "@fedify/relay"; +import { MemoryKvStore, InProcessMessageQueue } from "@fedify/fedify"; +// ---cut-before--- +const relay = createRelay("mastodon", { + kv: new MemoryKvStore(), + origin: "https://relay.example.com", + queue: new InProcessMessageQueue(), + subscriptionHandler: async (ctx, actor) => true, +}); +~~~~ + +> [!NOTE] +> For production, use [`RedisMessageQueue`], [`PostgresMessageQueue`], +> or [`MysqlMessageQueue`]. `subscriptionHandler` (required) : Callback to approve or reject subscription requests. See [*Handling subscriptions*](#handling-subscriptions). To create an open relay - that accepts all subscriptions: + that accepts all subscriptions, set `subscriptionHandler` to always return + `true`. ~~~~ typescript subscriptionHandler: async (ctx, actor) => true @@ -185,19 +186,17 @@ Configuration options Relay types ----------- -The first parameter to `createRelay()` specifies the relay protocol. -For detailed protocol specifications, see [FEP-ae0c]. - -| Feature | `"mastodon"` | `"litepub"` | -| ---------------------- | ---------------------------- | -------------------------- | -| Activity forwarding | Direct | Wrapped in `Announce` | -| Following relationship | One-way | Bidirectional | -| Subscription state | Immediate `"accepted"` | `"pending"` → `"accepted"` | -| Compatibility | Broad (most implementations) | LitePub-aware servers | +The first parameter to `createRelay()` selects how this relay server handles +subscriptions and forwards activities. The package implements the server side +of the Mastodon-style and LitePub-style protocols described by [FEP-ae0c]; it +does not configure an existing ActivityPub application as a relay client. -> [!TIP] -> Use `"mastodon"` for broader compatibility. Switch to `"litepub"` only if -> you need its specific features. +| Feature | `"mastodon"` | `"litepub"` | +| ------------------------- | ---------------------- | -------------------------- | +| Activity forwarding | Direct | Wrapped in `Announce` | +| Following relationship | One-way | Bidirectional | +| Subscription state | Immediate `"accepted"` | `"pending"` → `"accepted"` | +| Canonical `Follow` object | Public collection | Relay actor | [FEP-ae0c]: https://w3id.org/fep/ae0c @@ -244,55 +243,61 @@ in their server settings. The URL format differs depending on the relay type. ### Subscription URL -The subscription URL differs between Mastodon-style and LitePub-style relays: - -| Relay type | Subscription URL | Example | -| ------------ | --------------------------- | --------------------------------- | -| `"mastodon"` | Inbox URL: `{origin}/inbox` | `https://relay.example.com/inbox` | -| `"litepub"` | Actor URL: `{origin}/actor` | `https://relay.example.com/actor` | - -For more details on the protocol differences, see [FEP-ae0c]. - -### Subscribing from Mastodon - -To subscribe from a Mastodon instance: - -1. Go to **Preferences** → **Administration** → **Relays** -2. Click **Add new relay** -3. Enter the relay inbox URL (e.g., `https://relay.example.com/inbox`) -4. Click **Save and enable** +Retrieve subscription URLs from the relay instance rather than constructing +them from assumed paths. -The relay will receive a `Follow` activity from the instance. If the -`subscriptionHandler` approves the request, the relay sends back an `Accept` -activity, and the instance becomes a subscriber. +~~~~ typescript twoslash +import { createRelay } from "@fedify/relay"; +import { MemoryKvStore } from "@fedify/fedify"; +const relay = createRelay("mastodon", { + kv: new MemoryKvStore(), + origin: "https://relay.example.com", + subscriptionHandler: async (ctx, actor) => true, +}); +// ---cut-before--- +const actorUri = await relay.getActorUri(); +const sharedInboxUri = await relay.getSharedInboxUri(); +~~~~ -> [!NOTE] -> Mastodon only supports Mastodon-style relays. Use the inbox URL -> (`https://{domain}/inbox`) when subscribing from Mastodon. +| Relay type | Give clients | Default URI | +| ------------ | ---------------- | --------------------------------------- | +| `"mastodon"` | `sharedInboxUri` | `https://relay.example.com/inbox` | +| `"litepub"` | `actorUri` | `https://relay.example.com/users/relay` | -### Subscribing from Pleroma/Akkoma +For more details on the protocol differences, see [FEP-ae0c]. -Pleroma and Akkoma use LitePub-style relays by default. To subscribe: -1. Use the admin CLI or MIX task to add the relay -2. Enter the relay actor URL (e.g., `https://relay.example.com/actor`) +Application responsibilities +---------------------------- -### Subscribing from other software +`createRelay()` provides the relay actor, inboxes, subscription handshake, +activity forwarding, cryptographic keys, and follower storage. The surrounding +application still needs to implement the following. -Consult your server software's documentation for specific instructions. -The general process is: + - Route requests for the configured `origin` to `relay.fetch()` without + rewriting the relay's paths. + - Terminate HTTPS and use a persistent `KvStore` in production. + - Configure a durable `MessageQueue` when delivery should survive process + restarts. + - Implement subscription policy and infrastructure-level rate limiting, + monitoring, and moderation. + - Provide WebFinger or NodeInfo separately when deployed clients require + those discovery endpoints. -1. Find the relay settings in your server's administration panel -2. Add the appropriate relay URL (inbox URL for Mastodon-style, actor URL - for LitePub-style) -3. Wait for the subscription to be approved +The `subscriptionHandler` decides who is stored as a delivery recipient. It is +not authorization for publishing to the relay. The relay verifies incoming +activities using Fedify's federation pipeline, but it does not require the +sender to be a stored follower or check that an activity addresses the Public +collection. Deployments should account for that behavior in their access and +moderation policies. Handling subscriptions ---------------------- The `subscriptionHandler` is required and determines whether to approve or -reject subscription requests. For an open relay that accepts all subscriptions: +reject subscription requests. The following example creates an open relay that +accepts all subscriptions. ~~~~ typescript twoslash import { createRelay } from "@fedify/relay"; @@ -305,7 +310,7 @@ const relay = createRelay("mastodon", { }); ~~~~ -To implement approval logic with blocklists: +Approval logic can also be implemented with a domain block list. ~~~~ typescript twoslash import { createRelay } from "@fedify/relay"; @@ -326,13 +331,11 @@ const relay = createRelay("mastodon", { }); ~~~~ -The handler receives: - - - `ctx`: The `Context` object - - `actor`: The `Actor` requesting subscription +The handler receives the `Context` object as `ctx` and the `Actor` +requesting the subscription as `actor`. -Return `true` to approve or `false` to reject. Rejected requests receive a -`Reject` activity. +Return `true` to approve the request or `false` to reject it. The relay +responds to rejected requests with a `Reject` activity. Managing followers @@ -343,7 +346,7 @@ interface. ### Listing all followers -Use `listFollowers()` to iterate over all followers: +Use `listFollowers()` to iterate over all followers. ~~~~ typescript twoslash import { createRelay } from "@fedify/relay"; @@ -363,7 +366,7 @@ for await (const follower of relay.listFollowers()) { ### Getting a specific follower -Use `getFollower()` to retrieve a specific follower by actor ID: +Use `getFollower()` to retrieve a specific follower by actor ID. ~~~~ typescript twoslash import { createRelay } from "@fedify/relay"; @@ -385,7 +388,7 @@ if (follower != null) { ### `RelayFollower` type -Each follower entry contains: +Each follower entry contains the following. - `actorId`: The actor's ID (URL) as a string - `actor`: The validated `Actor` object @@ -407,7 +410,7 @@ Stored with keys `["follower", actorId]`. Actor objects typically range from ### Cryptographic keys -Two key pairs are generated and stored: +The relay generates and stores two key pairs. | Key | Purpose | | --------------------------------- | ----------------------------------------------- | @@ -424,42 +427,18 @@ Security considerations ### Signature verification -The relay automatically verifies incoming activities using: - - - [HTTP Signatures] - - [Linked Data Signatures] - - [Object Integrity Proofs] - -Invalid signatures are silently ignored. Enable [logging](./log.md) for the -`["fedify", "sig"]` category to debug verification failures. - -[HTTP Signatures]: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 -[Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/ -[Object Integrity Proofs]: https://w3id.org/fep/8b32 - -### Subscription abuse - -Protect against abuse by: - -1. Implementing a `subscriptionHandler` to validate requests -2. Maintaining a blocklist -3. Rate limiting at the infrastructure level -4. Monitoring activity volumes - -### Content moderation - -> [!WARNING] -> Running a relay makes you responsible for forwarded content. Establish clear -> policies and vet subscribing instances. - -### Privacy +Incoming activities pass through Fedify's normal signature verification +pipeline. A valid signature authenticates the sender but does not make the +activity trusted. -The relay has access to all activities that pass through it. Do not store or -log activity content beyond operational needs. +The `subscriptionHandler` controls which actors receive forwarded activities. +It does not restrict which actors can submit activities to the relay, and +`createRelay()` does not check whether an activity addresses the Public +collection before forwarding it. -> [!CAUTION] -> Never forward non-public activities. The relay is designed only for public -> content distribution. +Deployments should apply appropriate access controls, rate limiting, and +moderation to the relay inbox. Avoid logging activity content unless it is +needed for operation or debugging. Monitoring @@ -467,7 +446,7 @@ Monitoring ### Logging -Enable relay-specific logging: +The following example enables Fedify logging, including relay operations. ~~~~ typescript twoslash import { configure, getConsoleSink } from "@logtape/logtape"; @@ -480,22 +459,23 @@ await configure({ }); ~~~~ -Key log categories: +You can enable logging relevant to relay operation as follows. | Category | Description | | ------------------------------------ | ---------------------- | +| `["fedify", "relay"]` | Relay-specific events | | `["fedify", "federation", "inbox"]` | Incoming activities | | `["fedify", "federation", "outbox"]` | Outgoing activities | | `["fedify", "sig"]` | Signature verification | ### OpenTelemetry -The relay supports [OpenTelemetry](./opentelemetry.md) tracing. Key spans: +Relay operations are included in [OpenTelemetry](./opentelemetry.md). -| Span | Description | -| ------------------------------------- | ----------------------- | -| `activitypub.inbox` | Receiving activities | -| `activitypub.send_activity` | Forwarding activities | -| `activitypub.dispatch_inbox_listener` | Processing inbox events | +| Span | Description | +| ------------------------------------- | ---------------------------- | +| `activitypub.inbox` | Receiving an activity | +| `activitypub.send_activity` | Sending a relayed activity | +| `activitypub.dispatch_inbox_listener` | Processing an inbox activity | diff --git a/packages/relay/README.md b/packages/relay/README.md index 74f437ab8..0169deaa5 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -40,33 +40,38 @@ subscribed instances, creating a shared pool of federated content. Relay protocols --------------- -This package supports two popular relay protocols used in the fediverse: +This package implements the relay-server side of the two relay protocols +described by [FEP-ae0c]. It does not subscribe an existing ActivityPub +application to remote relays. + +[FEP-ae0c]: https://w3id.org/fep/ae0c ### Mastodon-style relay -The Mastodon-style relay protocol uses LD signatures for activity -verification and follows the Public collection. This protocol is widely -supported by Mastodon and many other ActivityPub implementations. +Mastodon-style clients subscribe by sending a `Follow` whose object is the +ActivityStreams Public collection to the relay's shared inbox. The relay +accepts or rejects the subscription and forwards signed activities directly to +accepted subscribers. -Key features: +#### Key features - - Direct activity relaying with proper content types (`Create`, `Update`, - `Delete`, `Move`) - - LD signature verification and generation - - Follows the ActivityPub Public collection - - Simple subscription mechanism via `Follow` activities + - Direct forwarding of `Create`, `Update`, `Delete`, `Move`, and `Announce` + activities + - Immediate subscription state after approval + - Subscription through the shared inbox URI ### LitePub-style relay -The LitePub-style relay protocol uses bidirectional following relationships -and wraps activities in `Announce` activities for distribution. +LitePub-style clients subscribe by following the relay actor. After approving +the request, the relay follows the client actor back and waits for an `Accept`. +It wraps forwarded objects in `Announce` activities. -Key features: +#### Key features - Reciprocal following between relay and subscribers - - Activities wrapped in `Announce` for distribution + - Activities distributed through `Announce` - Two-phase subscription (pending → accepted) - - Enhanced federation capabilities + - Subscription through the relay actor URI Installation @@ -102,7 +107,7 @@ Usage ### Creating a relay -Here's a simple example of creating a relay server using the factory function: +Here's a simple example of creating a relay server using the factory function. ~~~~ typescript import { createRelay } from "@fedify/relay"; @@ -128,7 +133,7 @@ const relay = createRelay("mastodon", { Deno.serve((request) => relay.fetch(request)); ~~~~ -You can also create a LitePub-style relay by changing the type: +You can also create a LitePub-style relay by changing the type. ~~~~ typescript const relay = createRelay("litepub", { @@ -138,10 +143,25 @@ const relay = createRelay("litepub", { }); ~~~~ +The relay actor and shared inbox use fixed internal routes. Retrieve their +public URIs from the relay instead of constructing paths yourself. + +~~~~ typescript +const actorUri = await relay.getActorUri(); +// https://relay.example.com/users/relay + +const inboxUri = await relay.getSharedInboxUri(); +// https://relay.example.com/inbox +~~~~ + +Give Mastodon-style clients the shared inbox URI and LitePub-style clients the +actor URI. + ### Subscription handling The `subscriptionHandler` is required and determines whether to approve or -reject subscription requests. For an open relay that accepts all subscriptions: +reject subscription requests. The following example creates an open relay that +accepts all subscriptions. ~~~~ typescript const relay = createRelay("mastodon", { @@ -151,7 +171,7 @@ const relay = createRelay("mastodon", { }); ~~~~ -You can also implement custom approval logic: +You can also implement custom approval logic. ~~~~ typescript const relay = createRelay("mastodon", { @@ -199,7 +219,7 @@ if (follower) { The relay's `fetch()` method returns a standard `Response` object, making it compatible with any web framework that supports the Fetch API. Here's an -example with Hono: +example with Hono. ~~~~ typescript import { Hono } from "hono"; @@ -224,31 +244,44 @@ export default app; How it works ------------ -The relay operates by: +1. Actor registration—the relay presents itself as an `Application` actor at + `/users/relay`. +2. Subscription—Mastodon-style clients follow the Public collection; + LitePub-style clients follow the relay actor. +3. Approval—the relay's subscription handler determines whether to approve + the subscription and responds with `Accept` or `Reject`. +4. Forwarding—the relay handles `Create`, `Update`, `Delete`, `Move`, and + `Announce` activities delivered to its inbox. Mastodon-style relays forward + them directly; LitePub-style relays wrap their objects in `Announce`. +5. Unsubscription—instances can unsubscribe by sending an `Undo` activity + wrapping their original `Follow` activity. + + +Application responsibilities +---------------------------- + +`createRelay()` provides the relay-specific ActivityPub routes and behavior. +The surrounding application remains responsible for HTTPS, persistent storage, +a durable production queue, subscription policy, rate limiting, monitoring, +and moderation. WebFinger and NodeInfo discovery endpoints are not configured +by this package. -1. **Actor registration**: The relay presents itself as a Service actor at - `/users/relay` -2. **Subscription**: Instances subscribe to the relay by sending a `Follow` - activity -3. **Approval**: The relay's subscription handler determines whether to - approve the subscription (responds with `Accept` or `Reject`) -4. **Forwarding**: When a subscribed instance sends activities (`Create`, - `Update`, `Delete`, `Move`) to the relay's inbox, the relay forwards them - to all other subscribed instances -5. **Unsubscription**: Instances can unsubscribe by sending an `Undo` activity - wrapping their original `Follow` activity +The `subscriptionHandler` controls which actors become delivery recipients; it +does not authorize publishing to the relay. The relay does not require an +activity sender to be a stored follower or inspect its audience for the Public +collection, so deployments need to account for that behavior in their access +and moderation policies. Storage requirements -------------------- -The relay requires a key–value store to persist: +The relay requires a key–value store to persist the following data. - - Subscriber list and their Follow activity IDs - - Subscriber actor information - - Relay's cryptographic key pairs (RSA and Ed25519) + - Subscriber actor information and subscription state + - The relay's cryptographic key pairs (RSA and Ed25519) -Any `KvStore` implementation from Fedify can be used, including: +Any `KvStore` implementation from Fedify can be used, including the following. - `MemoryKvStore` (for development/testing) - `DenoKvStore` (Deno KV) @@ -301,7 +334,7 @@ Public interface for ActivityPub relay implementations. #### Relay types -The relay type is specified when calling `createRelay()`: +The relay type is specified when calling `createRelay()`. - `"mastodon"`: Mastodon-compatible relay using direct activity forwarding, immediate subscription approval, and LD signatures @@ -310,7 +343,7 @@ The relay type is specified when calling `createRelay()`: ### `RelayOptions` -Configuration options for the relay: +Configuration options for the relay. - `kv: KvStore` (required): Key–value store for persisting relay data - `origin: string` (required): Relay's origin URL (e.g., @@ -326,7 +359,7 @@ Configuration options for the relay: ### `SubscriptionRequestHandler` -A function that determines whether to approve a subscription request: +A function that determines whether to approve a subscription request. ~~~~ typescript type SubscriptionRequestHandler = ( @@ -347,7 +380,7 @@ type SubscriptionRequestHandler = ( ### `RelayFollower` -A follower of the relay with validated Actor instance: +A follower of the relay with validated Actor instance. ~~~~ typescript interface RelayFollower { diff --git a/packages/relay/src/factory.test.ts b/packages/relay/src/factory.test.ts new file mode 100644 index 000000000..7d268c5b1 --- /dev/null +++ b/packages/relay/src/factory.test.ts @@ -0,0 +1,25 @@ +import { MemoryKvStore } from "@fedify/fedify"; +import { createRelay, type RelayType } from "@fedify/relay"; +import { strictEqual } from "node:assert"; +import test, { describe } from "node:test"; + +describe("createRelay", () => { + for (const type of ["mastodon", "litepub"] satisfies RelayType[]) { + test(`${type} exposes the canonical relay URIs`, async () => { + const relay = createRelay(type, { + kv: new MemoryKvStore(), + origin: "https://relay.example.com", + subscriptionHandler: () => Promise.resolve(true), + }); + + strictEqual( + (await relay.getActorUri()).href, + "https://relay.example.com/users/relay", + ); + strictEqual( + (await relay.getSharedInboxUri()).href, + "https://relay.example.com/inbox", + ); + }); + } +}); diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index bc6a92cb5..9c64bf3e5 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -306,7 +306,30 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const originalFetch = globalThis.fetch; + const deliveredActivities: any[] = []; + globalThis.fetch = (async ( + input: URL | RequestInfo, + init?: RequestInit, + ) => { + const outboundRequest = input instanceof Request + ? input + : new Request(input, init); + if ( + outboundRequest.url === + "https://remote.example.com/users/alice/inbox" + ) { + deliveredActivities.push(await outboundRequest.json()); + return new Response(null, { status: 202 }); + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + await relay.fetch(request); + } finally { + globalThis.fetch = originalFetch; + } // Verify handler was called strictEqual(handlerCalled, true); @@ -319,6 +342,23 @@ describe("LitePubRelay", () => { ]); ok(isRelayFollowerData(followerData)); strictEqual(followerData.state, "pending"); + + const reciprocalFollow = deliveredActivities.find((activity) => + activity.type === "Follow" + ); + ok(reciprocalFollow, "Expected a reciprocal Follow activity"); + strictEqual( + reciprocalFollow.actor, + "https://relay.example.com/users/relay", + ); + strictEqual( + reciprocalFollow.object, + "https://remote.example.com/users/alice", + ); + strictEqual( + reciprocalFollow.to, + "https://remote.example.com/users/alice", + ); }); test("handles Follow activity with subscription rejection", async () => { @@ -816,6 +856,84 @@ describe("LitePubRelay", () => { ok(response.status === 200 || response.status === 202); }); + test("forwards activities only to accepted followers", async () => { + const kv = new MemoryKvStore(); + const pendingFollower = new Person({ + id: new URL("https://pending.example.com/users/bob"), + preferredUsername: "bob", + inbox: new URL("https://pending.example.com/users/bob/inbox"), + }); + const acceptedFollower = new Person({ + id: new URL("https://accepted.example.com/users/carol"), + preferredUsername: "carol", + inbox: new URL("https://accepted.example.com/users/carol/inbox"), + }); + await kv.set( + ["follower", pendingFollower.id!.href], + { actor: await pendingFollower.toJsonLd(), state: "pending" }, + ); + await kv.set( + ["follower", acceptedFollower.id!.href], + { actor: await acceptedFollower.toJsonLd(), state: "accepted" }, + ); + + const relay = createRelay("litepub", { + kv, + origin: "https://relay.example.com", + documentLoaderFactory: () => mockDocumentLoader, + authenticatedDocumentLoaderFactory: () => mockDocumentLoader, + subscriptionHandler: () => Promise.resolve(true), + }); + + const createActivity = new Create({ + id: new URL("https://remote.example.com/activities/create/1"), + actor: new URL("https://remote.example.com/users/alice"), + object: new Note({ + id: new URL("https://remote.example.com/notes/1"), + content: "Hello world", + }), + }); + let request = new Request("https://relay.example.com/inbox", { + method: "POST", + headers: { "Content-Type": "application/activity+json" }, + body: JSON.stringify( + await createActivity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest( + request, + rsaKeyPair.privateKey, + rsaPublicKey.id, + ); + + const originalFetch = globalThis.fetch; + const deliveredInboxUrls: string[] = []; + globalThis.fetch = (async ( + input: URL | RequestInfo, + init?: RequestInit, + ) => { + const outboundRequest = input instanceof Request + ? input + : new Request(input, init); + if (outboundRequest.url.endsWith("/inbox")) { + deliveredInboxUrls.push(outboundRequest.url); + return new Response(null, { status: 202 }); + } + return await originalFetch(input, init); + }) as typeof fetch; + + try { + const response = await relay.fetch(request); + ok(response.status === 200 || response.status === 202); + } finally { + globalThis.fetch = originalFetch; + } + + deepStrictEqual(deliveredInboxUrls, [ + "https://accepted.example.com/users/carol/inbox", + ]); + }); + test("handles Update activity with Announce forwarding", async () => { const kv = new MemoryKvStore();