--- url: /intro.md --- # What is Fedify? Fedify is a TypeScript library for building federated server apps powered by [ActivityPub] and other standards, so-called [fediverse].\[^1] It aims to eliminate the complexity and boilerplate code when building a federated server app, so that you can focus on your business logic and user experience. Currently, Fedify provides the following features out of the box: * Type-safe objects for [Activity Vocabulary] (including some vendor-specific extensions) * [WebFinger] client and server * [HTTP Signatures] & [HTTP Message Signatures] * [Object Integrity Proofs][FEP-8b32] & [Linked Data Signatures] * Middlewares for handling webhooks * [NodeInfo] protocol * Enhanced interoperability with Mastodon and other popular fediverse software * [Integration with various web frameworks](./manual/integration.md) * [CLI toolchain for testing and debugging](./cli.md) If you want to know more about the project, please take a look at the following resources: * [GitHub] * Tutorials: [Learning the basics](./tutorial/basics.md) & [Creating a microblog](./tutorial/microblog.md) * [API reference] * [Examples] If you have any questions, suggestions, or feedback, please feel free to join our [Matrix chat space] or [GitHub Discussions]. \[^1]: You may already know some of the networks in the fediverse, such as [Mastodon], [Lemmy], [Pixelfed], [PeerTube], and so on. [ActivityPub]: https://www.w3.org/TR/activitypub/ [fediverse]: https://en.wikipedia.org/wiki/Fediverse [Activity Vocabulary]: https://www.w3.org/TR/activitystreams-vocabulary/ [WebFinger]: https://datatracker.ietf.org/doc/html/rfc7033 [HTTP Signatures]: https://tools.ietf.org/html/draft-cavage-http-signatures-12 [HTTP Message Signatures]: https://www.rfc-editor.org/rfc/rfc9421 [FEP-8b32]: https://w3id.org/fep/8b32 [Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/ [NodeInfo]: https://nodeinfo.diaspora.software/ [GitHub]: https://github.com/fedify-dev/fedify [API reference]: https://jsr.io/@fedify/fedify [Examples]: https://github.com/fedify-dev/fedify/tree/main/examples [Matrix chat space]: https://matrix.to/#/#fedify:matrix.org [GitHub Discussions]: https://github.com/fedify-dev/fedify/discussions [Mastodon]: https://joinmastodon.org/ [Lemmy]: https://join-lemmy.org/ [Pixelfed]: https://pixelfed.org/ [PeerTube]: https://joinpeertube.org/ --- --- url: /why.md description: >- This document explains why you should consider using Fedify for your ActivityPub server development. --- # Why Fedify? Developing a federated application using ActivityPub can be complex and time-consuming. While the ActivityPub specification provides a solid foundation for decentralized social networking, implementing it from scratch presents significant challenges. Fedify alleviates these pain points, allowing developers to focus on their application's unique features rather than protocol intricacies. ## Common pain points when implementing ActivityPub from scratch Without a framework like Fedify, developers face numerous challenges: ### Technical complexity Steep learning curve : The ActivityPub family of specifications is extensive, covering hundreds of pages of technical documentation JSON-LD complexities : Dealing with context resolution, expansion, and compaction manually is error-prone Cryptographic hurdles : Implementing HTTP Signatures, key management, and signature verification correctly requires specialized knowledge ### Infrastructure requirements Message delivery reliability : Building retry logic, exponential backoff, and failure handling from scratch Performance bottlenecks : Naive message delivery implementations can cause timeouts, high memory usage, and server crashes Scalability issues : Direct delivery to thousands of followers can overload your application servers ### Federation challenges Incompatible implementations : Different ActivityPub servers interpret the specifications differently, requiring implementation-specific adaptations Evolving ecosystem : Federation standards continue to evolve with community extensions and conventions Security vulnerabilities : Potential for SSRF attacks, signature forgery, and other security issues without careful implementation ### Development overhead No standardized tools : Developers must build their own debugging and testing utilities for federation Reinventing the wheel : Common patterns like WebFinger resolution or follower management must be reimplemented Maintenance burden : Keeping up with security patches and protocol changes across the fediverse ## Protocol complexity and data handling The ActivityPub family of W3C recommendations—including ActivityStreams 2.0, Activity Vocabulary, and ActivityPub—is extensive and challenging to implement correctly. Messages encoded in JSON-LD using the ActivityStreams vocabulary require careful attention to contexts, IRI resolution, and object serialization. Fedify provides: * A higher-level abstraction over these protocols * Type-safe TypeScript classes for all standard ActivityStreams types * Utilities for creation, manipulation, and validation of ActivityPub objects ## Federation infrastructure Building an ActivityPub server requires implementing several interconnected systems: ### Message processing and delivery Fedify offers a comprehensive solution for managing ActivityPub's inbox/outbox model: * Robust inbox handlers with activity de-duplication * Configurable message queues with reliable retry mechanisms ### Scalability with fan-out architecture One of the most challenging aspects of ActivityPub implementation is efficiently delivering activities to potentially thousands of recipients. Fedify's [two-stage delivery process](./manual/send.md#optimizing-activity-delivery-for-large-audiences) addresses this: 1. For activities with many recipients, a single consolidated message containing the activity and all recipient information is created 2. A background worker processes this message and re-enqueues individual delivery tasks 3. Each delivery has independent retry logic and error handling ```mermaid flowchart TB A[Client] -->|Create post| B[Application] B -->|Create activity| C[Fedify] subgraph "Two-stage delivery process" C -->|1\. Single consolidated message| D[(Message Queue)] D -->|2\. Process message| E[Fan-out Worker] E -->|3\. Create individual delivery tasks| F[(Delivery Queue)] end F -->|Deliver to recipient 1| G[Remote server 1] F -->|Deliver to recipient 2| H[Remote server 2] F -->|Deliver to recipient N| I[Remote server N] G -.-|Failed? Retry with backoff| F H -.-|Failed? Retry with backoff| F I -.-|Failed? Retry with backoff| F ``` Benefits include: * Faster API response times for improved UX * Reduced memory consumption through payload deduplication * Configurable fan-out strategies with `"auto"`, `"skip"`, or `"force"` options ## Security and interoperability Security is paramount in federation, while interoperability across diverse implementations presents ongoing challenges. Fedify provides: * Comprehensive cryptographic support (RSA-PKCS#1-v1.5 and Ed25519 keys) * Multiple authentication methods ([HTTP Signatures](./manual/send.md#http-signatures), [HTTP Message Signatures](./manual/send.md#http-message-signatures), [Object Integrity Proofs](./manual/send.md#object-integrity-proofs), [Linked Data Signatures](./manual/send.md#linked-data-signatures)) * SSRF protection and other security best practices * [Activity transformers](./manual/send.md#activity-transformers) that adjust outgoing activities for maximum compatibility * Multi-origin support with canonical URL handling for consistent identity ## Technology agnosticism Fedify doesn't force you to use specific technologies, giving you the freedom to build with your preferred tools. ### Framework flexibility Works with virtually any JavaScript/TypeScript web framework: * [Express](./manual/integration.md#express) * [Hono](./manual/integration.md#hono) * [Fresh](./manual/integration.md#fresh) * [SvelteKit](./manual/integration.md#sveltekit) * [Next.js](./manual/integration.md#next.js) * [h3](./manual/integration.md#h3)/Nitro … and many more! ### Database independence * Use any SQL or NoSQL database (PostgreSQL, MySQL, MongoDB, etc.) * Use any ORM (Prisma, TypeORM, Drizzle ORM, etc.) * Fedify only requires a [key–value interface](./manual/kv.md) for its internal caching ## Developer experience Fedify significantly improves the developer experience through: TypeScript-native design : Comprehensive type definitions with intelligent auto-completion Observability : [Built-in OpenTelemetry support](./manual/opentelemetry.md) for tracing, metrics, and monitoring [CLI toolchain](./cli.md) : Tools for testing and debugging federation, including: ``` - [Object lookup utility](./cli.md#fedify-lookup-looking-up-an-activitypub-object) - [Ephemeral inbox server](./cli.md#fedify-inbox-ephemeral-inbox-server) for testing outgoing activities - [Local tunnel](./cli.md#fedify-tunnel-exposing-a-local-http-server-to-the-public-internet) for exposing development servers to the internet ``` ## Success stories Several notable projects have chosen Fedify to implement ActivityPub federation, demonstrating its effectiveness in real-world applications: ### Ghost: Federation for a major publishing platform [Ghost], a leading open-source publishing platform used by thousands of journalists, creators, and companies worldwide, chose Fedify for their ActivityPub implementation: * Built a separate ActivityPub service with Fedify rather than integrating directly into Ghost core * Leveraged Fedify to quickly implement federation while focusing on delivering value to their large user base * Contributes back features and bug fixes to the Fedify ecosystem > ActivityPub sits on top of a lot more standards than you might think, so it's > sometimes necessary to juggle a lot of complex standards to get the full > picture… This is the main reason I created Fedify. > > … > > We can definitely attest to the problems that Fedify is working hard to > solve, because even in just a few weeks of early prototyping we were running > into the issues described above right away. > > —From *[Alright, let's Fedify]* [Ghost]: https://ghost.org [Alright, let's Fedify]: https://activitypub.ghost.org/day-4/ ### Hollo: Single-user microblogging [Hollo] is a federated microblogging platform specifically designed for single users, created by the original developer of Fedify: * Initially faced challenges implementing ActivityPub from scratch, which inspired the creation of Fedify * Now powered by Fedify, offering features like Mastodon-compatible API, CommonMark support, and Misskey-style quotes * Serves as both a showcase and test case for Fedify's capabilities > Implementing ActivityPub from the ground up was quite painful for me… > I realized that I was already building a sloppy quasi-framework for > ActivityPub. I thought, “No way, I'm going to build a proper ActivityPub > framework,” and Fedify is the result. > > —Hong Minhee, creator of Fedify and Hollo, in *[Alright, let's Fedify]* [Hollo]: https://docs.hollo.social ### Hackers' Pub: Community-driven federation [Hackers' Pub] is an open-source community project building federation capabilities with Fedify: * Demonstrates how community-driven projects can leverage Fedify to implement ActivityPub * Showcases the accessibility of federation technology to developers beyond large organizations * Part of the growing ecosystem of developers adopting Fedify to join the fediverse [Hackers' Pub]: https://hackers.pub/ ### Encyclia: Bridging academic research to the fediverse [Encyclia] is an innovative service that makes [ORCID] (Open Researcher and Contributor ID) records available on the fediverse: * Bridges academic research into federated social networks by making researcher profiles and publications discoverable through ActivityPub * Allows users to follow researchers and see their new publications in their federated social feeds * Built using Fedify for ActivityPub implementation with tested interoperability across Mastodon and other fediverse platforms * Demonstrates Fedify's versatility in specialized domains beyond traditional social networking applications > Encyclia is built using Fedify, an ActivityPub-based federated server > framework that helps developers easily integrate their applications with > the fediverse… It simplifies the complex implementation of the ActivityPub > protocol, significantly reducing development time. > > —From the [Encyclia roadmap] [Encyclia]: https://encyclia.pub/ [ORCID]: https://orcid.org/ [Encyclia roadmap]: https://encyclia.pub/roadmap ### Typo Blue: Text-only blogging meets the fediverse [Typo Blue] is a Korean text-only blogging platform that embraces minimalist writing, allowing users to focus purely on text without images or attachments: * Originally featured email subscription for readers to follow blogs * Recently added opt-in ActivityPub integration using Fedify, enabling blogs to be discoverable across the fediverse * Allows readers on Mastodon, Misskey, and other ActivityPub-compatible platforms to follow and interact with blogs * Demonstrates how specialized platforms can maintain their unique identity while participating in the open social web [Typo Blue]: https://typo.blue/ ### SiliconBeest: Serverless federation on Cloudflare Workers [SiliconBeest] is a serverless fediverse platform built on Cloudflare Workers, demonstrating how Fedify can power ActivityPub federation on edge-native infrastructure: * Built a Mastodon API-compatible social networking server on Cloudflare Workers * Uses Fedify together with *@fedify/cfworkers* to handle federation, message delivery, signatures, WebFinger, and related protocol concerns * Combines Fedify with platform-native services like KV and Queues to support a serverless deployment model * Shows how Fedify can be adapted beyond conventional server environments for ActivityPub applications [SiliconBeest]: https://github.com/SJang1/siliconbeest ## Conclusion While building an ActivityPub server from scratch can be educational, it's a complex undertaking that can slow development and lead to interoperability or security issues. Fedify abstracts away these complexities, providing a robust, secure, and developer-friendly framework. By handling the low-level details of ActivityPub, WebFinger, authentication, and more, Fedify empowers you to: Develop faster : Focus on your application's core functionality and user experience Reduce errors : Leverage type safety and battle-tested components Ensure interoperability : Work seamlessly with the diverse fediverse ecosystem Improve security : Rely on built-in security mechanisms and best practices Stay flexible : Integrate with your technology stack of choice If you're looking to build a federated application on the ActivityPub protocol, Fedify offers a powerful and efficient path to success. --- --- url: /install.md description: How to install Fedify. --- # Installation ## Quick start The easiest way to start a new Fedify project is to use the `fedify init` command. It creates a new directory with a minimal Fedify project template. ### CLI toolchain First of all, you need to have the `fedify` command, the Fedify CLI toolchain, installed on your system. If you haven't installed it yet, please follow the following instructions: ::: code-group ```sh [npm] npm install -g @fedify/cli ``` ```sh [Homebrew (Linux/macOS)] brew install fedify ``` ```powershell [Scoop (Windows)] scoop install fedify ``` ```sh [Bun] bun install -g @fedify/cli ``` ```sh [Deno] deno install -gA --unstable-fs --unstable-kv -n fedify jsr:@fedify/cli ``` ::: If you use Deno earlier than 2.7.0, add `--unstable-temporal` to the Deno installation command above. There are other ways to install the `fedify` command. Please refer to the [*Installation* section](./cli.md#installation) in the *CLI toolchain* docs. ### Project setup After installing the `fedify` command, you can create a new Fedify project by running the following command: ```sh fedify init your-project-dir ``` The above command will start a wizard to guide you through the project setup. You can choose the JavaScript runtime, the package manager, and the web framework you want to integrate Fedify with, and so on. After the wizard finishes, you will have a new Fedify project in the *your-project-dir* directory. For more information about the `fedify init` command, please refer to the [*`fedify init`* section](./cli.md#fedify-init-initializing-a-fedify-project) in the *CLI toolchain* docs. [![The “fedify init” command demo](https://asciinema.org/a/979416.svg)](https://asciinema.org/a/979416) ### Alternative: Using `@fedify/create` If you don't want to install the `fedify` CLI globally, you can use `@fedify/create` directly: ::: code-group ```sh [npm] npm init @fedify your-project-dir ``` ```sh [pnpm] pnpm create @fedify your-project-dir ``` ```sh [Yarn] yarn create @fedify your-project-dir ``` ```sh [Bun] bunx @fedify/create your-project-dir ``` ::: This works the same way as `fedify init` and will guide you through the same project setup wizard. > \[!TIP] > Already running a federated service on another JavaScript ActivityPub > library? See [*Migrating from other libraries*](./manual/migrate.md) for > guides covering `activitypub-express`, `@activity-kit/*`, hand-rolled > Express code, and `activitystrea.ms`. ## Manual installation Fedify is available on [JSR] for [Deno] and on [npm] for [Bun] and [Node.js]. [JSR]: https://jsr.io/@fedify/fedify [Deno]: https://deno.com/ [npm]: https://www.npmjs.com/package/@fedify/fedify [Bun]: https://bun.sh/ [Node.js]: https://nodejs.org/ ### Deno [Deno] is the primary runtime for Fedify. As a prerequisite, you need to have Deno 2.0.0 or later installed on your system. Then you can install Fedify via the following command: ```sh deno add jsr:@fedify/fedify ``` Fedify requires the [`Temporal`] API. On Deno 2.7.0 or later, it is stable and no extra setting is needed. On Deno versions earlier than 2.7.0, add `"temporal"` to the `"unstable"` field in *deno.json*: ```json{5} { "imports": { "@fedify/fedify": "jsr:@fedify/fedify" }, "unstable": ["temporal"] } ``` [`Temporal`]: https://tc39.es/proposal-temporal/docs/ ### Bun Fedify can also be used in Bun. You can install it via the following command: ```sh bun add @fedify/fedify ``` ### Node.js Fedify can also be used in Node.js. As a prerequisite, you need to have Node.js 22.0.0 or later installed on your system. Then you can install Fedify via the following command: ::: code-group ```sh [npm] npm add @fedify/fedify ``` ```sh [pnpm] pnpm add @fedify/fedify ``` ```sh [Yarn] yarn add @fedify/fedify ``` ::: We recommend using [ESM] with Fedify by adding `"type": "module"` to the *package.json* file. While Fedify also supports [CommonJS] for legacy compatibility, ESM is the preferred approach: ```json{2} { "type": "module", "dependencies": { "@fedify/fedify": "^1.8.1" } } ``` [ESM]: https://nodejs.org/api/esm.html [CommonJS]: https://nodejs.org/docs/latest/api/modules.html ## Editor setup `fedify init` configures Visual Studio Code for you out of the box. Setting up [Zed] takes a few extra steps because `fedify init` does not generate *.zed/* files yet. [Zed]: https://zed.dev/ ### Visual Studio Code *For Deno projects*, `fedify init` writes a *.vscode/extensions.json* that recommends the [Deno extension]: ```json [.vscode/extensions.json] { "recommendations": ["denoland.vscode-deno"] } ``` The matching *.vscode/settings.json* turns on Deno's language server and sets it as the default formatter for JavaScript and TypeScript: ```jsonc [.vscode/settings.json] { "deno.enable": true, "deno.unstable": true, "editor.detectIndentation": false, "editor.indentSize": 2, "editor.insertSpaces": true, "[typescript]": { "editor.defaultFormatter": "denoland.vscode-deno", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.sortImports": "always" } } // The same block applies to [javascript], [javascriptreact], and // [typescriptreact]; [json] and [jsonc] use "vscode.json-language-features". } ``` Deno's [*Set up your environment*] guide covers other editors and shells. *For Node.js/Bun projects*, `fedify init` writes a *.vscode/extensions.json* that recommends the [Oxc extension]: ```json [.vscode/extensions.json] { "recommendations": ["oxc.oxc-vscode"] } ``` The matching *.vscode/settings.json* sets Oxfmt as the default formatter and enables Oxc's fix-all action on save: ```jsonc [.vscode/settings.json] { "editor.detectIndentation": false, "editor.indentSize": 2, "editor.insertSpaces": true, "oxc.fmt.configPath": ".oxfmtrc.json", "[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.oxc": "always" } } // The same block applies to [javascript], [javascriptreact], // [typescriptreact], [json], and [jsonc]. } ``` [Deno extension]: https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno [*Set up your environment*]: https://docs.deno.com/runtime/getting_started/setup_your_environment/ [Oxc extension]: https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode ### Zed > \[!TIP] > `fedify init` does not generate *.zed/* configuration; the snippets below > need to be added by hand. *For Deno projects*, create *.zed/settings.json* that enables the Deno language server and uses it as the formatter for TypeScript and JavaScript. The `!` prefix disables the bundled TypeScript and ESLint servers so they do not compete with Deno: ```jsonc [.zed/settings.json] { "lsp": { "deno": { "settings": { "deno": { "enable": true } } } }, "languages": { "TypeScript": { "formatter": [ { "language_server": { "name": "deno" } } ], "language_servers": [ "deno", "!typescript-language-server", "!vtsls", "!eslint", "!biome", "..." ] } // The same block applies to TSX and JavaScript. } } ``` Zed's [Deno language docs][zed-deno] cover the available options. [Hackers' Pub] is a real-world reference. *For Node.js/Bun projects*, Zed enables its bundled TypeScript language server out of the box; the typical addition is wiring up Oxc for formatting and lint fixes. See Zed's [TypeScript language docs][zed-ts] for setup. Set Oxc as the language server and turn on its fix-all action on format: ```jsonc [.zed/settings.json] { "language_servers": ["oxc", "..."], "code_actions_on_format": { "source.fixAll.oxc": true } } ``` [zed-deno]: https://zed.dev/docs/languages/deno [Hackers' Pub]: https://github.com/hackers-pub/hackerspub/blob/main/.zed/settings.json [zed-ts]: https://zed.dev/docs/languages/typescript ## Linting `fedify init` configures the [`@fedify/lint`] plugin automatically: Deno projects pick it up through `lint.plugins` in *deno.json*, and Node.js/Bun projects through a generated *.oxlintrc.json* using `@fedify/lint/oxlint`. For the full rule list, configuration options, and how to run the linter, see the [*Linting*](./manual/lint.md) chapter. [`@fedify/lint`]: https://jsr.io/@fedify/lint ## Agentic skill *This skill is available since Fedify 2.2.0.* Fedify ships a [Markdown skill file][SKILL.md] that AI coding assistants such as [Claude Code], [Codex], or [OpenCode] can load to learn Fedify's APIs, common pitfalls, and recommended patterns. The file lives inside the `@fedify/fedify` package itself, so the only remaining step is exposing it to your agent's skills directory. [SKILL.md]: https://github.com/fedify-dev/fedify/blob/main/claude-plugin/skills/fedify/SKILL.md [Claude Code]: https://claude.com/product/claude-code [Codex]: https://developers.openai.com/codex [OpenCode]: https://opencode.ai/ ### Claude Code plugin Install the Fedify plugin from the Claude Code marketplace. The plugin works with any runtime and adds six slash commands, two specialized agents, and the full Fedify skill: ``` /plugin marketplace add fedify-dev/fedify /plugin install fedify@fedify ``` ### Node.js/Bun Use [`skills-npm`], a third-party tool by Anthony Fu (it is not a Fedify package), that scans *node\_modules* for packages exposing the `agents.skills` field in their *package.json* and links them into your agent's skills directory on every install. 1. Install `skills-npm` as a dev dependency: ::: code-group ```sh [npm] npm add -D skills-npm ``` ```sh [pnpm] pnpm add -D skills-npm ``` ```sh [Yarn] yarn add -D skills-npm ``` ```sh [Bun] bun add -D skills-npm ``` ::: 2. Add a `prepare` script to your *package.json* so it runs after every install: ```json { "scripts": { "prepare": "skills-npm" } } ``` 3. Reinstall once. The Fedify skill appears at *.claude/skills/fedify/* (and similar locations for other supported agents). The same script picks up other Fedify packages and any third-party npm packages that adopt the convention. [`skills-npm`]: https://github.com/antfu/skills-npm ### Deno Install the skill by hand: 1. Pick your agent's skills directory. For Claude Code, this is *.claude/skills/fedify/*. 2. Download *SKILL.md* from the Fedify repository: ```sh mkdir -p .claude/skills/fedify curl -L -o .claude/skills/fedify/SKILL.md \ https://raw.githubusercontent.com/fedify-dev/fedify/main/claude-plugin/skills/fedify/SKILL.md ``` 3. Either commit the file or add it to *.gitignore*, depending on your team's preference. --- --- url: /cli.md description: >- The fedify command is a CLI toolchain for Fedify and debugging ActivityPub-enabled federated server apps. This section explains the key features of the fedify command. --- # `fedify`: CLI toolchain The `fedify` command is a CLI toolchain for Fedify and debugging ActivityPub-enabled federated server apps. Although it is primarily designed for developers who use Fedify, it can be used with any ActivityPub-enabled server. ## Installation ### Using npm If you have [Node.js] or [Bun] installed, you can install `fedify` by running the following command: ::: code-group ```sh [npm] npm install -g @fedify/cli ``` ```sh [Bun] bun install -g @fedify/cli ``` ::: [Node.js]: https://nodejs.org/ [Bun]: https://bun.sh/ ### Using Homebrew on macOS and Linux If you are using macOS or Linux and have [Homebrew] installed, you can install `fedify` by running the following command: ```sh brew install fedify ``` [Homebrew]: https://brew.sh/ ### Using Scoop on Windows If you are using Windows and have [Scoop] installed, you can install `fedify` by running the following command: ```powershell scoop install fedify ``` [Scoop]: https://scoop.sh/ ### Using Deno If you have [Deno] installed, you can install `fedify` by running the following command: ::: code-group ```sh [Linux] deno install \ -gA \ --unstable-fs --unstable-kv \ -n fedify \ jsr:@fedify/cli ``` ```sh [macOS] deno install \ -gA \ --unstable-fs --unstable-kv \ -n fedify \ jsr:@fedify/cli ``` ```powershell [Windows] deno install ` -gA ` --unstable-fs --unstable-kv ` -n fedify ` jsr:@fedify/cli ``` ::: On Deno versions earlier than 2.7.0, add `--unstable-temporal` to the install command above. [Deno]: https://deno.com/ ### Using mise If you have [mise] installed, you can install `fedify` by running the following command: ```sh mise use -g github:fedify-dev/fedify ``` [mise]: https://mise.jdx.dev/ ### Downloading the executable You can download the pre-built executables from the [releases] page. Download the appropriate executable for your platform and put it in your `PATH`. [releases]: https://github.com/fedify-dev/fedify/releases ## Configuration file *This feature is available since Fedify 2.0.0.* The `fedify` command supports configuration files to set default values for command options. Configuration files are written in [TOML] format. [TOML]: https://toml.io/ ### Configuration file locations Configuration files are loaded from the following locations in order, with later files taking precedence over earlier ones: 1. System-wide configuration directories (see below) 2. *~/.config/fedify/config.toml* (user-level, or `$XDG_CONFIG_HOME/fedify/config.toml`) 3. *.fedify.toml* in the current working directory (project-level) 4. Custom path specified via `--config` option (highest precedence) The system-wide and user-level config paths vary by operating system: * **Linux/macOS (system)**: Directories listed in `$XDG_CONFIG_DIRS` (default: */etc/xdg/fedify/config.toml*) * **Linux/macOS (user)**: `$XDG_CONFIG_HOME/fedify/config.toml` (default: *~/.config/fedify/config.toml*) * **Windows (system)**: *%ProgramData%\fedify\config.toml* * **Windows (user)**: *%APPDATA%\fedify\config.toml* ### `--config`: Load an additional configuration file You can specify an additional configuration file to load using the `--config` option: ```sh fedify --config ./my-config.toml lookup @user@example.com ``` This file is loaded after all standard configuration files and takes the highest precedence. ### `--ignore-config`: Ignore all configuration files The `--ignore-config` option skips loading all configuration files. This is useful for CI environments or when you want reproducible behavior: ```sh fedify --ignore-config lookup @user@example.com ``` ### Configuration file structure Below is an example configuration file showing all available options: ```toml # Global settings (apply to all commands) debug = false userAgent = "MyApp/1.0" tunnelService = "fedify.com.es" # Or "serveo.net" or "pinggy.io" [webfinger] allowPrivateAddress = false maxRedirection = 5 [lookup] authorizedFetch = false firstKnock = "draft-cavage-http-signatures-12" # or "rfc9421" allowPrivateAddress = false traverse = false suppressErrors = false reverse = false defaultFormat = "default" # "default", "raw", "compact", or "expand" separator = "----" timeout = 30 # seconds [inbox] actorName = "Fedify Ephemeral Actor" actorSummary = "An ephemeral actor for testing purposes." authorizedFetch = false noTunnel = false follow = ["@user@example.com"] acceptFollow = ["*"] [relay] protocol = "mastodon" # or "litepub" port = 8000 name = "Fedify Relay" persistent = "/path/to/relay.db" # optional, uses in-memory if not specified noTunnel = false acceptFollow = ["*"] rejectFollow = [] [nodeinfo] raw = false bestEffort = false showFavicon = true showMetadata = false ``` All configuration options are optional. Command-line arguments always take precedence over configuration file values. ## `fedify init`: Initializing a Fedify project *This command is available since Fedify 0.12.0.* [![The “fedify init” command demo](https://asciinema.org/a/979416.svg)](https://asciinema.org/a/979416) The `fedify init` command is used to initialize a new Fedify project. It creates a new directory with the necessary files and directories for a Fedify project. To create a new Fedify project, run the below command: ```sh fedify init my-fedify-project ``` The above command will start the interactive prompt to initialize a new Fedify project. It will ask you a few questions to set up the project: * Web framework: Bare-bones, [Hono], [Elysia], [Express], [Nitro], [Next.js], or [Astro] * Package manager: [Deno], [Bun], [npm], [pnpm], or [Yarn] * Message queue: In-Process, [Redis], [PostgreSQL], [AMQP] (e.g., [RabbitMQ]), or [Deno KV] (if Deno) * Key–value store: In-Memory, [Redis], [PostgreSQL], or [Deno KV] (if Deno) > \[!TIP] > Projects created with `fedify init` automatically include [`@fedify/lint`] > for consistent code linting. Deno projects get a lint plugin configured in > *deno.json*, while Node.js and Bun projects get an *.oxlintrc.json* with > `@fedify/lint/oxlint`. > \[!NOTE] > If you find the full `@fedify/cli` toolchain too heavy for your needs, you > can use [`@fedify/create`] instead to scaffold a new Fedify project without > installing the CLI globally. See the > [*Alternative: Using `@fedify/create`*](./install.md#alternative-using-fedify-create) > section for details. Alternatively, you can specify the options in the command line to skip some of interactive prompts: [Hono]: https://hono.dev/ [Elysia]: https://elysiajs.com/ [Express]: https://expressjs.com/ [Nitro]: https://nitro.unjs.io/ [Next.js]: https://nextjs.org/ [Astro]: https://astro.build/ [npm]: https://www.npmjs.com/ [pnpm]: https://pnpm.io/ [Yarn]: https://yarnpkg.com/ [Redis]: https://redis.io/ [PostgreSQL]: https://www.postgresql.org/ [AMQP]: https://www.amqp.org/ [RabbitMQ]: https://www.rabbitmq.com/ [Deno KV]: https://deno.com/kv [`@fedify/lint`]: /manual/lint [`@fedify/create`]: https://www.npmjs.com/package/@fedify/create ### `-p`/`--package-manager`: Package manager You can specify the package manager by using the `-p`/`--package-manager` option. The available options are: * `deno`: [Deno] * `pnpm`: [pnpm] * `bun`: [Bun] * `yarn`: [Yarn] * `npm`: [npm] ### `-w`/`--web-framework`: Web framework You can specify the web framework to integrate with Fedify by using the `-w`/`--web-framework` option. The available options are: * `bare-bones`: A minimal setup without any web framework integration, but in Node.js, [Hono] is used for a simple adapter for a lightweight experience. * `hono`: [Hono] * `nitro`: [Nitro] * `next`: [Next.js] * `elysia`: [Elysia] * `astro`: [Astro] * `express`: [Express] ### `-k`/`--kv-store`: key–value store You can specify the key–value store to use by using the `-k`/`--kv-store` option. The available options are: * `in-memory`: An in-memory key–value store that does not persist data across restarts. This is useful for testing and development purposes. * `redis`: [Redis] * `postgres`: [PostgreSQL] * `denokv`: [Deno KV] (if Deno) ### `-m`/`--message-queue`: Message queue You can specify the message queue to use by using the `-m`/`--message-queue` option. The available options are: * `in-process`: An in-process message queue that does not persist messages across restarts. This is useful for testing and development purposes. * `redis`: [Redis] * `postgres`: [PostgreSQL] * `amqp`: [AMQP] (e.g., [RabbitMQ]) * `denokv`: [Deno KV] (if Deno) ### `--dry-run`: Preview without creating files *This option is available since Fedify 1.8.0.* The `--dry-run` option allows you to preview what files and configurations would be created without actually creating them. This is useful for reviewing the project structure before committing to the initialization. ```sh fedify init my-fedify-project --dry-run ``` When using `--dry-run`, the command will: * Display all files that would be created with their contents * Show which dependencies would be installed * Preview any commands that would be executed * Not create any directories or files on your filesystem This option works with all other initialization options, allowing you to preview different configurations before making a decision. ### `--allow-non-empty`: Initialize in a non-empty directory *This option is available since Fedify 2.2.0.* By default, `fedify init` asks for confirmation before using a directory that already contains files. This prompt protects you from accidentally initializing a project in the wrong directory. In non-interactive scripts or CI jobs, use the `--allow-non-empty` option to allow a non-empty target directory: ```sh fedify init . --allow-non-empty ``` This option does not overwrite existing project files. Before making changes, `fedify init` checks the files it would generate and fails if any of them already exist. Unrelated files can remain in the target directory only when the selected framework scaffolder accepts them. Some scaffolders, such as *create-next-app*, still reject unrelated files even if `fedify init` skips its own confirmation prompt, while a freshly initialized *.git* directory remains acceptable. ### `--skip-install`: Skip installing dependencies *This option is available since Fedify 2.3.0.* By default, `fedify init` runs the selected package manager's install command right after scaffolding the project. The `--skip-install` option scaffolds the files without running install, which is useful when: * installation is handled separately in a CI pipeline; * the new project lives inside a monorepo whose dependencies are installed from the workspace root; or * you want to inspect the generated files before installing. ```sh fedify init my-fedify-project --skip-install ``` After scaffolding, `fedify init` prints the command to run to install dependencies later. Other steps such as creating files, applying patches, and running the framework-specific scaffolder (e.g., *create-next-app*) still happen as usual; only the final install step is skipped. ## `fedify lookup`: Looking up an ActivityPub object The `fedify lookup` command is used to look up an ActivityPub object by its URL or an actor by its handle. For example, the below command looks up a `Note` object with the given URL: ```sh fedify lookup https://todon.eu/@hongminhee/112341925069749583 ``` The output will be like the below: ``` Note { id: URL "https://todon.eu/users/hongminhee/statuses/112341925069749583", attachments: [ Document { name: "The demo video on my terminal", url: URL "https://todon.eu/system/media_attachments/files/112/341/916/300/016/369/original/f83659866f94054f.mp"... 1 more character, mediaType: "video/mp4" } ], attribution: URL "https://todon.eu/users/hongminhee", contents: [ '

I'm working on adding a CLI toolchain to \[!NOTE] > `--recurse` and [`-t`/`--traverse`](#t-traverse-traverse-the-collection) > are mutually exclusive. > > Recursive fetches disallow private/localhost addresses by default for > safety. URLs explicitly provided on the command line always allow private > addresses, while recursive object fetches honor > [`-p`/`--allow-private-address`](#p-allow-private-address-allow-private-ip-addresses) > when you explicitly opt in. Recursive JSON-LD `@context` URLs still remain > blocked. ### `--recurse-depth`: Set recursion depth limit *This option is available since Fedify 2.1.0.* The `--recurse-depth` option sets the maximum recursion depth when using [`--recurse`](#recurse-recurse-through-object-relationships). By default, it is set to `20`. ```sh fedify lookup --recurse=replyTarget --recurse-depth=10 https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf ``` This option depends on the `--recurse` option. ### `-S`/`--suppress-errors`: Suppress partial errors during traversal or recursion *This option is available since Fedify 0.14.0.* The `-S`/`--suppress-errors` option is used to suppress partial errors during traversal or recursion. For traversal mode: ```sh fedify lookup --traverse --suppress-errors https://fosstodon.org/users/hongminhee/outbox ``` For recursion mode: ```sh fedify lookup --recurse=replyTarget --suppress-errors https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf ``` The difference between with and without the `-S`/`--suppress-errors` option is that the former will suppress the partial errors during traversal or recursion, while the latter will stop on the first such error. ### `-c`/`--compact`: Compact JSON-LD > \[!NOTE] > This option is mutually exclusive with `-e`/`--expanded` and `-r`/`--raw`. You can also output the object in the [compacted JSON-LD] format by using the `-c`/`--compact` option: ```sh fedify lookup --compact https://todon.eu/@hongminhee/112341925069749583 ``` The output will be like the below: ```json { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://todon.eu/users/hongminhee/statuses/112341925069749583", "type": "Note", "attachment": { "type": "Document", "mediaType": "video/mp4", "name": "The demo video on my terminal", "url": "https://todon.eu/system/media_attachments/files/112/341/916/300/016/369/original/f83659866f94054f.mp4" }, "attributedTo": "https://todon.eu/users/hongminhee", "cc": "https://todon.eu/users/hongminhee/followers", "content": "

I'm working on adding a CLI toolchain to #Fedify to help with debugging. The first feature I implemented is the ActivityPub object lookup.

Here's a demo.

#fedidev #ActivityPub

", "contentMap": { "en": "

I'm working on adding a CLI toolchain to #Fedify to help with debugging. The first feature I implemented is the ActivityPub object lookup.

Here's a demo.

#fedidev #ActivityPub

" }, "published": "2024-04-27T07:08:57Z", "replies": { "id": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies", "type": "Collection", "first": { "type": "CollectionPage", "items": "https://todon.eu/users/hongminhee/statuses/112343493232608516", "next": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies?min_id=112343493232608516&page=true", "partOf": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies" } }, "as:sensitive": false, "to": "as:Public", "url": "https://todon.eu/@hongminhee/112341925069749583" } ``` [compacted JSON-LD]: https://www.w3.org/TR/json-ld/#compacted-document-form ### `-e`/`--expanded`: Expanded JSON-LD > \[!NOTE] > This option is mutually exclusive with `-c`/`--compact` and `-r`/`--raw`. You can also output the object in the [expanded JSON-LD] format by using the `-e`/`--expanded` option: ```sh fedify lookup --expand https://todon.eu/@hongminhee/112341925069749583 ``` The output will be like the below: ```json [ { "@id": "https://todon.eu/users/hongminhee/statuses/112341925069749583", "@type": [ "https://www.w3.org/ns/activitystreams#Note" ], "https://www.w3.org/ns/activitystreams#attachment": [ { "@type": [ "https://www.w3.org/ns/activitystreams#Document" ], "https://www.w3.org/ns/activitystreams#mediaType": [ { "@value": "video/mp4" } ], "https://www.w3.org/ns/activitystreams#name": [ { "@value": "The demo video on my terminal" } ], "https://www.w3.org/ns/activitystreams#url": [ { "@id": "https://todon.eu/system/media_attachments/files/112/341/916/300/016/369/original/f83659866f94054f.mp4" } ] } ], "https://www.w3.org/ns/activitystreams#attributedTo": [ { "@id": "https://todon.eu/users/hongminhee" } ], "https://www.w3.org/ns/activitystreams#cc": [ { "@id": "https://todon.eu/users/hongminhee/followers" } ], "https://www.w3.org/ns/activitystreams#content": [ { "@value": "

I'm working on adding a CLI toolchain to #Fedify to help with debugging. The first feature I implemented is the ActivityPub object lookup.

Here's a demo.

#fedidev #ActivityPub

" }, { "@language": "en", "@value": "

I'm working on adding a CLI toolchain to #Fedify to help with debugging. The first feature I implemented is the ActivityPub object lookup.

Here's a demo.

#fedidev #ActivityPub

" } ], "https://www.w3.org/ns/activitystreams#published": [ { "@type": "http://www.w3.org/2001/XMLSchema#dateTime", "@value": "2024-04-27T07:08:57Z" } ], "https://www.w3.org/ns/activitystreams#replies": [ { "@id": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies", "@type": [ "https://www.w3.org/ns/activitystreams#Collection" ], "https://www.w3.org/ns/activitystreams#first": [ { "@type": [ "https://www.w3.org/ns/activitystreams#CollectionPage" ], "https://www.w3.org/ns/activitystreams#items": [ { "@id": "https://todon.eu/users/hongminhee/statuses/112343493232608516" } ], "https://www.w3.org/ns/activitystreams#next": [ { "@id": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies?min_id=112343493232608516&page=true" } ], "https://www.w3.org/ns/activitystreams#partOf": [ { "@id": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies" } ] } ] } ], "https://www.w3.org/ns/activitystreams#sensitive": [ { "@value": false } ], "https://www.w3.org/ns/activitystreams#to": [ { "@id": "https://www.w3.org/ns/activitystreams#Public" } ], "https://www.w3.org/ns/activitystreams#url": [ { "@id": "https://todon.eu/@hongminhee/112341925069749583" } ] } ] ``` [expanded JSON-LD]: https://www.w3.org/TR/json-ld/#expanded-document-form ### `-r`/`--raw`: Raw JSON *This option is available since Fedify 0.15.0.* > \[!NOTE] > This option is mutually exclusive with `-c`/`--compact` and `-e`/`--expanded`. You can also output the fetched object in the raw JSON format by using the `-r`/`--raw` option: ```sh fedify lookup --raw https://todon.eu/@hongminhee/112341925069749583 ``` The output will be like the below: ```json { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount", "blurhash": "toot:blurhash", "focalPoint": { "@container": "@list", "@id": "toot:focalPoint" }, "Hashtag": "as:Hashtag" } ], "id": "https://todon.eu/users/hongminhee/statuses/112341925069749583", "type": "Note", "summary": null, "inReplyTo": null, "published": "2024-04-27T07:08:57Z", "url": "https://todon.eu/@hongminhee/112341925069749583", "attributedTo": "https://todon.eu/users/hongminhee", "to": [ "https://www.w3.org/ns/activitystreams#Public" ], "cc": [ "https://todon.eu/users/hongminhee/followers" ], "sensitive": false, "atomUri": "https://todon.eu/users/hongminhee/statuses/112341925069749583", "inReplyToAtomUri": null, "conversation": "tag:todon.eu,2024-04-27:objectId=90184788:objectType=Conversation", "content": "

I'm working on adding a CLI toolchain to #Fedify to help with debugging. The first feature I implemented is the ActivityPub object lookup.

Here's a demo.

#fedidev #ActivityPub

", "contentMap": { "en": "

I'm working on adding a CLI toolchain to #Fedify to help with debugging. The first feature I implemented is the ActivityPub object lookup.

Here's a demo.

#fedidev #ActivityPub

" }, "attachment": [ { "type": "Document", "mediaType": "video/mp4", "url": "https://todon.eu/system/media_attachments/files/112/341/916/300/016/369/original/f83659866f94054f.mp4", "name": "The demo video on my terminal", "blurhash": "U87_4lWB_3WBt7bHazWV~qbHaybFozj[ayfj", "width": 1092, "height": 954 } ], "tag": [ { "type": "Hashtag", "href": "https://todon.eu/tags/fedify", "name": "#fedify" }, { "type": "Hashtag", "href": "https://todon.eu/tags/fedidev", "name": "#fedidev" }, { "type": "Hashtag", "href": "https://todon.eu/tags/activitypub", "name": "#activitypub" } ], "replies": { "id": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies?min_id=112343493232608516&page=true", "partOf": "https://todon.eu/users/hongminhee/statuses/112341925069749583/replies", "items": [ "https://todon.eu/users/hongminhee/statuses/112343493232608516" ] } } } ``` ### `-a`/`--authorized-fetch`: Authorized fetch You can also use the `-a`/`--authorized-fetch` option to fetch the object with authentication. Under the hood, this option generates a one-time key pair, spins up a temporary ActivityPub server to serve the public key, and signs the request with the private key. Here's an example where the `fedify lookup` fails due to the object being protected: ```sh fedify lookup @tchambers@indieweb.social ``` The above command will output the below error: ``` Failed to fetch the object. It may be a private object. Try with -a/--authorized-fetch. ``` However, you can fetch the object with the `-a`/`--authorized-fetch` option: ```sh fedify lookup --authorized-fetch @tchambers@indieweb.social ``` This time, the above command will output the object successfully: ``` Person { id: URL "https://indieweb.social/users/tchambers", attachments: [ PropertyValue { name: "Indieweb Site", value: 'Technologist, writer, admin of indieweb.social. Fascinated by how new politics impacts technology"... 346 more characters, url: URL "https://indieweb.social/@tchambers", preferredUsername: "tchambers", publicKey: CryptographicKey { id: URL "https://indieweb.social/users/tchambers#main-key", owner: URL "https://indieweb.social/users/tchambers", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: Uint8Array(3) [ 1, 0, 1 ], hash: { name: "SHA-256" } }, usages: [ "verify" ] } }, manuallyApprovesFollowers: false, inbox: URL "https://indieweb.social/users/tchambers/inbox", outbox: URL "https://indieweb.social/users/tchambers/outbox", following: URL "https://indieweb.social/users/tchambers/following", followers: URL "https://indieweb.social/users/tchambers/followers", endpoints: Endpoints { sharedInbox: URL "https://indieweb.social/inbox" }, discoverable: true, memorial: false, indexable: true } ``` ### `--first-knock`: First-knock spec for `-a`/`--authorized-fetch` *This option is available since Fedify 1.6.0.* The `--first-knock` option is used to specify which HTTP Signatures spec to try first when using the `-a`/`--authorized-fetch` option. The ActivityPub ecosystem currently uses different versions of HTTP Signatures specifications, and the [double-knocking] technique (trying one version, then falling back to another if rejected) allows for better compatibility across servers. Available options are: `draft-cavage-http-signatures-12` : [HTTP Signatures], which is obsolete but still widely adopted in the fediverse as of May 2025. `rfc9421` (default) : [RFC 9421]: HTTP Message Signatures, which is the final revision of the specification and is recommended, but not yet widely adopted in the fediverse as of June 2025. If the first signature attempt fails, Fedify will automatically try the other specification format, implementing the [double-knocking] technique described in the [ActivityPub HTTP Signatures] specification. [double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions [HTTP Signatures]: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 [RFC 9421]: https://www.rfc-editor.org/rfc/rfc9421 [ActivityPub HTTP Signatures]: https://swicg.github.io/activitypub-http-signature/ ### `--tunnel-service`: Tunneling service for `-a`/`--authorized-fetch` The `--tunnel-service` option is used to specify which tunneling service to use when using the `-a`/`--authorized-fetch` option. The authorized fetch feature requires a temporary server to be exposed to the public internet, and this option allows you to choose the tunneling service. Available services can be found in the output of the `fedify lookup --help` command. For example, to use fedify.com.es as the tunneling service: ```sh fedify lookup --authorized-fetch --tunnel-service fedify.com.es @user@example.com ``` ### `-u`/`--user-agent`: Custom `User-Agent` header *This option is available since Fedify 1.3.0.* By default, the `fedify lookup` command sends the `User-Agent` header with the value `Fedify/1.3.0 (Deno/2.0.4)` (version numbers may vary). You can specify a custom `User-Agent` header by using the `-u`/`--user-agent` option. For example, to send the `User-Agent` header with the value `MyApp/1.0`, run the below command: ```sh fedify lookup --user-agent MyApp/1.0 @fedify@hollo.social ``` ### `-p`/`--allow-private-address`: Allow private IP addresses URLs explicitly provided on the command line always allow private or localhost addresses, so local servers can be looked up without any extra flags: ```sh fedify lookup http://localhost:8000/users/alice ``` The `-p`/`--allow-private-address` option additionally allows private addresses for URLs discovered during traversal or recursive object fetches. It only affects discovered URLs used by [`-t`/`--traverse`](#t-traverse-traverse-the-collection) and [`--recurse`](#recurse-recurse-through-object-relationships), since URLs embedded in remote responses are otherwise rejected to mitigate SSRF attacks against private addresses. Recursive JSON-LD `@context` URLs are still blocked even when this option is enabled. ```sh fedify lookup --traverse --allow-private-address http://localhost:8000/users/alice/outbox fedify lookup --recurse=replyTarget --allow-private-address http://localhost:8000/notes/1 ``` ### `-s`/`--separator`: Output separator *This option is available since Fedify 1.3.0.* You can specify the separator between the outputs when looking up multiple objects at once by using the `-s`/`--separator` option. For example, to use the separator `====` between the outputs, run the below command: ```sh fedify lookup -s ==== @fedify@hollo.social @hongminhee@fosstodon.org ``` It does not affect the output when looking up a single object. > \[!TIP] > The separator is also used when looking up a collection object with the > [`-t`/`--traverse`](#t-traverse-traverse-the-collection) option. ### `--reverse`: Reverse output order *This option is available since Fedify 2.1.0.* The `--reverse` option reverses the output order of fetched results. It affects output order only, and does not change lookup semantics. ```sh fedify lookup @fedify@hollo.social @hongminhee@fosstodon.org --reverse fedify lookup --traverse https://fosstodon.org/users/hongminhee/outbox --reverse fedify lookup --recurse=replyTarget https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf --reverse ``` When using `--reverse`, `fedify lookup` buffers results before printing. This may increase memory usage for large traversals or long recursion chains. ### `-o`/`--output`: Output file path *This option is available since Fedify 1.8.0.* You can specify the output file path to save lookup results, instead of printing results to stdout. For example, to save the retrieved information about the specified objects to a given path, run the command below: ```sh fedify lookup -o actors.json @fedify@hollo.social @hongminhee@fosstodon.org ``` ### `-T`/`--timeout`: Request timeout *This option is available since Fedify 1.9.0.* You can specify the request timeout duration by using the `-T`/`--timeout` option. The duration should be an integer in seconds. By default, there is no timeout. For example, to set the request timeout to 10 seconds, run the below command: ```sh fedify lookup --timeout 10 @fedify@hollo.social ``` ## `fedify inbox`: Ephemeral inbox server The `fedify inbox` command is used to spin up an ephemeral server that serves the ActivityPub inbox with a one-time actor, through a short-lived public DNS with HTTPS. This is useful when you want to test and debug the outgoing activities of your server. To start an ephemeral inbox server, run the below command: ```sh fedify inbox ``` If it goes well, you will see the output like the below (without termination; press ^C to stop the server): ``` ✔ The ephemeral ActivityPub server is up and running: https://f9285cf4974c86.lhr.life/ ✔ Sent follow request to @faguscus_dashirniul@activitypub.academy. ╭───────────────┬─────────────────────────────────────────╮ │ Actor handle: │ i@f9285cf4974c86.lhr.life │ ├───────────────┼─────────────────────────────────────────┤ │ Actor URI: │ https://f9285cf4974c86.lhr.life/i │ ├───────────────┼─────────────────────────────────────────┤ │ Actor inbox: │ https://f9285cf4974c86.lhr.life/i/inbox │ ├───────────────┼─────────────────────────────────────────┤ │ Shared inbox: │ https://f9285cf4974c86.lhr.life/inbox │ ╰───────────────┴─────────────────────────────────────────╯ ``` Although the given URIs and handle are short-lived, they are anyway publicly dereferenceable until the server is terminated. You can use these URIs and handle to test and debug the outgoing activities of your server. If any incoming activities are received, the server will log them to the console: ``` ╭────────────────┬─────────────────────────────────────╮ │ Request #: │ 2 │ ├────────────────┼─────────────────────────────────────┤ │ Activity type: │ Follow │ ├────────────────┼─────────────────────────────────────┤ │ HTTP request: │ POST /i/inbox │ ├────────────────┼─────────────────────────────────────┤ │ HTTP response: │ 202 │ ├────────────────┼─────────────────────────────────────┤ │ Details │ https://f9285cf4974c86.lhr.life/r/2 │ ╰────────────────┴─────────────────────────────────────╯ ``` You can also see the details of the incoming activities by visiting the `/r/:id` endpoint of the server in your browser: ![The details of the incoming activities](cli/fedify-inbox-web.png) ### `-f`/`--follow`: Follow an actor The `-f`/`--follow` option is used to follow an actor. You can specify the actor handle or URI to follow. For example, to follow the actor with the handle *@john@doe.com* and *@jane@doe.com*, run the below command: ```sh fedify inbox -f @john@doe.com -f @jane@doe.com ``` > \[!NOTE] > Although `-f`/`--follow` option sends `Follow` activities to the specified > actors, it does not guarantee that they will accept the follow requests. > If the actors accept the follow requests, you will receive the `Accept` > activities in the inbox server, and the server will log them to the console: > > ``` > ╭────────────────┬─────────────────────────────────────╮ > │ Request #: │ 0 │ > ├────────────────┼─────────────────────────────────────┤ > │ Activity type: │ Accept │ > ├────────────────┼─────────────────────────────────────┤ > │ HTTP request: │ POST /i/inbox │ > ├────────────────┼─────────────────────────────────────┤ > │ HTTP response: │ 202 │ > ├────────────────┼─────────────────────────────────────┤ > │ Details │ https://f9285cf4974c86.lhr.life/r/0 │ > ╰────────────────┴─────────────────────────────────────╯ > ``` ### `-a`/`--accept-follow`: Accept follow requests The `-a`/`--accept-follow` option is used to accept follow requests from actors. You can specify the actor handle or URI to accept follow requests. Or you can accept all follow requests by specifying the wildcard `*`. For example, to accept follow requests from the actor with the handle *@john@doe.com* and *@jane@doe.com*, run the below command: ```sh fedify inbox -a @john@doe.com -a @jane@doe.com ``` When the follow requests are received from the specified actors, the server will immediately send the `Accept` activities to them. Otherwise, the server will just log the `Follow` activities to the console without sending the `Accept` activities. ### `-T`/`--no-tunnel`: Local server without tunneling The `-T`/`--no-tunnel` option is used to disable the tunneling feature of the inbox server. By default, the inbox server tunnels the local server to the public internet, so that the server is accessible from the outside. If you want to disable the tunneling feature, run the below command: ```sh fedify inbox --no-tunnel ``` It would be useful when you want to test the server locally but are worried about the security implications of exposing the server to the public internet. > \[!NOTE] > If you disable the tunneling feature, the ephemeral ActivityPub instance will > be served via HTTP instead of HTTPS. ### `--tunnel-service`: Tunneling service The `--tunnel-service` option is used to specify which tunneling service to use for exposing the ephemeral inbox server to the public internet. Available services can be found in the output of the `fedify inbox --help` command. For example, to use fedify.com.es as the tunneling service: ```sh fedify inbox --tunnel-service fedify.com.es ``` > \[!NOTE] > This option cannot be used together with `-T`/`--no-tunnel`. ### `-A`/`--authorized-fetch`: Authorized fetch mode The `-A`/`--authorized-fetch` option enables authorized fetch mode on the ephemeral inbox server. When enabled, incoming requests without valid HTTP Signatures are rejected with `401 Unauthorized`. ```sh fedify inbox --authorized-fetch ``` This is useful for testing whether your ActivityPub server correctly signs its outgoing requests, as many instances in the fediverse (such as Mastodon with secure mode) require valid signatures. ## `fedify nodeinfo`: Visualizing an instance's NodeInfo *This command is available since Fedify 1.8.0.* *The `fedify node` alias is deprecated and will be removed in version 2.0.0.* ![The result of fedify lookup fosstodon.org. The NodeInfo document is visualized along with the favicon.](cli/fedify-nodeinfo.png) The `fedify nodeinfo` command fetches the given instance's [NodeInfo] document and visualizes it in [`neofetch`]-style. The argument can be either a bare hostname or a full URL. > \[!TIP] > Not all instances provide the NodeInfo document. If the given instance does > not provide the NodeInfo document, the command will output an error message. [NodeInfo]: https://nodeinfo.diaspora.software/ [`neofetch`]: https://github.com/dylanaraps/neofetch ### `-r`/`--raw`: Raw JSON > \[!NOTE] > This option is mutually exclusive with `-b`/`--best-effort`, `--no-favicon` > and `-m`/`--metadata`. You can also output the fetched NodeInfo document in the raw JSON format by using the `-r`/`--raw` option: ```sh fedify nodeinfo --raw fosstodon.org ``` The output will be like the below: ```json { "version": "2.0", "software": { "name": "mastodon", "version": "4.4.2" }, "protocols": [ "activitypub" ], "services": { "outbound": [], "inbound": [] }, "usage": { "users": { "total": 62444, "activeMonth": 8788, "activeHalfyear": 14000 }, "localPosts": 4335412 }, "openRegistrations": false, "metadata": { "nodeName": "Fosstodon", "nodeDescription": "Fosstodon is an invite only Mastodon instance that is open to those who are interested in technology; particularly free & open source software.\r\n\r\nIf you wish to join, contact us for an invite." } } ``` ### `-b`/`--best-effort`: Parsing with best effort > \[!NOTE] > This option is mutually exclusive with `-r`/`--raw`. The `-b`/`--best-effort` option is used to parse the NodeInfo document with best effort. If the NodeInfo document is not well-formed, the option will try to parse it as much as possible. ### `--no-favicon`: Disabling favicon fetching > \[!NOTE] > This option is mutually exclusive with `-r`/`--raw`. The `--no-favicon` option is used to disable fetching the favicon of the instance. ### `-m`/`--metadata`: Showing metadata > \[!NOTE] > This option is mutually exclusive with `-r`/`--raw`. The `-m`/`--metadata` option is used to show the extra metadata of the NodeInfo, i.e., the `metadata` field of the document. ### `-u`/`--user-agent`: Custom `User-Agent` header *This option is available since Fedify 1.3.0.* By default, the `fedify nodeinfo` command sends the `User-Agent` header with the value `Fedify/1.3.0 (Deno/2.0.4)` (version numbers may vary). You can specify a custom `User-Agent` header by using the `-u`/`--user-agent` option. For example, to send the `User-Agent` header with the value `MyApp/1.0`, run the below command: ```sh fedify nodeinfo --user-agent MyApp/1.0 mastodon.social ``` ## `fedify relay`: Running an ephemeral ActivityPub relay server *This command is available since Fedify 2.0.0.* The `fedify relay` command is used to spin up an ephemeral ActivityPub relay server that forwards activities between federated instances. The server can use either [Mastodon] or [LitePub] compatible relay protocol. To start a relay server, you must specify the relay protocol using the `-p`/`--protocol` option: ```sh fedify relay -p mastodon ``` If it goes well, you will see the output like the below (without termination; press ^C to stop the server): ``` ✔ Relay server is running: https://f9285cf4974c86.lhr.life/ ╭───────────────┬──────────────────────────────────────────╮ │ Actor URI: │ https://f9285cf4974c86.lhr.life/actor │ ├───────────────┼──────────────────────────────────────────┤ │ Shared Inbox: │ https://f9285cf4974c86.lhr.life/inbox │ ├───────────────┼──────────────────────────────────────────┤ │ Protocol: │ mastodon │ ├───────────────┼──────────────────────────────────────────┤ │ Name: │ Fedify Relay │ ├───────────────┼──────────────────────────────────────────┤ │ Storage: │ in-memory │ ╰───────────────┴──────────────────────────────────────────╯ Press ^C to stop the relay server. ``` By default, the relay server is tunneled to the public internet so that external instances can connect to it. [Mastodon]: https://joinmastodon.org/ [LitePub]: https://litepub.social/ ### `-p`/`--protocol`: Relay protocol The `-p`/`--protocol` option specifies which relay protocol to use. This option is required. The available options are: * `mastodon`: [Mastodon]-compatible relay protocol * `litepub`: [LitePub]-compatible relay protocol ### `--persistent`: Persistent storage The `--persistent` option specifies a path to a SQLite database file for persistent storage. If not specified, the relay uses in-memory storage which is lost when the server stops. ```sh fedify relay -p mastodon --persistent relay.db ``` ### `-P`/`--port`: Local port The `-P`/`--port` option specifies the local port to listen on. By default, it listens on port 8000. ```sh fedify relay -p mastodon -P 3000 ``` ### `-n`/`--name`: Relay display name The `-n`/`--name` option specifies the relay display name. By default, it is `Fedify Relay`. ```sh fedify relay -p mastodon -n "My Relay" ``` ### `-a`/`--accept-follow`: Accept follow requests The `-a`/`--accept-follow` option specifies which actors' follow requests to accept. The argument can be either an actor URI, a handle, or a wildcard (`*`). This option can be specified multiple times. If a wildcard is specified, all follow requests will be accepted. ```sh fedify relay -p mastodon -a @john@doe.com -a @jane@doe.com ``` ### `-r`/`--reject-follow`: Reject follow requests The `-r`/`--reject-follow` option specifies which actors' follow requests to reject. The argument can be either an actor URI, a handle, or a wildcard (`*`). This option can be specified multiple times. If a wildcard is specified, all follow requests will be rejected. ```sh fedify relay -p mastodon -r @spammer@example.com ``` > \[!NOTE] > When both `-a`/`--accept-follow` and `-r`/`--reject-follow` are specified, > a follow request is accepted only if the actor matches the accept list and > does *not* match the reject list. ### `-T`/`--no-tunnel`: Local server without tunneling The `-T`/`--no-tunnel` option disables the tunneling feature of the relay server. By default, the relay server tunnels the local server to the public internet for external access. ```sh fedify relay -p mastodon --no-tunnel ``` > \[!NOTE] > If you disable the tunneling feature, the relay server will be served via > HTTP instead of HTTPS. ### `--tunnel-service`: Tunneling service The `--tunnel-service` option specifies which tunneling service to use for exposing the relay server to the public internet. Available services can be found in the output of the `fedify relay --help` command. For example, to use fedify.com.es as the tunneling service: ```sh fedify relay -p mastodon --tunnel-service fedify.com.es ``` > \[!NOTE] > This option cannot be used together with `-T`/`--no-tunnel`. ## `fedify tunnel`: Exposing a local HTTP server to the public internet *This command is available since Fedify 0.13.0.* The `fedify tunnel` command is used to expose a local HTTP server to the public internet using a secure tunnel. It is useful when you want to test your local ActivityPub server with the real-world ActivityPub instances. To create a tunnel for a local server, for example, running on port 3000, run the below command: ```sh fedify tunnel 3000 ``` > \[!TIP] > > The HTTP requests through the tunnel have the following headers: > > `X-Forwarded-For` > : The IP address of the client. > > `X-Forwarded-Proto` > : The protocol of the client, either `http` or `https`. > > `X-Forwarded-Host` > : The host of the public tunnel server. > > If you want to make your local server aware of these headers, you can use > the [x-forwarded-fetch] middleware in front of your HTTP server. > > For more information, see [*How the `Federation` object recognizes the domain > name* > section](./manual/federation.md#how-the-federation-object-recognizes-the-domain-name) > in the *Federation* document. [x-forwarded-fetch]: https://github.com/dahlia/x-forwarded-fetch ### `-s`/`--service`/`--tunnel-service`: The tunneling service The `-s`/`--service` option is used to specify the tunneling service to use. The `--tunnel-service` is an alias for consistency with other commands that support tunneling. Available services can be found in the output of the `fedify tunnel --help` command. For example, to use fedify.com.es, run the below command: ```sh fedify tunnel --service fedify.com.es 3000 ``` ## `fedify webfinger`: Looking up a WebFinger resource *This command is available since Fedify 1.8.0.* The `fedify webfinger` command is used to look up a WebFinger resource by resource URI or handle. [WebFinger] is a protocol that allows discovery of information about people and other entities on the Internet using simple web requests. This command is useful for debugging and testing WebFinger implementations. To look up a WebFinger resource, for example, for a user handle, run the below command: ```sh fedify webfinger @username@domain.com ``` The output will be like the below: ```json { "subject": "acct:username@domain.com", "aliases": [ "https://domain.com/@username", "https://domain.com/users/username" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://domain.com/@username" }, { "rel": "self", "type": "application/activity+json", "href": "https://domain.com/users/username" } ] } ``` You can also look up a WebFinger resource by its URL. For example, the below command looks up a WebFinger resource by http or acct URL: ```sh fedify webfinger https://domain.com/@username fedify webfinger acct:username@domain.com ``` Or, you can also look up multiple WebFinger resources at once. For example, the below command looks up multiple WebFinger resources: ```sh fedify webfinger @user1@domain.com https://domain.com/@username acct:username@domain.com ``` The outputs will be displayed sequentially, each preceded by a success message indicating which resource was found. [WebFinger]: https://tools.ietf.org/html/rfc7033 ### `-a`/`--user-agent`: Custom `User-Agent` header By default, the `fedify webfinger` command sends the `User-Agent` header with the value `Fedify/1.8.0 (Deno/2.4.0)` (version numbers may vary). You can specify a custom `User-Agent` header by using the `-a`/`--user-agent` option. For example, to send the `User-Agent` header with the value `MyApp/1.0`, run the below command: ```sh fedify webfinger --user-agent MyApp/1.0 @username@domain.com ``` ### `-p`/`--allow-private-address`: Allow private IP addresses The `-p`/`--allow-private-address` option is used to allow private IP addresses. If you want to allow private IP addresses, run the below command: ```sh fedify webfinger --allow-private-address @username@localhost ``` Mostly useful for testing purposes. *Do not use this in production.* ### `--max-redirection`: Maximum number of redirections The `--max-redirection` option is used to control the maximum number of redirections allowed during WebFinger lookups. By default, it is set to 5. If you want to set a custom limit, run the below command: ```sh # Use default redirection limit (5) fedify webfinger @user@example.com # Set custom redirection limit fedify webfinger @user@example.com --max-redirection 3 # Disable redirections entirely fedify webfinger @user@example.com --max-redirection 0 ``` ## Shell completions The `fedify` command supports shell completions for [Bash](#bash), [Fish](#fish), and [Zsh](#zsh). ### Bash To enable Bash completions add the following line to your profile file (*~/.bashrc*, *~/.bash\_profile*, or *~/.profile*): ```bash source <(fedify completions bash) ``` ### Fish To enable Fish completions add the following line to your profile file (*~/.config/fish/config.fish*): ```fish source (fedify completions fish | psub) ``` ### Zsh To enable Zsh completions add the following line to your profile file (*~/.zshrc*): ```zsh source <(fedify completions zsh) ``` --- --- url: /tutorial/basics.md description: >- This tutorial provides a step-by-step guide to building a small federated server with the Fedify framework. It is intended for developers who want to build a federated server with the Fedify framework. --- # Learning the basics of Fedify > \[!TIP] > > This tutorial is also available in the following languages: [한국어] (Korean). In this tutorial, we will build a small federated server that can only accept follow requests to understand the basic concepts of the Fedify framework. Despite its simplicity, it will cover the key features of the ActivityPub protocol and the Fedify framework, such as actors, sending and receiving activities, and the inbox. This tutorial will not use the quick start project template created by the [`fedify init`](../cli.md#fedify-init-initializing-a-fedify-project) command. Instead, we will start from scratch to understand how the Fedify framework works without any boilerplate code. As prerequisite knowledge, you should have a basic understanding of JavaScript, command-line interfaces, and minimum experience with building web server apps. However, it's perfectly fine if you're not familiar with the ActivityPub protocol or TypeScript; we will explain them as we go. [한국어]: https://hackmd.io/@OKSUchun/fedify-tutorial-ko ## What we will build We will build a small federated server which can accept follow requests from other servers. The server will have a single actor (i.e., account) and an inbox to receive follow requests. When the server receives a follow request, it will send an accept activity back to the sender. The home page of the server will list the actor's followers. ## Creating a new project > \[!TIP] > We recommend using [Deno] or [Bun] (which are TypeScript-first) for the best > experience, but you can use [Node.js] if you prefer. Let's create a new project directory and initialize a new project: ::: code-group ```sh [Deno] mkdir follow-server cd follow-server/ echo '{ "unstable": ["kv"] }' > deno.json deno add jsr:@fedify/fedify ``` ```sh [Bun] mkdir follow-server cd follow-server/ echo '{ "type": "module" }' > package.json bun add @fedify/fedify @deno/kv ``` ```sh [Node.js] mkdir follow-server cd follow-server/ echo '{ "type": "module" }' > package.json npm add -D typescript tsx @types/node npm add @fedify/fedify @deno/kv @hono/node-server ``` ::: The above commands will create a *deno.json* (in case of Deno) or *package.json* (in case of Node.js or Bun) in the project directory with the following content (formatted for readability):\[^2] ::: code-group ```json [Deno] { "unstable": ["kv"], "imports": { "@fedify/fedify": "jsr:@fedify/fedify@^1.1.0" } } ``` ```json [Bun] { "type": "module", "dependencies": { "@deno/kv": "^0.8.1", "@fedify/fedify": "^1.1.0" } } ``` ```json [Node.js] { "type": "module", "devDependencies": { "@types/node": "^20.12.7", "tsx": "^4.8.2", "typescript": "^5.4.5" }, "dependencies": { "@deno/kv": "^0.8.1", "@fedify/fedify": "^1.1.0", "@hono/node-server": "^1.11.1" } } ``` ::: > \[!NOTE] > If you use Deno 2.7.0 or later, you do not need any extra setting for > [`Temporal`]. If you use Deno earlier than 2.7.0, add `"temporal"` to the > [`"unstable"`] field in *deno.json*. > \[!NOTE] > In Bun and Node.js, we recommend adding [`"type": "module"`] to the > *package.json* file to use [ESM] imports. While Fedify also supports > [CommonJS] for legacy compatibility, ESM is the preferred and recommended > approach. > \[!TIP] > Do you wonder why we need to add *[tsx]* and *@types/node* in the case of > Node.js? It's because Fedify is written in TypeScript, and > Node.js doesn't support TypeScript out of the box. By adding *tsx* and > *@types/node*, you can write TypeScript code in Node.js without any hassle. \[^2]: The actual version number may vary depending on the latest version of the Fedify framework as of reading this tutorial. [Deno]: https://deno.com/ [Bun]: https://bun.sh/ [Node.js]: https://nodejs.org/ [`Temporal`]: https://tc39.es/proposal-temporal/docs/ [`"unstable"`]: https://docs.deno.com/runtime/manual/tools/unstable_flags/#configuring-flags-in-deno.json [`"type": "module"`]: https://nodejs.org/api/packages.html#type [ESM]: https://nodejs.org/api/esm.html [CommonJS]: https://nodejs.org/docs/latest/api/modules.html [tsx]: https://tsx.is/ ## Creating the server Now, let's create the server script. Create a new file named *server.ts* in the project directory and write the following code: ::: code-group ```typescript twoslash [Deno] Deno.serve(request => new Response("Hello, world", { headers: { "Content-Type": "text/plain" } }) ); ``` ```typescript twoslash [Bun] import "bun"; // ---cut-before--- Bun.serve({ port: 8000, fetch(request) { return new Response("Hello, world", { headers: { "Content-Type": "text/plain" } }); } }); ``` ```typescript twoslash [Node.js] import { serve } from "@hono/node-server"; serve({ port: 8000, fetch(request) { return new Response("Hello, world", { headers: { "Content-Type": "text/plain" } }); } }); ``` ::: It's a simple HTTP server that responds with Hello, world to any incoming request. You can run the server by executing the following command: ::: code-group ```sh [Deno] deno run -A server.ts ``` ```sh [Bun] bun server.ts ``` ```sh [Node.js] node --import tsx server.ts ``` :::: Now, open your web browser and navigate to . You should see the Hello, world message. As you can guess, [`Deno.serve()`] (in case of Deno), [`Bun.serve()`] (in case of Bun), and [`serve()`] (in case of Node.js) are functions to create an HTTP server. They take a callback function that receives a [`Request`] object and returns a [`Response`] object. The `Response` object is sent back to the client. This server is not federated yet, but it's a good starting point to build a federated server. [`Deno.serve()`]: https://docs.deno.com/api/deno/~/Deno.serve [`Bun.serve()`]: https://bun.sh/docs/api/http#bun-serve [`serve()`]: https://github.com/honojs/node-server?tab=readme-ov-file#usage [`Request`]: https://developer.mozilla.org/en-US/docs/Web/API/Request [`Response`]: https://developer.mozilla.org/en-US/docs/Web/API/Response ## `Federation` object To make the server federated, we need to use the `Federation` object from the Fedify framework. The `Federation` object is the main object that handles ActivityPub activities and actors. Let's modify the server script to use the `Federation` object: ```typescript twoslash [server.ts] import { createFederation, MemoryKvStore } from "@fedify/fedify"; const federation = createFederation({ kv: new MemoryKvStore(), }); ``` In the above code, we import the `createFederation()` function from the Fedify framework to create a new `Federation` object. We pass an object to the `createFederation()` function, which is the configuration object. The `kv` property is a key–value store that is used to store several internal data of the `Federation` object. We use the `MemoryKvStore` to open a key–value store. > \[!IMPORTANT] > Since `MemoryKvStore` is for testing and development purposes, you should > use a persistent key–value store like `DenoKvStore` (in Deno) or > [`RedisKvStore`] (from [`@fedify/redis`] package) or [`PostgresKvStore`] > (from [`@fedify/postgres`] package) or [`MysqlKvStore`] > (from [`@fedify/mysql`] package) for production use. > > For further details, see the [*Key–value store* section](../manual/kv.md). Then, we pass the incoming `Request` to the `Federation.fetch()` method: ::: code-group ```typescript{2} twoslash [Deno] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- Deno.serve( request => federation.fetch(request, { contextData: undefined }) ); ``` ```typescript{4} twoslash [Bun] import "bun"; import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- Bun.serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ``` ```typescript{6} twoslash [Node.js] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- import { serve } from "@hono/node-server"; serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ``` ::: The `Federation.fetch()` method takes the incoming `Request` and a few options. In this case, we pass `undefined` as the `contextData` because we don't need to share any context data here. > \[!TIP] > The `Federation` object is a generic class that takes a type parameter named > `TContextData`. The `TContextData` type is the type of the context data, > which is shared among the actor dispatcher, inbox listener, and other > callback functions. The `TContextData` type can be `void` if you don't > need to share any context data, but it can be any type if you need to share > context data. > > See [*`TContextData`* section](../manual/federation.md#tcontextdata) for more > details. The `Federation` object is now ready to handle incoming requests. Let's move on to the next step. > \[!TIP] > Although it's not mandatory, we highly recommend to set up loggers to see > what's going on in the server. To set up loggers, you need to install > [LogTape] first: > > ::: code-group > > ```sh [Deno] > deno add jsr:@logtape/logtape > ``` > > ```sh [Bun] > bun add @logtape/logtape > ``` > > ```sh [Node.js] > npm add @logtape/logtape > ``` > > ::: > > Then, you can set up loggers by calling [`configure()`] function at the > top of the *server.ts* file: > > ```typescript twoslash [server.ts] > import { configure, getConsoleSink } from "@logtape/logtape"; > > await configure({ > sinks: { console: getConsoleSink() }, > filters: {}, > loggers: [ > { category: "fedify", sinks: ["console"], lowestLevel: "info" }, > ], > }); > ``` [`RedisKvStore`]: https://jsr.io/@fedify/redis/doc/kv/~/RedisKvStore [`@fedify/redis`]: https://github.com/fedify-dev/fedify/tree/main/packages/redis [`PostgresKvStore`]: https://jsr.io/@fedify/postgres/doc/kv/~/PostgresKvStore [`@fedify/postgres`]: https://github.com/fedify-dev/fedify/tree/main/packages/postgres [`MysqlKvStore`]: https://jsr.io/@fedify/mysql/doc/kv/~/MysqlKvStore [`@fedify/mysql`]: https://github.com/fedify-dev/fedify/tree/main/packages/mysql [LogTape]: https://logtape.org/ [`configure()`]: https://jsr.io/@logtape/logtape/doc/~/configure ## Actor dispatcher The `Federation` object needs an actor dispatcher to handle incoming activities 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 identifier as *me* (you can choose any identifier you like). Let's create an actor dispatcher for our server: ::: code-group ```typescript{7-16} twoslash [Deno] import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = createFederation({ kv: new MemoryKvStore(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); Deno.serve( request => federation.fetch(request, { contextData: undefined }) ); ``` ```typescript{7-16} twoslash [Bun] import "bun"; // ---cut-before--- import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = createFederation({ kv: new MemoryKvStore(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); Bun.serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ``` ```typescript{8-17} twoslash [Node.js] import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; import { serve } from "@hono/node-server"; const federation = createFederation({ kv: new MemoryKvStore(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ``` ::: In the above code, we use the `~Federatable.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 identifier. The callback function should return an `Actor` object, a `Tombstone` if the account is gone, 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: ::: code-group ```sh [Deno] deno run -A server.ts ``` ```sh [Bun] bun run server.ts ``` ```sh [Node.js] node --import tsx server.ts ``` ::: Now, open a new terminal session and run the following command to query the actor:\[^3] ```sh curl "http://localhost:8000/.well-known/webfinger?resource=acct:me@localhost:8000" ``` The response should look like this (formatted for readability): ```json { "subject": "acct:me@localhost:8000", "aliases": [ "http://localhost:8000/users/me" ], "links": [ { "rel": "self", "href": "http://localhost:8000/users/me", "type": "application/activity+json" }, { "rel": "http://webfinger.net/rel/profile-page", "href": "http://localhost:8000/" } ] } ``` The above response shows that the actor *me* is found on the server, and its canonical URI is `http://localhost:8000/users/me`. Let's see what happens when we query the actor's canonical URI (note that the request contains the `Accept: application/activity+json` header):\[^3] ```sh curl -H"Accept: application/activity+json" http://localhost:8000/users/me ``` The response should look like this (formatted for readability): ```json { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "toot": "http://joinmastodon.org/ns#", "discoverable": "toot:discoverable", "suspended": "toot:suspended", "memorial": "toot:memorial", "indexable": "toot:indexable" } ], "id": "http://localhost:8000/users/me", "type": "Person", "name": "Me", "preferredUsername": "me", "summary": "This is me!", "url": "http://localhost:8000/" } ``` The response shows the actor *me* with its basic information. Requests we've made so far are what ordinary ActivityPub implementations do behind the scenes when they try to look up an actor (i.e., account). For example, when you search with a full handle of an actor on Mastodon, a Mastodon instance queries the WebFinger endpoint to find the actor's canonical URI and then queries the canonical URI to get the actor's profile. However, you still can't follow the actor *me* from other ActivityPub servers, because our server is not exposed to the public internet yet. We will cover this in the next section. > \[!TIP] > If you are curious about the actor dispatcher further, see the > [*Actor dispatcher* section](../manual/actor.md) in the manual. \[^3]: It assumes that you have [curl] installed on your system. If you don't have curl, you need to install it first. [curl]: https://curl.se/ ## Exposing the server to the public internet To expose the server to the public internet, generally, you need a proper domain name configured with a DNS record pointing to your server's IP address. However, for local development, you can use the [`fedify tunnel`](../cli.md#fedify-tunnel-exposing-a-local-http-server-to-the-public-internet) command to temporarily expose your server to the public internet. To use `fedify tunnel`, first make sure you have the `fedify` command installed. If you haven't installed it yet, please follow the installation instructions in the [*`fedify`: CLI toolchain* section](../cli.md#installation). After installing the `fedify` command, you can expose your server to the public internet by running the following command (note that you need to run this command in a new terminal session so that the server is still running): ```sh fedify tunnel 8000 ``` The above command will expose your server to the public internet. You will see a public URL that you can use to access your server from the internet, e.g.: ```console ✔ Your local server at 8000 is now publicly accessible: https://e875a03fc2a35b.lhr.life/ Press ^C to close the tunnel. ``` > \[!NOTE] > Do not rely on `fedify tunnel` for production use. It is only for local > development. The domain name it provides is temporary and will change every > time you restart the command. However, since `fedify tunnel` is a reverse proxy between the public internet and your server, the server is still not aware of the fact that it is exposed to the public internet through HTTPS. In order to make the server aware of it, you need to place a [x-forwarded-fetch] middleware in front of the `Federation`. To do this, you need to install the package: ::: code-group ```sh [Deno] deno add jsr:@hongminhee/x-forwarded-fetch ``` ```sh [Bun] bun add x-forwarded-fetch ``` ```sh [Node.js] npm add x-forwarded-fetch ``` ::: Then, import the package and place the `behindProxy()` middleware in front of the `Federation.fetch()` method: ::: code-group ```typescript{1,4} twoslash [Deno] // @noErrors: 2300 2307 import { behindProxy } from "x-forwarded-fetch"; import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- import { behindProxy } from "@hongminhee/x-forwarded-fetch"; Deno.serve( behindProxy(request => federation.fetch(request, { contextData: undefined })) ); ``` ```typescript{1,5} twoslash [Bun] import "bun"; import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- import { behindProxy } from "x-forwarded-fetch"; Bun.serve({ port: 8000, fetch: behindProxy((request) => federation.fetch(request, { contextData: undefined })), }); ``` ```typescript{2,6} twoslash [Node.js] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- import { serve } from "@hono/node-server"; import { behindProxy } from "x-forwarded-fetch"; serve({ port: 8000, fetch: behindProxy((request) => federation.fetch(request, { contextData: undefined })), }); ``` ::: To restart the server, you need to stop the server by pressing ^C and then run the server again: ::: code-group ```sh [Deno] deno run -A server.ts ``` ```sh [Bun] bun run server.ts ``` ```sh [Node.js] node --import tsx server.ts ``` ::: Let's query the actor *me* again, but this time with the public URL (change the domain name to the one `fedify tunnel` provides you):\[^3] ```sh curl "https://e875a03fc2a35b.lhr.life/.well-known/webfinger?resource=acct:me@e875a03fc2a35b.lhr.life" curl -H"Accept: application/activity+json" https://e875a03fc2a35b.lhr.life/users/me ``` Does it work? If so, congratulations! Your server is now exposed to the public internet. However, you still can't follow the actor *me* from other ActivityPub servers because our server doesn't accept follow requests yet. > \[!TIP] > There are alternatives to `fedify tunnel`. See also the [*Exposing a local > server to the public* > section](../manual/test.md#exposing-a-local-server-to-the-public) in the > manual for more details. [x-forwarded-fetch]: https://github.com/dahlia/x-forwarded-fetch ## Inbox listener In ActivityPub, an [inbox] is where an actor receives incoming activities from other actors. To accept follow requests from other servers, we need to register an inbox listener for the actor *me*. Let's register an inbox listener for the actor *me*. First, every activity is represented as a class in the Fedify framework. The `Follow` class represents the `Follow` activity. We will use the `Follow` class to handle incoming follow requests: ```typescript twoslash [server.ts] import { createFederation, MemoryKvStore } from "@fedify/fedify"; import { Follow, Person } from "@fedify/vocab"; // [!code highlight] ``` Then, we register an inbox listener for the `Follow` activity: ```typescript{3-11} twoslash [server.ts] import { type Federation } from "@fedify/fedify"; import { Follow } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- federation .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.identifier !== "me") return; const follower = await follow.getActor(ctx); console.debug(follower); }); ``` Yet, the above code doesn't do anything other than printing the follower's information to the console. We will send an accept activity back to the sender when we receive a follow request in the next section, but here we just see who the follower is. In order to test the inbox listener, the actor *me* needs to point out its inbox URI in the actor object. Let's modify the actor dispatcher to include the inbox URI: ```typescript twoslash [server.ts] import { type Federation } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), inbox: ctx.getInboxUri(identifier), // Inbox URI // [!code highlight] }); }); ``` Now, let's restart the server and look up the actor *me* again, but this time on your Mastodon instance (or any other ActivityPub server you have account on). To look up the actor *me*, you need to search with the full handle of the actor (i.e., *@me@your-server-domain*): ![Search results on Mastodon; the actor @me shows up.](./basics/search-result.png) When you find the actor *me*, click on the actor's profile and then click on the *Follow* button. You should see your Mastodon account sending a follow request to the actor *me* on the console where the server is running: ``` Person { id: URL "...", name: "...", ... omitted for brevity ... } ``` However, the server doesn't send an accept activity back to the sender yet. We will cover this in the next section. > \[!TIP] > If you are curious about the inbox listener further, see the > [*Inbox listeners* section](../manual/inbox.md) in the manual. [inbox]: https://www.w3.org/TR/activitypub/#inbox ## Generating a key pair To send an activity, first, we need to generate a key pair for the actor *me* so that the server can sign the activity with the private key. Fortunately, Fedify provides helper functions to generate and export/import keys: ```typescript{3-5} twoslash [server.ts] import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, MemoryKvStore, } from "@fedify/fedify"; import { Follow, Person } from "@fedify/vocab"; ``` By the way, when should we generate a key pair? In general, you should generate a key pair when the actor is created. In our case, we generate a key pair when the actor *me* is dispatched for the first time. Then, we store the key pair in the key–value store so that the server can use the key pair later. The `~ActorCallbackSetters.setKeyPairsDispatcher()` method is used to set a key pairs dispatcher for the actor. The key pairs dispatcher is a function that is called when the key pairs of an actor is needed. Let's set a key pairs dispatcher for the actor *me*. `~ActorCallbackSetters.setKeyPairsDispatcher()` method should be chained after the `~Federatable.setActorDispatcher()` method: ::: code-group ```typescript{13-14,17-37} twoslash [Deno] import { exportJwk, generateCryptoKeyPair, importJwk, type Federation, } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- const kv = await Deno.openKv(); // Open the key–value store federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), 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(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { if (identifier != "me") return []; // Other than "me" is not found. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; }>(["key"]); if (entry == null || entry.value == null) { // Generate a new key pair at the first time: const { privateKey, publicKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); // Store the generated key pair to the Deno KV database in JWK format: await kv.set( ["key"], { privateKey: await exportJwk(privateKey), publicKey: await exportJwk(publicKey), } ); return [{ privateKey, publicKey }]; } // Load the key pair from the Deno KV database: const privateKey = await importJwk(entry.value.privateKey, "private"); const publicKey = await importJwk(entry.value.publicKey, "public"); return [{ privateKey, publicKey }]; }); ``` ```typescript{15-16,19-39} twoslash [Bun] // @noErrors: 2307 import { exportJwk, generateCryptoKeyPair, importJwk, type Federation, } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- import { serialize as encodeV8, deserialize as decodeV8 } from "node:v8"; import { openKv } from "@deno/kv"; // Open the key–value store: const kv = await openKv("kv.db", { encodeV8, decodeV8 }); federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), 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(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { if (identifier != "me") return []; // Other than "me" is not found. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; }>(["key"]); if (entry == null || entry.value == null) { // Generate a new key pair at the first time: const { privateKey, publicKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); // Store the generated key pair to the Deno KV database in JWK format: await kv.set( ["key"], { privateKey: await exportJwk(privateKey), publicKey: await exportJwk(publicKey), } ); return [{ privateKey, publicKey }]; } // Load the key pair from the Deno KV database: const privateKey = await importJwk(entry.value.privateKey, "private"); const publicKey = await importJwk(entry.value.publicKey, "public"); return [{ privateKey, publicKey }]; }); ``` ```typescript{15-16,19-39} twoslash [Node.js] import { exportJwk, generateCryptoKeyPair, importJwk, type Federation, } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- import { openKv } from "@deno/kv"; const kv = await openKv("kv.db"); // Open the key–value store federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), 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(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { if (identifier != "me") return []; // Other than "me" is not found. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; }>(["key"]); if (entry == null || entry.value == null) { // Generate a new key pair at the first time: const { privateKey, publicKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); // Store the generated key pair to the Deno KV database in JWK format: await kv.set( ["key"], { privateKey: await exportJwk(privateKey), publicKey: await exportJwk(publicKey), } ); return [{ privateKey, publicKey }]; } // Load the key pair from the Deno KV database: const privateKey = await importJwk(entry.value.privateKey, "private"); const publicKey = await importJwk(entry.value.publicKey, "public"); return [{ privateKey, publicKey }]; }); ``` ::: In the above code, we use the `~ActorCallbackSetters.setKeyPairsDispatcher()` method to set a key pairs dispatcher for the actor *me*. The key pairs dispatcher is called when the key pairs of an actor is needed. The key pairs dispatcher should return an array of objects that contain the private key and the public key of the actor. In this case, we generate a new key pair at the first time and store it in the key–value store. When the actor *me* is dispatched again, the key pairs dispatcher loads the key pair from the key–value store. > \[!NOTE] > Although we use the Deno KV database in this tutorial, you can use any > other your favorite database to store the key pair. The key–value store > is just an example. Restart the server and make an HTTP request to the actor *me* using `curl`. Now you should see the actor *me* with the public key in the response: ```json {16-21} { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "toot": "http://joinmastodon.org/ns#", "discoverable": "toot:discoverable", "suspended": "toot:suspended", "memorial": "toot:memorial", "indexable": "toot:indexable" } ], "id": "https://e875a03fc2a35b.lhr.life/users/me", "type": "Person", "inbox": "https://e875a03fc2a35b.lhr.life/users/me/inbox", "publicKey": { "id": "https://e875a03fc2a35b.lhr.life/users/me#main-key", "type": "CryptographicKey", "owner": "https://e875a03fc2a35b.lhr.life/users/me", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0kptPO/arJVTv1qBzISP\nhJC8MZSut20FHZuJFON/kTscQT19eP2zGC9qDnQVl1vOXrvFybPWMjQP4p2x1/VM\np0wnY2EzKsdU4+lKfHsjd0VU2+TJvPtZ/AqJAG3PLMXeN7E5RpeUTwdTr9fkyrHE\n0M8n8yWG1AMtXp5pzhR/Le8uHmuSjbgJxIZPZOj8T6ZdMXKxudF0H/i0IB60lN9D\nt5tOzajmE5jvZD0mapdIDhghidGBu77fgopKmBtNn3IDjLJLXIh3dp7NICl1czHB\ntVtU1c2kmNPXq1WSndQgokN4CXNoy/BqTKo4VhIOWWb/oGaTZOWflFM5EXWTJUxK\n8JFyCD/1KVJXYEd662y+r400oDJqHKHhG78yud83PD4bpbJm/t7BD7RgO95g/rpN\nwi8mjLQVp7Y9ttXGf3lEgbBPZfPr0pm3X4ppoDAwtzVO7RmfboSb9ECa9uwQc1VG\nse3yNi7bDrHIu+HjBzk+glELcW2Hj4t4s/PPX9g0fH3UHgME1Pysz3Y8OZZeJlTu\n1yYcCg9X/dMV1qxxon6b8XhIEttW+RZjJunmtzOt1sKf2NM2jPXv+ZmFRao1eOzo\nvcVI/eeXV+1LDhHtTQJGnLObqnHnVdg3Qiaao176KOxrKh4/l6kJmaq/pw8+ZSkE\nzxUovxHGCJ0UqqgcaPsBsJMCAwEAAQ==\n-----END PUBLIC KEY-----" }, "name": "Me", "preferredUsername": "me", "summary": "This is me!", "url": "https://e875a03fc2a35b.lhr.life/" } ``` Alright, we have the public key of the actor *me*. Let's move on to the next section to send an accept activity back to the sender when we receive a follow request. > \[!TIP] > If you are curious about the key pairs dispatcher further, see the > [*Public keys of an `Actor`* > section](../manual/actor.md#public-keys-of-an-actor) in the manual. ## Sending an `Accept` activity When the server receives a follow request, it should send an `Accept` or `Reject` activity back to the sender. The `Accept` activity is a response to the follow request and indicates that the follow request is accepted. Let's import the `Accept` class from the Fedify framework: ```typescript twoslash [server.ts] import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Accept, // [!code highlight] Follow, Person, } from "@fedify/vocab"; ``` Then, we modify the inbox listener to send an `Accept` activity back to the follower when we receive a follow request: ```typescript{10-17} twoslash [server.ts] import { type Federation } from "@fedify/fedify"; import { Accept, Follow } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- federation .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.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( { identifier: parsed.identifier }, follower, new Accept({ actor: follow.objectId, object: follow }), ); }); ``` Restart the server, and make a follow request from your Mastodon account to the actor *me*. You should see the server immediately accept the follow request. > \[!TIP] > If you are curious about sending activities further, see the [*Sending > activities* section](../manual/send.md) in the manual. ## Listing followers The server should list the actor's followers on the home page. To do this, we need to store the followers in the key–value store. We will store each `Follow` activity's ID as the key and the follower's actor ID as the value: ```typescript{16-17} twoslash [server.ts] import { type Federation } from "@fedify/fedify"; import { Accept, Follow } from "@fedify/vocab"; const federation = null as unknown as Federation; const kv = await Deno.openKv(); // ---cut-before--- federation .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.identifier !== "me") return; const follower = await follow.getActor(ctx); if (follower == null) return; await ctx.sendActivity( { identifier: parsed.identifier }, follower, new Accept({ actor: follow.objectId, object: follow }), ); // Store the follower in the key–value store: await kv.set(["followers", follow.id.href], follow.actorId.href); }); ``` Now, we need to make the home page to show the actor's followers. Let's modify the script inside the fetch function: ::: code-group ```typescript{2-16} twoslash [Deno] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; const kv = await Deno.openKv(); // ---cut-before--- Deno.serve(async (request) => { const url = new URL(request.url); // The home page: if (url.pathname === "/") { const followers: string[] = []; for await (const entry of kv.list({ prefix: ["followers"] })) { if (followers.includes(entry.value)) continue; followers.push(entry.value); } return new Response( `
    ${followers.map((f) => `
  • ${f}
  • `)}
`, { headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } // The federation-related requests are handled by the Federation object: return await federation.fetch(request, { contextData: undefined }); }); ``` ```typescript{4-18} twoslash [Bun] import "bun"; import type { Federation } from "@fedify/fedify"; import { openKv } from "@deno/kv"; const federation = null as unknown as Federation; const kv = await openKv(); // ---cut-before--- Bun.serve({ port: 8000, async fetch(request) { const url = new URL(request.url); // The home page: if (url.pathname === "/") { const followers: string[] = []; for await (const entry of kv.list({ prefix: ["followers"] })) { if (followers.includes(entry.value)) continue; followers.push(entry.value); } return new Response( `
    ${followers.map((f) => `
  • ${f}
  • `)}
`, { headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } // The federation-related requests are handled by the Federation object: return await federation.fetch(request, { contextData: undefined }); } }); ``` ```typescript{4-18} twoslash [Node.js] import { serve } from "@hono/node-server"; import type { Federation } from "@fedify/fedify"; import { openKv } from "@deno/kv"; const federation = null as unknown as Federation; const kv = await openKv(); // ---cut-before--- serve({ port: 8000, async fetch(request) { const url = new URL(request.url); // The home page: if (url.pathname === "/") { const followers: string[] = []; for await (const entry of kv.list({ prefix: ["followers"] })) { if (followers.includes(entry.value)) continue; followers.push(entry.value); } return new Response( `
    ${followers.map((f) => `
  • ${f}
  • `)}
`, { headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } // The federation-related requests are handled by the Federation object: return await federation.fetch(request, { contextData: undefined }); } }); ``` ::: The above code lists the actor's followers on the home page. The followers are stored in the key–value store, and we retrieve the followers from the key–value store and display them on the home page. Restart the server and navigate to the home page in your web browser. You should see the actor's followers in the bulleted list. > \[!NOTE] > Although the above code handles the home page *before* the federation-related > requests, it is just an example. In a real-world application, you should > integrate Fedify with a web framework like [Hono], where the order of > handling requests is reversed: the federation-related requests are handled > *before* other web-related requests. > > For more details, see the [*Integration* section](../manual/integration.md). [Hono]: https://hono.dev/ ## Linting your federation code As your federated server grows, it's important to maintain code quality and catch common mistakes early. Fedify provides the [`@fedify/lint`] package with linting plugins for both Deno Lint and ESLint, which include specialized linting rules for federation code. The `@fedify/lint` package helps you avoid common pitfalls such as: * Using incorrect actor IDs (e.g., relative URLs instead of `Context.getActorUri()`) * Missing required actor properties (inbox, outbox, followers, etc.) * Incorrect URL patterns for actor collections * Missing public keys or assertion methods For example, if you had written the actor dispatcher like this: ```typescript // ❌ Wrong: Using relative URL federation.setActorDispatcher( "/{identifier}", (_ctx, identifier) => { return new Person({ id: new URL(`/${identifier}`), // ❌ Linter will catch this name: "Example User", }); }, ); ``` The linter would warn you: ``` error[fedify-lint/actor-id-mismatch]: Actor's `id` property must match `_ctx.getActorUri(identifier)`. Ensure you're using the correct context method. ``` This helps you catch mistakes before they cause issues in production. > \[!TIP] > For detailed setup instructions, available rules, and configuration options, > see the [*Linting* section](../manual/lint.md) in the manual. [`@fedify/lint`]: https://jsr.io/@fedify/lint ## Wrapping up Congratulations! You have built a small federated server that can accept follow requests and list the actor's followers. You have learned the key features of the ActivityPub protocol and the Fedify framework, such as actors, sending and receiving activities, and the inbox. In this tutorial, we have covered the following topics: * Creating a new project * Creating the server * `Federation` object * Actor dispatcher * Exposing the server to the public internet * Inbox listener * Generating a key pair * Sending an `Accept` activity * Listing followers You can extend the server by adding more features, such as sending other activities, handling other types of activities, and implementing other callback functions. The Fedify framework provides a wide range of features to build a federated server, and you can explore them by reading the [manual](../manual/federation.md) and the [API reference]. If you have any questions or feedback, feel free to ask in the [Fedify community] on Matrix or the [GitHub Discussions]. [API reference]: https://jsr.io/@fedify/fedify [Fedify community]: https://matrix.to/#/#fedify:matrix.org [GitHub Discussions]: https://github.com/fedify-dev/fedify/discussions ## Exercises * Implement unfollowing feature: Listen to the `Undo` activity and remove the follower from the key–value store when the server receives an `Undo` activity. * Integration with a web framework: In the above example, we hard-coded the home page inside the callback function passed to `Deno.serve()` (in case of Deno), `Bun.serve()` (in case of Bun), and `serve()` (in case of Node.js). This is not enough for a real-world application as you would need to rendering HTML templates, handling media files, and so on. Instead, you can use a web framework like [Hono] or [Fresh] to utilize the proper routing system and [JSX] templates to produce HTML. See also the [*Integration* section](../manual/integration.md) in the manual for more details. [Fresh]: https://fresh.deno.dev/ [JSX]: https://facebook.github.io/jsx/ --- --- url: /tutorial/microblog.md description: >- In this tutorial, we will build a small microblog that implements the ActivityPub protocol, similar to Mastodon or Misskey, using Fedify, an ActivityPub server framework. --- # Creating your own federated microblog > \[!TIP] > > This tutorial is also available in the following languages: [한국어] (Korean) > and [日本語] (Japanese). In this tutorial, we will build a small [microblog] that implements the ActivityPub protocol, similar to [Mastodon] or [Misskey], using [Fedify], an ActivityPub server framework. This tutorial will focus more on how to use Fedify rather than understanding its underlying operating principles. If you have any questions, suggestions, or feedback, please feel free to join our [Matrix chat space] or [GitHub Discussions]. [한국어]: https://hackers.pub/@hongminhee/2025/fedify-tutorial-ko [日本語]: https://zenn.dev/hongminhee/books/4a38b6358a027b [microblog]: https://en.wikipedia.org/wiki/Microblogging [Mastodon]: https://joinmastodon.org/ [Misskey]: https://misskey-hub.net/ [Fedify]: https://fedify.dev/ [Matrix chat space]: https://matrix.to/#/#fedify:matrix.org [GitHub Discussions]: https://github.com/fedify-dev/fedify/discussions ## Target audience This tutorial is aimed at those who want to learn Fedify and create ActivityPub server software. We assume that you have experience in creating web applications using HTML and HTTP, and that you understand command-line interfaces, SQL, JSON, and basic JavaScript. However, you don't need to know TypeScript, JSX, ActivityPub, or Fedify—we'll teach you what you need to know about these as we go along. You don't need experience in creating ActivityPub software, but we do assume that you've used at least one ActivityPub software like Mastodon or Misskey. This is so you have an idea of what we're trying to build. \*\[JSX]: JavaScript XML ## Goals In this tutorial, we'll use Fedify to create a single-user microblog that can communicate with other federated software and services via ActivityPub. This software will include the following features: * Only one account can be created. * Other accounts in the fediverse can follow the user. * Followers can unfollow the user. * The user can view their list of followers. * The user can create posts. * The user's posts are visible to followers in the fediverse. * The user can follow other accounts in the fediverse. * The user can view a list of accounts they are following. * The user can view a chronological list of posts from accounts they follow. To simplify the tutorial, we'll impose the following feature constraints: * Account profiles (bio, photos, etc.) cannot be set. * Once created, an account cannot be deleted. * Once posted, a post cannot be edited or deleted. * Once followed, another account cannot be unfollowed. * There are no likes, shares, or comments. * There is no search functionality. * There are no security features such as authentication or permission checks. Of course, after completing the tutorial, you're welcome to add these features—it would be good practice! The complete source code is available in the [GitHub repository], with commits separated according to each implementation step for your reference. [GitHub repository]: https://github.com/fedify-dev/microblog ## Setting up the development environment ### Installing Node.js Fedify supports three JavaScript runtimes: [Deno], [Bun], and [Node.js]. Among these, Node.js is the most widely used, so we'll use Node.js as the basis for this tutorial. > \[!TIP] > A JavaScript runtime is a platform that executes JavaScript code. Web browsers > are one type of JavaScript runtime, and for command-line or server use, > Node.js is widely used. Recently, cloud edge functions like > [Cloudflare Workers] have also gained popularity as JavaScript runtimes. To use Fedify, you need Node.js version 22.0.0 or higher. There are [various installation methods]—choose the one that suits you best. Once Node.js is installed, you'll have access to the `node` and `npm` commands: ```sh node --version npm --version ``` [Deno]: https://deno.com/ [Bun]: https://bun.sh/ [Node.js]: https://nodejs.org/ [Cloudflare Workers]: https://workers.cloudflare.com/ [various installation methods]: https://nodejs.org/en/download/package-manager ### Installing the `fedify` command To set up a Fedify project, you need to install the [`fedify`](../cli.md) command on your system. There are [several installation methods](../cli.md#installation), but using the `npm` command is the simplest: ```sh npm install -g @fedify/cli ``` After installation, check if you can use the `fedify` command. You can check the version of the `fedify` command with this command: ```sh fedify --version ``` Make sure the version number is 1.0.0 or higher. If it's an older version, you won't be able to properly follow this tutorial. ### `fedify init` to initialize the project To start a new Fedify project, let's decide on a directory path to work in. In this tutorial, we'll name it *microblog*. Run the [`fedify init`](../cli.md#fedify-init-initializing-a-fedify-project) command followed by the directory path (it's okay if the directory doesn't exist yet): ```sh fedify init microblog ``` When you run the `fedify init` command, you'll see a series of prompts. Select *Hono*, *npm*, *in-process*, and *in-memory* in order: ```console ___ _____ _ _ __ /'_') | ___|__ __| (_)/ _|_ _ .-^^^-/ / | |_ / _ \/ _` | | |_| | | | __/ / | _| __/ (_| | | _| |_| | <__.|_|-|_| |_| \___|\__,_|_|_| \__, | |___/ ? Choose the web framework to use Bare-bones ❯ Hono Nitro Next.js ElysiaJS Astro Express Nuxt SolidStart ? Choose the package manager to use deno pnpm bun yarn ❯ npm ? Choose the message queue to use ❯ in-process redis postgres mysql amqp - denokv (not supported with npm) (disabled) ? Choose the key-value store to use ❯ in-memory redis postgres mysql - denokv (not supported with npm) (disabled) ``` > \[!NOTE] > Fedify is not a full-stack framework, but rather a framework specialized for > implementing ActivityPub servers. Therefore, it's designed to be used > alongside other web frameworks. In this tutorial, we'll adopt [Hono] as > our web framework to use with Fedify. After a moment, you'll see files created in your working directory with the following structure: * *.vscode/* — Visual Studio Code related settings * *extensions.json* — Recommended extensions for Visual Studio Code * *settings.json* — Visual Studio Code settings * *node\_modules/* — Directory where dependent packages are installed (contents omitted) * *src/* — Source code * *app.tsx* — Server unrelated to ActivityPub * *federation.ts* — ActivityPub server * *index.ts* — Entry point * *logging.ts* — Logging configuration * *.oxfmtrc.json* — Formatter settings * *.oxlintrc.json* — Linter settings * *package.json* — Package metadata * *tsconfig.json* — TypeScript settings As you might guess, we're using TypeScript instead of JavaScript, which is why we have *.ts* and *.tsx* files instead of *.js* files. The generated source code is a working demo. Let's first check if it runs properly: ```sh npm run dev ``` This command will keep the server running until you press Ctrl+C: ```console Server started at http://0.0.0.0:8000 ``` With the server running, open a new terminal tab and run the following command: ```sh fedify lookup http://localhost:8000/users/john ``` This command queries an actor (actor) on the ActivityPub server we've set up locally. In ActivityPub, an actor can be thought of as an account that's accessible across various ActivityPub servers. If you see output like this, it's working correctly: ```console ✔ Looking up the object... Person { id: URL "http://localhost:8000/users/john", name: "john", preferredUsername: "john" } ``` This result tells us that there's an actor object located at the */users/john* path, it's of type `Person`, its ID is *http://localhost:8000/users/john*, its name is *john*, and its username is also *john*. > \[!TIP] > [`fedify lookup`](../cli.md#fedify-lookup-looking-up-an-activitypub-object) > is a command to query ActivityPub objects. This is equivalent > to searching with the corresponding URI on Mastodon. (Of course, since your > server is only accessible locally at the moment, searching on Mastodon won't > yield any results yet.) > > If you prefer `curl` over the `fedify lookup` command, you can also query > the actor with this command (note that we're sending the Accept > header with the `-H` option): > > ```sh > curl -H"Accept: application/activity+json" http://localhost:8000/users/john > ``` > > However, if you query like this, the result will be in JSON format, > which is difficult to read with the naked eye. If you also have the `jq` > command installed on your system, you can use `curl` and `jq` together: > > ```sh > curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq . > ``` [Hono]: https://hono.dev/ ### Visual Studio Code [Visual Studio Code] might not be your favorite editor. However, we recommend using Visual Studio Code while following this tutorial. This is because we need to use TypeScript, and Visual Studio Code is currently the most convenient and excellent TypeScript IDE. Also, the generated project setup already includes Visual Studio Code settings, so you don't have to wrestle with formatters or linters. > \[!WARNING] > Don't confuse this with Visual Studio. Visual Studio Code and Visual Studio > only share a brand name; they are completely different software. After [installing Visual Studio Code], open the working directory by selecting *File* → *Open Folder…* from the menu. If you see a popup in the bottom right asking you to install the recommended Oxc extension for this repository, click the *Install* button to install the extension. Installing this extension will automatically format your TypeScript code, so you don't have to wrestle with code styles like indentation or spacing when writing TypeScript code. > \[!TIP] > If you're a loyal Emacs or Vim user, we won't discourage you from using your > favorite editor. However, we recommend setting up TypeScript LSP. > The difference in productivity depending on whether TypeScript LSP is set up > or not is significant. \*\[LSP]: Language Server Protocol [Visual Studio Code]: https://code.visualstudio.com/ [installing Visual Studio Code]: https://code.visualstudio.com/docs/setup/setup-overview ## Prerequisites ### TypeScript Before we start modifying code, let's briefly go over TypeScript. If you're already familiar with TypeScript, you can skip this section. TypeScript adds static type checking to JavaScript. The TypeScript syntax is almost the same as JavaScript, but the main difference is that you can specify types for variables and functions. Types are specified by adding a colon (`:`) after the variable or parameter. For example, the following code indicates that the `foo` variable is a `string`: ```typescript twoslash let foo: string; ``` If you try to assign a value of a different type to a variable declared like this, Visual Studio Code will show a red underline *before you even run it* and display a type error: ```typescript twoslash // @errors: 2322 let foo: string; // ---cut-before--- foo = 123; ``` When coding, don't ignore red underlines. If you ignore them and run the program, it's likely that an error will actually occur at that part. The most common type of error you'll encounter when coding in TypeScript is the `null` possibility error. For example, in the following code, the `bar` variable can be either a `string` or `null` (`string | null`): ```typescript twoslash function someFunction(): string | null { return ""; } // ---cut-before--- const bar: string | null = someFunction(); ``` What happens if you try to get the first character of this variable's content like this? ```typescript twoslash // @errors: 18047 function someFunction(): string | null { return ""; } const bar: string | null = someFunction(); // ---cut-before--- const firstChar = bar.charAt(0); ``` You'll get a type error like above. It's saying that `bar` might sometimes be `null`, and in that case, calling `null.charAt(0)` would cause an error, so you need to fix the code. In such cases, you need to add handling for the `null` case like this: ```typescript twoslash function someFunction(): string | null { return ""; } const bar: string | null = someFunction(); // ---cut-before--- const firstChar = bar === null ? "" : bar.charAt(0); ``` In this way, TypeScript helps prevent bugs by making you think of cases you might not have considered when coding. Another incidental advantage of TypeScript is that it enables auto-completion. For example, if you type `foo.`, a list of methods available for string objects will appear, allowing you to choose from them. This allows for faster coding without having to check documentation each time. We hope you'll feel the charm of TypeScript as you follow this tutorial. Above all, Fedify provides the best experience when used with TypeScript. > \[!TIP] > If you want to learn TypeScript properly and thoroughly, we recommend reading > *[The TypeScript Handbook]*. It takes about 30 minutes to read it all. [The TypeScript Handbook]: https://www.typescriptlang.org/docs/handbook/intro.html ### JSX JSX is a syntax extension of JavaScript that allows you to insert XML or HTML into JavaScript code. It can also be used in TypeScript, in which case it's sometimes called TSX. In this tutorial, we'll write all HTML within JavaScript code using JSX syntax. Those who are already familiar with JSX can skip this section. For example, the following code assigns an HTML tree with a `
` element at the top to the `html` variable: ```tsx twoslash const html =

Hello, JSX!

; ``` You can also insert JavaScript expressions using curly braces (the following code assumes, of course, that there's a `getName()` function): ```tsx twoslash /** * A hypothetical function that returns a name. */ function getName(): string { return ""; } // ---cut-before--- const html =

Hello, {getName()}!

; ``` One of the features of JSX is that you can define your own tags called components. Components can be defined as ordinary JavaScript functions. For example, the following code defines and uses a `` component (component names typically follow PascalCase style): ```tsx twoslash import type { Child, FC } from "hono/jsx"; function getName() { return "JSX"; } interface ContainerProps { name: string; children: Child; } const Container: FC = (props) => { return
{props.children}
; }; const html =

Hello, {getName()}!

; ``` In the above code, `FC` is provided by [Hono], the web framework we'll use, and it helps define the type of the component. `FC` is a generic type, and the types that go inside the angle brackets after `FC` are type arguments. Here, we specify the props type as the type argument. Props refer to the parameters passed to the component. In the above code, we declared and used the `ContainerProps` interface as the props type for the `` component. > \[!TIP] > Type arguments for generic types can be multiple, separated by commas. > For example, `Foo` applies type arguments `A` and `B` to the generic > type `Foo`. > > There are also generic functions, which are written as > `someFunction(foo, bar)`. > > When there's only one type argument, the angle brackets enclosing the type > argument may look like XML/HTML tags, but they have nothing to do with JSX > functionality. > > `FC` > : Applies the type argument `ContainerProps` to the generic type `FC`. > > `` > : Opens a component tag named ``. Must be closed with > ``. Among the things passed as props, `children` is worth noting specifically. This is because the child elements of the component are passed as the `children` prop. As a result, in the above code, the `html` variable is assigned the HTML tree `

Hello, JSX!

`. > \[!TIP] > JSX was invented in the React project and started to be widely used. > If you want to know more about JSX, read the *[Writing Markup with JSX]* and > *[JavaScript in JSX with Curly Braces]* sections of the React documentation. [Writing Markup with JSX]: https://react.dev/learn/writing-markup-with-jsx [JavaScript in JSX with Curly Braces]: https://react.dev/learn/javascript-in-jsx-with-curly-braces ## Account creation page The first thing we'll create is the account creation page. We need to create an account before we can post or follow other accounts. Let's start by building the visible part. First, let's create a file named *src/views.tsx*. Inside this file, we'll define a `` component using JSX: ```tsx twoslash [src/views.tsx] import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ( Microblog
{props.children}
); ``` To avoid spending too much time on design, we'll use a CSS framework called [Pico CSS]. > \[!TIP] > When the type of a variable or parameter can be inferred by TypeScript's type > checker, like `props` above, it's fine to omit the type annotation. Even when > the type annotation is omitted in such cases, you can check the type of > a variable by hovering your mouse cursor over the variable name in > Visual Studio Code. Next, in the same file, let's define a `` component that will go inside the layout: ```tsx twoslash [src/views.tsx] import type { FC } from "hono/jsx"; // ---cut-before--- export const SetupForm: FC = () => ( <>

Set up your microblog

); ``` In JSX, you can only have one top-level element, but the `` component has two top-level elements: `

` and `
`. That's why we've wrapped them with `<>` and ``. This is called a fragment. Now it's time to use the components we've defined. Open the *src/app.tsx* file and `import` the two components we just defined: ```tsx twoslash [src/app.tsx] // @noErrors: 2395 2307 import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const SetupForm: FC = () => <>; // ---cut-before--- import { Layout, SetupForm } from "./views.tsx"; ``` Then, display the account creation form we just made on the */setup* page: ```tsx twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const SetupForm: FC = () => <>; // ---cut-before--- app.get("/setup", (c) => c.html( , ), ); ``` Now, let's open the page in a web browser. If you see a screen like this, it's working correctly: ![Account creation page](./microblog/account-creation-page.png) > \[!NOTE] > To use JSX, the source file extension must be *.jsx* or *.tsx*. > Note that both files we edited in this section have the *.tsx* extension. [Pico CSS]: https://picocss.com/ ### Database setup Now that we've implemented the visible part, it's time to implement the functionality. We need a place to store account information, so let's use [SQLite]. SQLite is a relational database suitable for small-scale applications. First, let's declare a table to hold account information. From now on, we'll write all table declarations in the *src/schema.sql* file. We'll store account information in the `users` table: ```sql [src/schema.sql] CREATE TABLE IF NOT EXISTS users ( id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL UNIQUE CHECK (trim(lower(username)) = username AND username <> '' AND length(username) <= 50) ); ``` Since the microblog we're creating can only have one account, we've put a constraint on the `id` column, which is the primary key, to not allow values other than `1`. This ensures that the `users` table can't contain more than one record. We've also put constraints on the `username` column to not allow empty strings or strings that are too long. Now we need to run the *src/schema.sql* file to create the users table. For this, we need the `sqlite3` command, which you can [get from the SQLite website] or install from your platform's package manager. For macOS, you don't need to get it separately as it is built into the system. If you get it directly, you can get the *sqlite-tools-\*.zip* file for your OS and unzip it. If you use a package manager, you can also install it with the following command: ::: code-group ```sh [Debian & Ubuntu] sudo apt install sqlite3 ``` ```sh [Fedora & RHEL] sudo dnf install sqlite ``` ```powershell [Chocolatey] choco install sqlite ``` ```powershell [Scoop] scoop install sqlite ``` ```powershell [Windows Package Manager] winget install SQLite.SQLite ``` ::: Okay, now that we have the `sqlite3` command, let's use it to create a database file: ```sh sqlite3 microblog.sqlite3 < src/schema.sql ``` The above command will create a *microblog.sqlite3* file, which will store your SQLite data. [SQLite]: https://www.sqlite.org/ [get from the SQLite website]: https://www.sqlite.org/download.html ### Connecting to the database in the app Now we need to use the SQLite database in our app. To use SQLite database in Node.js, we need a SQLite driver library, and we'll use the *[better-sqlite3]* package. You can easily install the package with the `npm` command: ```sh npm add better-sqlite3 npm add --save-dev @types/better-sqlite3 ``` > \[!TIP] > The *[@types/better-sqlite3]* package contains type information about > the *better-sqlite3* package's API for TypeScript. You need to install this > package to enable auto-completion and type checking when editing in Visual > Studio Code. > > Packages like this in the *@types/* scope are called [Definitely Typed] > packages. When a library is not written in TypeScript, the community adds > type information and makes it into a package. Now that we've installed the package, let's write code to connect to the database using this package. Create a new file named *src/db.ts* and code it as follows: ```typescript twoslash [src/db.ts] import Database from "better-sqlite3"; const db = new Database("microblog.sqlite3"); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); export default db; ``` > \[!TIP] > The settings made through the `db.pragma()` function have the following > effects: > > [`journal_mode = WAL`] > : Adopts [Write-Ahead Logging] mode as a way to implement atomic commits and > rollbacks in SQLite. This mode is generally more performant than > the default [rollback journal] mode. > > [`foreign_keys = ON`] > : By default, SQLite does not check foreign key constraints. Turning on this > setting makes it check foreign key constraints, which helps maintain data > integrity. Now let's declare a type in JavaScript to represent the record stored in the `users` table. Create a *src/schema.ts* file and define the `User` type as follows: ```typescript twoslash [src/schema.ts] export interface User { id: number; username: string; } ``` [better-sqlite3]: https://github.com/WiseLibs/better-sqlite3 [@types/better-sqlite3]: https://www.npmjs.com/package/@types/better-sqlite3 [Definitely Typed]: https://github.com/DefinitelyTyped/DefinitelyTyped [`journal_mode = WAL`]: https://www.sqlite.org/wal.html [Write-Ahead Logging]: https://en.wikipedia.org/wiki/Write-ahead_logging [rollback journal]: https://www.sqlite.org/lockingv3.html#rollback [`foreign_keys = ON`]: https://www.sqlite.org/foreignkeys.html#fk_enable ### Record insertion Now that we've connected to the database, it's time to write code to insert records. Open the *src/app.tsx* file and `import` the `db` object and `User` type that will be used for record insertion: ```typescript twoslash [src/app.tsx] // @noErrors: 2307 import Database from "better-sqlite3"; const db = new Database(""); interface User {} // ---cut-before--- import db from "./db.ts"; import type { User } from "./schema.ts"; ``` Implement the `POST /setup` handler: ```typescript twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import Database from "better-sqlite3"; const db = new Database(""); interface User {} // ---cut-before--- app.post("/setup", async (c) => { // Check if an account already exists const user = db.prepare("SELECT * FROM users LIMIT 1").get(); if (user != null) return c.redirect("/"); const form = await c.req.formData(); const username = form.get("username"); if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) { return c.redirect("/setup"); } db.prepare("INSERT INTO users (username) VALUES (?)").run(username); return c.redirect("/"); }); ``` Add code to check if an account already exists to the `GET /setup` handler we created earlier: ```tsx{2-4} twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const SetupForm: FC = () => <>; import Database from "better-sqlite3"; const db = new Database(""); interface User {} // ---cut-before--- app.get("/setup", (c) => { // Check if an account already exists const user = db.prepare("SELECT * FROM users LIMIT 1").get(); if (user != null) return c.redirect("/"); return c.html( , ); }); ``` ### Testing Now that we've roughly implemented the account creation feature, let's try it out. Open the page in a web browser and create an account. In this tutorial, we'll assume that we used *johndoe* as the username. If it's created, let's also check if the record was properly inserted into the SQLite database: ```sh echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3 ``` If the record was properly inserted, you should see output like this (of course, `johndoe` will be whatever username you entered): | `id` | `username` | | ---- | ---------- | | `1` | `johndoe` | ## Profile page Now that we've created an account, let's implement a profile page to display the account information. Although we don't have much information to show yet. Let's start with the visible part again. Open the *src/views.tsx* file and define a `` component: ```tsx twoslash [src/views.tsx] import type { FC } from "hono/jsx"; // ---cut-before--- export interface ProfileProps { name: string; handle: string; } export const Profile: FC = ({ name, handle }) => ( <>

{name}

{handle}

); ``` Then, open the *src/app.tsx* file and `import` the component we just defined: ```tsx twoslash [src/app.tsx] // @noErrors: 2395 2307 import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const Profile: FC = () => <>; export const SetupForm: FC = () => <>; // ---cut-before--- import { Layout, Profile, SetupForm } from "./views.tsx"; ``` And add a `GET /users/{username}` request handler that displays the `` component: ```tsx twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const Profile: FC = () => <>; import Database from "better-sqlite3"; const db = new Database(""); interface User { username: string; } // ---cut-before--- app.get("/users/:username", async (c) => { const user = db .prepare("SELECT * FROM users WHERE username = ?") .get(c.req.param("username")); if (user == null) return c.notFound(); const url = new URL(c.req.url); const handle = `@${user.username}@${url.host}`; return c.html( , ); }); ``` Now let's test if it displays correctly. Open the page in your web browser (if you created an account with a username other than `johndoe`, change the URL accordingly). You should see a screen like this: ![Profile page](./microblog/profile-page.png) > \[!TIP] > A fediverse handle, or simply handle, is a unique address that identifies > an account in the fediverse. For example, it looks like > `@hongminhee@fosstodon.org`. It's similar to an email address, > and its structure is also similar to an email address. It starts with `@`, > followed by a name, then another `@`, and finally the domain name of > the server the account belongs to. Sometimes the initial `@` is omitted. > > Technically, handles are implemented using two standards: [WebFinger] and > the [`acct:` URI scheme]. Thanks to Fedify implementing these, you don't need > to know the implementation details while following this tutorial. [WebFinger]: https://datatracker.ietf.org/doc/html/rfc7033 [`acct:` URI scheme]: https://datatracker.ietf.org/doc/html/rfc7565 ## Implementing the actor As the name suggests, ActivityPub is a protocol for exchanging activities. Writing a post, editing a post, deleting a post, liking a post, commenting, editing a profile… All actions that happen in social media are expressed as activities. And all activities are transmitted from actor to actor. For example, when John Doe writes a post, a writing (`Create(Note)`) activity is sent from Joh Doe to John Doe's followers. If Jane Doe likes that post, a liking (`Like`) activity is sent from Jane Doe to John Doe. Therefore, the first step in implementing ActivityPub is to implement the actor. The demo app generated by the `fedify init` command already has a very simple actor implemented, but to communicate with actual software like Mastodon or Misskey, we need to implement the actor more properly. First, let's take a look at the current implementation. Open the *src/federation.ts* file: ```typescript{12-18} twoslash [src/federation.ts] import { createFederation, InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; const logger = getLogger("microblog"); const federation = createFederation({ kv: new MemoryKvStore(), queue: new InProcessMessageQueue(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { return new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier, name: identifier, }); }); export default federation; ``` The part we should focus on is the `~Federatable.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 `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. 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 `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 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 We need to create an `actors` table. Unlike the `users` table which only contains accounts on the current instance server, this table will also include remote actors belonging to federated servers. The table looks like this. Add the following SQL to the *src/schema.sql* file: ```sql [src/schema.sql] CREATE TABLE IF NOT EXISTS actors ( id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER REFERENCES users (id), uri TEXT NOT NULL UNIQUE CHECK (uri <> ''), handle TEXT NOT NULL UNIQUE CHECK (handle <> ''), name TEXT, inbox_url TEXT NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%' OR inbox_url LIKE 'http://%'), shared_inbox_url TEXT CHECK (shared_inbox_url LIKE 'https://%' OR shared_inbox_url LIKE 'http://%'), url TEXT CHECK (url LIKE 'https://%' OR url LIKE 'http://%'), created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '') ); ``` * The `user_id` column is a foreign key to connect with the `users` column. If the record represents a remote actor, it will be `NULL`, but if it's an account on the current instance server, it will contain the `users.id` value of that account. * The `uri` column contains the unique URI of the actor, also called the actor ID. All ActivityPub objects, including actors, have a unique ID in URI form. Therefore, it cannot be empty and cannot be duplicated. * The `handle` column contains the fediverse handle in the form of `@johndoe@example.com`. Likewise, it cannot be empty and cannot be duplicated. * The `name` column contains the name displayed in the UI. It usually contains a full name or nickname. However, according to the ActivityPub specification, this column can be empty. * The `inbox_url` column contains the URL of the actor's inbox. We'll explain in detail what an inbox is below, but for now, just know that it must exist for the actor. This column also cannot be empty or duplicated. * The `shared_inbox_url` column contains the URL of the actor's shared inbox, which we'll also explain below. It's not mandatory, so it can be empty, and as the column name suggests, it can share the same shared inbox URL with other actors. * The `url` column contains the profile URL of the actor. A profile URL means the URL of the profile page that can be opened in a web browser. Sometimes the actor's ID and profile URL are the same, but they can be different depending on the service, so in that case, the profile URL is stored in this column. It can be empty. * The `created` column records when the record was created. It cannot be empty, and by default, the insertion time is recorded. Now, let's apply the *src/schema.sql* file to the *microblog.sqlite3* database file: ```sh sqlite3 microblog.sqlite3 < src/schema.sql ``` And let's define a type in *src/schema.ts* to represent records stored in the `actors` table in JavaScript: ```typescript twoslash [src/schema.ts] export interface Actor { id: number; user_id: number | null; uri: string; handle: string; name: string | null; inbox_url: string; shared_inbox_url: string | null; url: string | null; created: string; } ``` ### Actor record Although we currently have one record in the `users` table, there's no corresponding record in the `actors` table. This is because we didn't add a record to the `actors` table when creating the account. We need to modify the account creation code to add records to both `users` and `actors`. First, let's modify the `SetupForm` in *src/views.tsx* to also input a name that will go into the `actors.name` column along with the username: ```tsx{16-18} twoslash [src/views.tsx] import type { FC } from "hono/jsx"; // ---cut-before--- export const SetupForm: FC = () => ( <>

Set up your microblog

); ``` Now `import` the Actor type we defined earlier in *src/app.tsx*: ```typescript [src/app.tsx] import type { Actor, User } from "./schema.ts"; ``` Now let's add code to the `POST /setup` handler to create a record in the `actors` table with the input name and other necessary information: ```typescript{7,19-24,26,30-44} twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import Database from "better-sqlite3"; const db = new Database(""); interface User {} import type { Federation } from "@fedify/fedify"; const fedi = null as unknown as Federation; // ---cut-before--- app.post("/setup", async (c) => { // Check if an account already exists const user = db .prepare( ` SELECT * FROM users JOIN actors ON (users.id = actors.user_id) LIMIT 1 `, ) .get(); if (user != null) return c.redirect("/"); const form = await c.req.formData(); const username = form.get("username"); if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) { return c.redirect("/setup"); } const name = form.get("name"); if (typeof name !== "string" || name.trim() === "") { return c.redirect("/setup"); } const url = new URL(c.req.url); const handle = `@${username}@${url.host}`; const ctx = fedi.createContext(c.req.raw, undefined); db.transaction(() => { db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run( username, ); db.prepare( ` INSERT OR REPLACE INTO actors (user_id, uri, handle, name, inbox_url, shared_inbox_url, url) VALUES (1, ?, ?, ?, ?, ?, ?) `, ).run( ctx.getActorUri(username).href, handle, name, ctx.getInboxUri(username).href, ctx.getInboxUri().href, ctx.getActorUri(username).href, ); })(); return c.redirect("/"); }); ``` When checking if an account already exists, we modified it to determine that there's no account yet not only when there's no record in the `users` table, but also when there's no matching record in the `actors` table. Apply the same condition to the `GET /setup` handler and the `GET /users/{username}` handler: ```tsx{7} twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import Database from "better-sqlite3"; const db = new Database(""); interface User {} import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const SetupForm: FC = () => <>; // ---cut-before--- app.get("/setup", (c) => { // Check if the user already exists const user = db .prepare( ` SELECT * FROM users JOIN actors ON (users.id = actors.user_id) LIMIT 1 `, ) .get(); if (user != null) return c.redirect("/"); return c.html( , ); }); ``` ```tsx{6} twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import Database from "better-sqlite3"; const db = new Database(""); interface Actor { name: string; } interface User { username: string; } import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const Profile: FC = () => <>; // ---cut-before--- app.get("/users/:username", async (c) => { const user = db .prepare( ` SELECT * FROM users JOIN actors ON (users.id = actors.user_id) WHERE username = ? `, ) .get(c.req.param("username")); if (user == null) return c.notFound(); const url = new URL(c.req.url); const handle = `@${user.username}@${url.host}`; return c.html( , ); }); ``` > \[!TIP] > In TypeScript, `A & B` means an object that is both type `A` and type `B`. > For example, given the type `{ a: number } & { b: string }`, `{ a: 123 }` or > `{ b: "foo" }` do not satisfy this type, but `{ a: 123, b: "foo" }` does > satisfy this type. Finally, open the *src/federation.ts* file and add the following code below the actor dispatcher: ```typescript twoslash [src/federation.ts] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ``` Don't worry about the `~Federatable.setInboxListeners()` method for now. We'll cover this when we explain about the inbox. Just note that the `~Context.getInboxUri()` method used in the account creation code needs the above code to work properly. If you've modified all the code, open the page in your browser and create an account again: ![Account creation page](./microblog/account-creation-page-2.png) ### Actor dispatcher Now that we've created the `actors` table and filled in a record, let's modify *src/federation.ts* again. First, `import` the `db` object, and `Endpoints` and Actor types: ```typescript twoslash [src/federation.ts] // @noErrors: 2307 import { createFederation } from "@fedify/fedify"; import { Endpoints, Person } from "@fedify/vocab"; import db from "./db.ts"; import type { Actor, User } from "./schema.ts"; ``` Now that we've `import`ed what we need, let's modify the `~Federatable.setActorDispatcher()` method: ```typescript{2-11,16-21} twoslash [src/federation.ts] import { type Federation } from "@fedify/fedify"; import { Endpoints, Person } from "@fedify/vocab"; import Database from "better-sqlite3"; const db = new Database(""); interface User {} interface Actor { name: string; } const federation = null as unknown as Federation; // ---cut-before--- federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = db .prepare( ` SELECT * FROM users JOIN actors ON (users.id = actors.user_id) WHERE users.username = ? `, ) .get(identifier); if (user == null) return null; return new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier, name: user.name, inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), url: ctx.getActorUri(identifier), }); }); ``` In the changed code, we now query the `users` table in the database and return `null` if it's not an account on the current server. In other words, it will respond with a proper `Person` object with `200 OK` for a `GET /users/johndoe` request (assuming the account was created with the username `johndoe`), and respond with `404 Not Found` for other requests. Let's look at how the part creating the `Person` object has changed. First, a `name` property has been added. This property uses the value from the `actors.name` column. We'll cover the `inbox` and `endpoints` properties when we explain about the inbox. The `url` property contains the profile URL of this account, and in this tutorial, we'll make the actor ID and the actor's profile URL match. > \[!TIP] > Sharp-eyed readers may have noticed that we're defining overlapping handlers > 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, the request handler on the > Fedify side responds. > > This way of giving different responses according to the Accept > header of the request is called HTTP [content negotiation], and Fedify itself > implements content negotiation. More specifically, all requests go through > Fedify once, and if it's not an ActivityPub-related request, it's passed on to > the integrated framework, which in this tutorial is Hono. > \[!TIP] > In Fedify, all URIs and URLs are represented as [`URL`] instances. [content negotiation]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation [`URL`]: https://developer.mozilla.org/ ### Testing Now, let's test if the actor dispatcher is working well. With the server running, open a new terminal tab and enter the following command: ```sh fedify lookup http://localhost:8000/users/alice ``` Since there's no account named `alice`, you'll get an error like this, unlike before: ```console ✔ Looking up the object... Failed to fetch the object. It may be a private object. Try with -a/--authorized-fetch. ``` Now let's look up the `johndoe` account: ```sh fedify lookup http://localhost:8000/users/johndoe ``` Now you get a good result: ```console ✔ Looking up the object... Person { id: URL "http://localhost:8000/users/johndoe", name: "John Doe", url: URL "http://localhost:8000/users/johndoe", preferredUsername: "johndoe", inbox: URL "http://localhost:8000/users/johndoe/inbox", endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" } } ``` ## Cryptographic key pairs The next thing we'll implement is the actor's cryptographic keys for signing. In ActivityPub, when an actor creates and sends an activity, it uses a [digital signature] to prove that the activity was really created by that actor. For this, each actor creates and holds their own matching private key (secret key) and public key pair, and makes the public key visible to other actors. When actors receive an activity, they compare the sender's public key with the activity's signature to verify that the activity was indeed created by the sender. Fedify handles the signing and signature verification automatically, but you need to implement the generation and preservation of the key pairs yourself. > \[!WARNING] > As the name suggests, the private key (secret key) should not be accessible > to anyone other than the signing subject. On the other hand, the public key's > purpose is to be public, so it's fine for anyone to access it. [digital signature]: https://en.wikipedia.org/wiki/Digital_signature ### Table creation Let's define a `keys` table in *src/schema.sql* to store the private and public key pairs: ```sql [src/schema.sql] CREATE TABLE IF NOT EXISTS keys ( user_id INTEGER NOT NULL REFERENCES users (id), type TEXT NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')), private_key TEXT NOT NULL CHECK (private_key <> ''), public_key TEXT NOT NULL CHECK (public_key <> ''), created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''), PRIMARY KEY (user_id, type) ); ``` If you look closely at the table, you can see that the `type` column only allows two types of values. One is the [RSA-PKCS#1-v1.5] type and the other is the [Ed25519] type. (What each of these means is not important for this tutorial.) Since the primary key is on `(user_id, type)`, there can be a maximum of two key pairs for one user. > \[!TIP] > We can't go into detail in this tutorial, but as of November 2024, > the ActivityPub network is in the process of transitioning from > the RSA-PKCS#1-v1.5 type to the Ed25519 type. Some software only > accepts the RSA-PKCS#1-v1.5 type, while some software accepts > the Ed25519 type. Therefore, to communicate with both sides, > both pairs of keys are needed. The `private_key` and `public_key` columns can receive strings, and we'll put JSON data in them. We'll cover how to encode private and public keys as JSON later. Now let's create the `keys` table: ```sh sqlite3 microblog.sqlite3 < src/schema.sql ``` Let's also define a `Key` type in the *src/schema.ts* file to represent records stored in the `keys` table in JavaScript: ```typescript twoslash [src/schema.ts] export interface Key { user_id: number; type: "RSASSA-PKCS1-v1_5" | "Ed25519"; private_key: string; public_key: string; created: string; } ``` [RSA-PKCS#1-v1.5]: https://www.rfc-editor.org/rfc/rfc2313 [Ed25519]: https://ed25519.cr.yp.to/ ### Key pairs dispatcher Now we need to write code to generate and load key pairs. Open the *src/federation.ts* file and `import` the `exportJwk()`, `generateCryptoKeyPair()`, `importJwk()` functions provided by Fedify and the `Key` type we defined earlier: ```typescript{5-7,9} twoslash [src/federation.ts] // @noErrors: 2307 import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Endpoints, Person } from "@fedify/vocab"; import type { Actor, Key, User } from "./schema.ts"; ``` Now let's modify the actor dispatcher part as follows: ```typescript twoslash [src/federation.ts] import { type Federation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Endpoints, Person } from "@fedify/vocab"; const federation = null as unknown as Federation; import { type Logger } from "@logtape/logtape"; const logger = null as unknown as Logger; import Database from "better-sqlite3"; const db = new Database(""); interface User { id: number; } interface Actor { name: string; } interface Key { type: "RSASSA-PKCS1-v1_5" | "Ed25519"; private_key: string; public_key: string; } // ---cut-before--- federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = db .prepare( ` SELECT * FROM users JOIN actors ON (users.id = actors.user_id) WHERE users.username = ? `, ) .get(identifier); if (user == null) return null; const keys = await ctx.getActorKeyPairs(identifier); return new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier, name: user.name, inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), url: ctx.getActorUri(identifier), publicKey: keys[0].cryptographicKey, assertionMethods: keys.map((k) => k.multikey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = db .prepare("SELECT * FROM users WHERE username = ?") .get(identifier); if (user == null) return []; const rows = db .prepare("SELECT * FROM keys WHERE keys.user_id = ?") .all(user.id); const keys = Object.fromEntries( rows.map((row) => [row.type, row]), ) as Record; const pairs: CryptoKeyPair[] = []; // For each of the two key formats (RSASSA-PKCS1-v1_5 and Ed25519) that // the actor supports, check if they have a key pair, and if not, // generate one and store it in the database: for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) { if (keys[keyType] == null) { logger.debug( "The user {identifier} does not have an {keyType} key; creating one...", { identifier, keyType }, ); const { privateKey, publicKey } = await generateCryptoKeyPair(keyType); db.prepare( ` INSERT INTO keys (user_id, type, private_key, public_key) VALUES (?, ?, ?, ?) `, ).run( user.id, keyType, JSON.stringify(await exportJwk(privateKey)), JSON.stringify(await exportJwk(publicKey)), ); pairs.push({ privateKey, publicKey }); } else { pairs.push({ privateKey: await importJwk( JSON.parse(keys[keyType].private_key), "private", ), publicKey: await importJwk( JSON.parse(keys[keyType].public_key), "public", ), }); } } return pairs; }); ``` First of all, we should pay attention to the `~ActorCallbackSetters.setKeyPairsDispatcher()` method called in succession after the `~Federatable.setActorDispatcher()` method. This method connects the key pairs returned by the callback function to the account. By connecting the key pairs in this way, Fedify automatically adds digital signatures with the registered private keys when sending activities. The `generateCryptoKeyPair()` function generates a new private key and public key pair and returns it as a [`CryptoKeyPair`] object. For your reference, the [`CryptoKeyPair`] type has the type `{ privateKey: CryptoKey; publicKey: CryptoKey; }`. The `exportJwk()` function returns an object representing the [`CryptoKey`] object in JWK format. You don't need to know what the JWK format is. Just understand that it's a standard format for representing cryptographic keys in JSON. [`CryptoKey`] is a web standard type for representing cryptographic keys as JavaScript objects. The `importJwk()` function converts a key represented in JWK format to a [`CryptoKey`] object. You can understand it as the opposite of the `exportJwk()` function. Now, let's turn our attention back to the `~Federatable.setActorDispatcher()` method. We're using a method called `~Context.getActorKeyPairs()`, which, as the name suggests, returns the key pairs of the actor. The actor's key pairs are those very key pairs we just loaded with the `~ActorCallbackSetters.setKeyPairsDispatcher()` method. We loaded two pairs of keys in RSA-PKCS#1-v1.5 and Ed25519 formats, so the `~Context.getActorKeyPairs()` method returns an array of two key pairs. Each element of the array is an object representing the key pair in various formats, which looks like this: ```typescript twoslash import type { CryptographicKey, Multikey } from "@fedify/vocab"; // ---cut-before--- interface ActorKeyPair { privateKey: CryptoKey; // Private key publicKey: CryptoKey; // Public key keyId: URL; // Unique identification URI of the key cryptographicKey: CryptographicKey; // Another format of the public key multikey: Multikey; // Yet another format of the public key } ``` It's complex to explain here how [`CryptoKey`], `CryptographicKey`, and `Multikey` differ, and why there need to be so many formats. For now, let's just note that when initializing the `Person` object, the `publicKey` property accepts the `CryptographicKey` type and the `assertionMethods` property accepts the `MultiKey[]` (TypeScript notation for an array of `Multikey`) type. By the way, why are there two properties in the `Person` object that hold public keys, `publicKey` and `assertionMethods`? Originally in ActivityPub, there was only the `publicKey` property, but later the `assertionMethods` property was added to allow registration of multiple keys. Similar to how we generated both RSA-PKCS#1-v1.5 and Ed25519 keys earlier, we're setting both properties for compatibility with various software. If you look closely, you can see that we're only registering the RSA-PKCS#1-v1.5 key to the legacy `publicKey` property (the first item in the array is the RSA-PKCS#1-v1.5 key pair, and the second item is the Ed25519 key pair). > \[!TIP] > Actually, the `publicKey` property can contain multiple keys too. However, > many software are already implemented under the assumption that > the `publicKey` property will only contain one key, so they often malfunction. > The `assertionMethods` property was proposed to avoid this. > > For those interested in this, refer to the [FEP-521a] document. [`CryptoKeyPair`]: https://developer.mozilla.org/en-US/docs/Web/API/CryptoKeyPair [`CryptoKey`]: https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey [FEP-521a]: https://w3id.org/fep/521a ### Testing Now that we've registered the cryptographic keys to the actor object, let's check if it's working well. Query the actor with the following command: ```sh fedify lookup http://localhost:8000/users/johndoe ``` If it's working correctly, you should see output like this: ```console{7,14,22-23,30,38,44} ✔ Looking up the object... Person { id: URL "http://localhost:8000/users/johndoe", name: "John Doe", url: URL "http://localhost:8000/users/johndoe", preferredUsername: "johndoe", publicKey: CryptographicKey { id: URL "http://localhost:8000/users/johndoe#main-key", owner: URL "http://localhost:8000/users/johndoe", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: Uint8Array(3) [ 1, 0, 1 ], hash: { name: "SHA-256" } }, usages: [ "verify" ] } }, assertionMethods: [ Multikey { id: URL "http://localhost:8000/users/johndoe#main-key", controller: URL "http://localhost:8000/users/johndoe", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: Uint8Array(3) [ 1, 0, 1 ], hash: { name: "SHA-256" } }, usages: [ "verify" ] } }, Multikey { id: URL "http://localhost:8000/users/johndoe#key-2", controller: URL "http://localhost:8000/users/johndoe", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "Ed25519" }, usages: [ "verify" ] } } ], inbox: URL "http://localhost:8000/users/johndoe/inbox", endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" } } ``` You can see that the `Person` object's `publicKey` property contains one `CryptographicKey` object in RSA-PKCS#1-v1.5 type, and the `assertionMethods` property contains two `Multikey` objects in RSA-PKCS#1-v1.5 and Ed25519 formats. ## Interoperating with Mastodon Now let's check if we can actually view the actor we've created in Mastodon. ### Exposing to the public internet Unfortunately, the current server is only accessible locally. However, it would be inconvenient to deploy somewhere every time we modify the code for testing. Wouldn't it be great if we could expose our local server to the internet without deployment for immediate testing? Here's where the [`fedify tunnel`](../cli.md#fedify-tunnel-exposing-a-local-http-server-to-the-public-internet) command comes in handy. In a terminal, open a new tab and enter this command followed by the port number of your local server: ```sh fedify tunnel 8000 ``` This creates a disposable domain name and relays to your local server. It will output a URL that's accessible from the outside: ```console ✔ Your local server at 8000 is now publicly accessible: https://temp-address.serveo.net/ Press ^C to close the tunnel. ``` Of course, you'll see your own unique URL different from the one above. You can check if it's connecting well by opening in your web browser (replace with your unique temporary domain): ![Profile page exposed to the public internet](./microblog/profile-page-2.png) Copy your fediverse handle shown on the above web page, then go into Mastodon and paste it into the search box in the upper left corner: ![Search results for the fediverse handle in Mastodon](./microblog/search-results.png) If the actor we created appears in the search results as shown above, it's working correctly. You can also click on the actor's name in the search results to go to their profile page: ![Actor's profile viewed in Mastodon](./microblog/remote-profile.png) But this is as far as we can go. Don't try to follow yet! For our actor to be followable from other servers, we need to implement an inbox. > \[!NOTE] > The `fedify tunnel` command automatically disconnects after a while if not > used. When this happens, you need to press Ctrl+C to > stop it, then run the `fedify tunnel 8000` command again to establish a new > connection. ## Inbox In ActivityPub, an inbox is an endpoint where an actor receives incoming activities from other actors. All actors have their own inbox, which is a URL that can receive activities via HTTP `POST` requests. When another actor sends a follow request, writes a post, comments, or performs any other interaction, the corresponding activity is delivered to the recipient's inbox. The server processes the activities that come into the inbox and responds appropriately, allowing it to communicate and function as part of the federated network. For now, we'll start by implementing the reception of follow requests. ### Table creation We need to create a `follows` table to hold the actors who follow you (followers) and the actors you follow (following). Add the following SQL to the *src/schema.sql* file: ```sql [src/schema.sql] CREATE TABLE IF NOT EXISTS follows ( following_id INTEGER REFERENCES actors (id), follower_id INTEGER REFERENCES actors (id), created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''), PRIMARY KEY (following_id, follower_id) ); ``` Let's create the `follows` table by executing *src/schema.sql* once again: ```sh sqlite3 microblog.sqlite3 < src/schema.sql ``` Open the *src/schema.ts* file and define a type to represent records stored in the `follows` table in JavaScript: ```typescript twoslash [src/schema.ts] export interface Follow { following_id: number; follower_id: number; created: string; } ``` ### Receiving `Follow` activity Now it's time to implement the inbox. Actually, we've already written the following code in the *src/federation.ts* file earlier: ```typescript twoslash [src/federation.ts] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation; // ---cut-before--- federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ``` Before modifying this code, let's `import` the `Accept` and `Follow` classes and the `getActorHandle()` function provided by Fedify: ```typescript{2,4,9} twoslash [src/federation.ts] import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Accept, Endpoints, Follow, Person, getActorHandle, } from "@fedify/vocab"; ``` Now let's modify the code calling the `~Federatable.setInboxListeners()` method as follows: ```typescript twoslash [src/federation.ts] import { type Federation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Accept, Endpoints, Follow, Person, getActorHandle, } from "@fedify/vocab"; const federation = null as unknown as Federation; import type { Logger } from "@logtape/logtape"; const logger = null as unknown as Logger; import Database from "better-sqlite3"; const db = new Database(""); interface Actor { id: number; } // ---cut-before--- federation .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}", { follow, }); return; } const object = ctx.parseUri(follow.objectId); if (object == null || object.type !== "actor") { logger.debug("The Follow object's object is not an actor: {follow}", { follow, }); return; } const follower = await follow.getActor(); if (follower?.id == null || follower.inboxId == null) { logger.debug("The Follow object does not have an actor: {follow}", { follow, }); return; } const followingId = db .prepare( ` SELECT * FROM actors JOIN users ON users.id = actors.user_id WHERE users.username = ? `, ) .get(object.identifier)?.id; if (followingId == null) { logger.debug( "Failed to find the actor to follow in the database: {object}", { object }, ); } const followerId = db .prepare( ` -- Add a new follower actor record or update if it already exists INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET handle = excluded.handle, name = excluded.name, inbox_url = excluded.inbox_url, shared_inbox_url = excluded.shared_inbox_url, url = excluded.url WHERE actors.uri = excluded.uri RETURNING * `, ) .get( follower.id.href, await getActorHandle(follower), follower.name?.toString(), follower.inboxId.href, follower.endpoints?.sharedInbox?.href, follower.url?.href, )?.id; db.prepare( "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)", ).run(followingId, followerId); const accept = new Accept({ actor: follow.objectId, to: follow.actorId, object: follow, }); await ctx.sendActivity(object, follower, accept); }); ``` Let's examine the code carefully. The `~InboxListenerSetters.on()` method defines the action to take when a specific type of activity is received. Here, we've written code to record the follower information in the database when a `Follow` activity is received, and then send an `Accept(Follow)` activity back to the actor who sent the follow request. The `follow.objectId` should contain the URI of the actor being followed. We use the `~Context.parseUri()` method to check if the URI inside it points to the actor we created. The `getActorHandle()` function returns the fediverse handle as a string from the given actor object. If there's no information about the actor who sent the follow request in the `actors` table yet, we first add a record. If a record already exists, we update it with the latest data. Then, we add the follower to the `follows` table. Once the record is completed in the database, we use the `~Context.sendActivity()` method to send an `Accept(Follow)` activity as a reply to the actor who sent the activity. It takes the sender as the first parameter, the recipient as the second parameter, and the activity object to send as the third parameter. ### ActivityPub.Academy Now it's time to check if follow requests are being received properly. While it would be fine to test from a regular Mastodon server, let's use the [ActivityPub.Academy] server, which allows us to see exactly how activities are exchanged. ActivityPub.Academy is a special Mastodon server for education and debugging purposes, where you can easily create temporary accounts with just one click. ![ActivityPub.Academy homepage](./microblog/academy.jpg) After agreeing to the privacy policy, click the *Sign Up* button to create a new account. The created account will have a randomly generated name and handle, and will disappear on its own after a day. Instead, you can create new accounts as many times as you want. Once you're logged in, paste the handle of the actor we created into the search box in the top left corner of the screen: ![Search results for our actor's handle on ActivityPub.Academy](./microblog/academy-search-results.png) If our actor appears in the search results, click the follow button on the right to send a follow request. Then click on *Activity Log* in the right menu: ![ActivityPub.Academy's Activity Log](./microblog/activity-log.png) You'll see an indication that a `Follow` activity was sent from the ActivityPub.Academy server to the inbox of the actor we created by clicking the follow button just now. You can see the contents of the activity by clicking *show source* in the bottom right: ![Activity Log screen after clicking show source](./microblog/activity-log-2.png) [ActivityPub.Academy]: https://activitypub.academy/ ### Testing Now that we've confirmed that the activity was sent well, it's time to check if our inbox code is working properly. First, let's see if a record was created properly in the `follows` table: ```sh echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3 ``` If the follow request was processed successfully, you should see a result like this (of course, the time will be different): | `following_id` | `follower_id` | `created` | | -------------- | ------------- | --------------------- | | `1` | `2` | `2024-09-01 10:19:41` | Let's also check if a new record was created in the `actors` table: ```sh echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3 ``` | `id` | `user_id` | `uri` | `handle` | `name` | `inbox_url` | `shared_inbox_url` | `url` | `created` | | ---- | --------- | ------------------------------------------------------ | ----------------------------------------- | -------------------- | ------------------------------------------------------------ | ----------------------------------- | ------------------------------------------------- | --------------------- | | `2` | | `https://activitypub.academy/users/dobussia_dovornath` | `@dobussia_dovornath@activitypub.academy` | `Dobussia Dovornath` | `https://activitypub.academy/users/dobussia_dovornath/inbox` | `https://activitypub.academy/inbox` | `https://activitypub.academy/@dobussia_dovornath` | `2024-09-01 10:19:41` | Now, let's look at ActivityPub.Academy's *Activity Log* again. If the `Accept(Follow)` activity sent by our actor arrived well, it should be displayed as follows: ![Accept(Follow) activity displayed in Activity Log](./microblog/activity-log-3.png) This way, you've implemented your first interaction via ActivityPub! ## Unfollow What happens if an actor from another server unfollows our actor after following it? Let's test this in [ActivityPub.Academy]. As before, enter our actor's fediverse handle in the ActivityPub.Academy search box: ![Search results in ActivityPub.Academy](./microblog/academy-search-results-2.png) If you look closely, you'll see an unfollow button in place of the follow button to the right of the actor name. Click this button to unfollow, then go to the *Activity Log* to see what activity is sent: ![Activity Log showing the sent Undo(Follow) activity](./microblog/activity-log-4.png) As you can see, an `Undo(Follow)` activity has been sent. If you click *show source* in the bottom right, you can see the detailed contents of the activity: ```json { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo", "type": "Undo", "actor": "https://activitypub.academy/users/dobussia_dovornath", "object": { "id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694", "type": "Follow", "actor": "https://activitypub.academy/users/dobussia_dovornath", "object": "https://temp-address.serveo.net/users/johndoe" } } ``` Looking at this JSON object, you can see that the `Undo(Follow)` activity includes the `Follow` activity that was received by our inbox earlier. However, since we haven't defined any behavior for when the inbox receives an `Undo(Follow)` activity, nothing has happened. ### Receiving `Undo(Follow)` activity To implement unfollow, open the *src/federation.ts* file and `import` the `Undo` class provided by Fedify: ```typescript twoslash [src/federation.ts] import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Accept, Endpoints, Follow, Person, Undo, // [!code highlight] getActorHandle, } from "@fedify/vocab"; ``` Then add `on(Undo, ...)` in succession after `on(Follow, ...)`: ```typescript{6-23} twoslash [src/federation.ts] // @errors: 1160 import { type Federation } from "@fedify/fedify"; import { Follow, Undo } from "@fedify/vocab"; const federation = null as unknown as Federation; import Database from "better-sqlite3"; const db = new Database(""); // ---cut-before--- federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { // ... omitted ... }) .on(Undo, async (ctx, undo) => { const object = await undo.getObject(); if (!(object instanceof Follow)) return; if (undo.actorId == null || object.objectId == null) return; const parsed = ctx.parseUri(object.objectId); if (parsed == null || parsed.type !== "actor") return; db.prepare( ` DELETE FROM follows WHERE following_id = ( SELECT actors.id FROM actors JOIN users ON actors.user_id = users.id WHERE users.username = ? ) AND follower_id = (SELECT id FROM actors WHERE uri = ?) `, ).run(parsed.identifier, undo.actorId.href); }); ``` This time, the code is shorter than when processing follow requests. It checks if the thing inside the `Undo(Follow)` activity is a `Follow` activity, uses the `parseUri()` method to check if the follow target of the `Follow` activity to be canceled is our actor, and then deletes the corresponding record from the `follows` table. ### Testing We can't unfollow once more since we already clicked the unfollow button in [ActivityPub.Academy] earlier. We'll have to follow again and then unfollow to test. But before that, we need to empty the `follows` table. Otherwise, there will be an error when the follow request comes in because the record already exists. Let's empty the `follows` table using the `sqlite3` command: ```sh echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3 ``` Now press the follow button again, then check the database: ```sh echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3 ``` If the follow request was processed successfully, you should see a result like this: | `following_id` | `follower_id` | `created` | | -------------- | ------------- | --------------------- | | `1` | `2` | `2024-09-02 01:05:17` | Now press the unfollow button again, then check the database one more time: ```sh echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3 ``` If the unfollow request was processed successfully, the record should have disappeared, so you should see a result like this: | `count(*)` | | ---------- | | `0` | ## Followers list It's cumbersome to view the followers list using the `sqlite3` command every time, so let's make it possible to view the followers list on the web. Let's start by adding a new component to the *src/views.tsx* file. First, `import` the Actor type: ```typescript twoslash [src/views.tsx] // @noErrors: 2307 import type { Actor } from "./schema.ts"; ``` Then define the `` component and the `` component: ```tsx twoslash [src/views.tsx] import type { FC } from "hono/jsx"; interface Actor { id: number; uri: string; name: string | null; handle: string; url: string | null; } // ---cut-before--- export interface FollowerListProps { followers: Actor[]; } export const FollowerList: FC = ({ followers }) => ( <>

Followers

    {followers.map((follower) => (
  • ))}
); export interface ActorLinkProps { actor: Actor; } export const ActorLink: FC = ({ actor }) => { const href = actor.url ?? actor.uri; return actor.name == null ? (
{actor.handle} ) : ( <> {actor.name}{" "} ( {actor.handle} ) ); }; ``` The `` component is used to represent a single actor, and the `` component uses the `` component to represent the list of followers. As you can see, since JSX doesn't have conditional statements or loops, we're using the ternary operator and the [`Array.map()`] method. Now let's create an endpoint to display the followers list. Open the *src/app.tsx* file and `import` the `` component: ```typescript twoslash [src/app.tsx] // @noErrors: 2307 import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx"; ``` Then add a request handler for `GET /users/{username}/followers`: ```tsx twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export const FollowerList: FC = () => <>; import Database from "better-sqlite3"; const db = new Database(""); interface Actor {} // ---cut-before--- app.get("/users/:username/followers", async (c) => { const followers = db .prepare( ` SELECT followers.* FROM follows JOIN actors AS followers ON follows.follower_id = followers.id JOIN actors AS following ON follows.following_id = following.id JOIN users ON users.id = following.user_id WHERE users.username = ? ORDER BY follows.created DESC `, ) .all(c.req.param("username")); return c.html( , ); }); ``` Now, shall we check if it's displaying correctly? There should be followers, so with `fedify tunnel` running, follow our actor from another Mastodon server or [ActivityPub.Academy]. After the follow request is accepted, open the page in your web browser, and you should see something like this: ![Followers list page](./microblog/followers-list.png) Now that we've created the followers list, it would be nice to display the number of followers on the profile page as well. Open the *src/views.tsx* file again and modify the `` component as follows: ```tsx{20-23} twoslash [src/views.tsx] import type { FC } from "hono/jsx"; // ---cut-before--- export interface ProfileProps { name: string; username: string; // [!code highlight] handle: string; followers: number; // [!code highlight] } export const Profile: FC = ({ name, username, // [!code highlight] handle, followers, // [!code highlight] }) => ( <>

{name}

{handle} ·{" "} {followers === 1 ? "1 follower" : `${followers} followers`}

); ``` Two props have been added to `ProfileProps`. `followers` is a prop that holds the number of followers, as the name suggests. `username` receives the username that will go into the URL to link to the followers list. Now go back to the *src/app.tsx* file and modify the `GET /users/{username}` request handler as follows: ```tsx{5-15,21,23} twoslash [src/app.tsx] import { Hono } from "hono"; const app = new Hono(); import type { FC } from "hono/jsx"; export const Layout: FC = (props) => ; export interface ProfileProps { name: string; username: string; handle: string; followers: number; } export const Profile: FC = () => <>; import Database from "better-sqlite3"; const db = new Database(""); interface User { id: number; username: string; } interface Actor { name: string; } const user = {} as unknown as User & Actor; const handle = "" as string; // ---cut-before--- app.get("/users/:username", async (c) => { // ... omitted ... if (user == null) return c.notFound(); // oxlint-disable-next-line typescript/no-non-null-assertion -- Always returns a single record const { followers } = db .prepare( ` SELECT count(*) AS followers FROM follows JOIN actors ON follows.following_id = actors.id WHERE actors.user_id = ? `, ) .get(user.id)!; // ... omitted ... return c.html( , ); }); ``` SQL that counts the number of records in the `follows` table in the database has been added. Now, let's check the changed profile page. When you open the page in your web browser, you should see something like this: ![Changed profile page](./microblog/profile-page-3.png) [`Array.map()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map ## Followers collection However, there's one problem. Let's look up our actor from a Mastodon server that is *not* ActivityPub.Academy. (You know how to look it up, right? With the server exposed to the public internet, enter the actor's handle in the Mastodon search box.) When you view our actor's profile in Mastodon, you might notice something strange: ![Our actor's profile viewed in Mastodon](./microblog/remote-profile-2.png) The number of followers is shown as 0. This is because our actor is not exposing the followers list via ActivityPub. To expose the followers list in ActivityPub, we need to define a followers collection. Open the *src/federation.ts* file and `import` the `Recipient` type provided by Fedify: ```typescript twoslash [src/federation.ts] import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, } from "@fedify/fedify"; import { Accept, Endpoints, Follow, Person, Undo, getActorHandle, type Recipient, // [!code highlight] } from "@fedify/vocab"; ``` Then add a followers collection dispatcher at the bottom: ```typescript twoslash [src/federation.ts] import { type Federation } from "@fedify/fedify"; import type { Recipient } from "@fedify/vocab"; const federation = null as unknown as Federation; import Database from "better-sqlite3"; const db = new Database(""); interface Actor { uri: string; inbox_url: string; shared_inbox_url: string | null; } // ---cut-before--- federation .setFollowersDispatcher( "/users/{identifier}/followers", (ctx, identifier, cursor) => { const followers = db .prepare( ` SELECT followers.* FROM follows JOIN actors AS followers ON follows.follower_id = followers.id JOIN actors AS following ON follows.following_id = following.id JOIN users ON users.id = following.user_id WHERE users.username = ? ORDER BY follows.created DESC `, ) .all(identifier); const items: Recipient[] = followers.map((f) => ({ id: new URL(f.uri), inboxId: new URL(f.inbox_url), endpoints: f.shared_inbox_url == null ? null : { sharedInbox: new URL(f.shared_inbox_url) }, })); return { items }; }, ) .setCounter((ctx, identifier) => { const result = db .prepare( ` SELECT count(*) AS cnt FROM follows JOIN actors ON actors.id = follows.following_id JOIN users ON users.id = actors.user_id WHERE users.username = ? `, ) .get(identifier); return result == null ? 0 : result.cnt; }); ``` The `~Federatable.setFollowersDispatcher()` method creates a followers 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 { readonly id: URL | null; readonly inboxId: URL | null; readonly endpoints?: { sharedInbox: URL | null; } | null; } ``` The `id` property contains the actor's unique IRI, and `inboxId` contains the URL of the actor's personal inbox. `endpoints.sharedInbox` contains the URL of the actor's shared inbox. Since we have all that information in 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 `identifier` parameter. Now, let's check if the followers collection is working properly by using the `fedify lookup` command: ```sh fedify lookup http://localhost:8000/users/johndoe/followers ``` If implemented correctly, you should see a result like this: ```console ✔ Looking up the object... OrderedCollection { totalItems: 1, items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ] } ``` However, just creating a followers collection like this doesn't let other servers know where the followers collection is. So we need to link to the followers collection in the actor dispatcher: ```typescript twoslash [src/federation.ts] import { type Federation } from "@fedify/fedify"; import { Person } from "@fedify/vocab"; const federation = null as unknown as Federation; // ---cut-before--- federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { // ... omitted ... return new Person({ // ... omitted ... followers: ctx.getFollowersUri(identifier), // [!code highlight] }); }) ``` Let's look up the actor with `fedify lookup` again: ```sh fedify lookup http://localhost:8000/users/johndoe ``` If you see a `"followers"` property included in the result as shown below, it's correct: ```console ✔ Looking up the object... Person { ... omitted ... inbox: URL "http://localhost:8000/users/johndoe/inbox", followers: URL "http://localhost:8000/users/johndoe/followers", endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" } } ``` Now, let's look up our actor in Mastodon again. But the result might be a bit disappointing: ![Our actor's profile viewed again in Mastodon](./microblog/remote-profile-2.png) The number of followers is still shown as 0. This is because Mastodon caches information about actors from other servers. There are ways to update this, but they're not as easy as pressing the F5 key: * One way is to wait for a week. Mastodon clears the cache that holds information about actors from other servers 7 days after the last update. * Another way is to send an `Update` activity, but this requires tedious coding. * Or you could try looking it up from another Mastodon server where the cache hasn't been created yet. * The last method is to turn off and on `fedify tunnel` to get a new temporary domain assigned. If you want to see the correct number of followers displayed on another Mastodon server, try one of the methods I've listed. ## Posts Now, it's finally time to implement posts. Unlike a typical blog, the microblog we're creating should be able to store posts created on other servers as well. Let's design with this in mind. ### Table creation Let's start by creating a `posts` table. Open the *src/schema.sql* file and add the following SQL: ```sql [src/schema.sql] CREATE TABLE IF NOT EXISTS posts ( id INTEGER NOT NULL PRIMARY KEY, uri TEXT NOT NULL UNIQUE CHECK (uri <> ''), actor_id INTEGER NOT NULL REFERENCES actors (id), content TEXT NOT NULL, url TEXT CHECK (url LIKE 'https://%' OR url LIKE 'http://%'), created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '') ); ``` * The `id` column is the primary key of the table. * The `uri` column holds the unique URI of the post. As mentioned earlier, all ActivityPub objects must have a unique URI. * The `actor_id` column points to the actor who wrote the post. * The `content` column contains the content of the post. * The `url` column contains the URL where the post is displayed in a web browser. There are cases where the URI of an ActivityPub object and the URL of the page displayed in a web browser match, but there are also cases where they don't, so a separate column is necessary. However, it can be empty. * The `created` column contains the time the post was created. Let's execute the SQL to create the `posts` table: ```sh sqlite3 microblog.sqlite3 < src/schema.sql ``` Also define a `Post` type in the *src/schema.ts* file to represent records that will be stored in the `posts` table in JavaScript: ```typescript twoslash [src/schema.ts] export interface Post { id: number; uri: string; actor_id: number; content: string; url: string | null; created: string; } ``` ### Home page To write a post, there needs to be a form somewhere, right? Come to think of it, we haven't properly created the home page yet. Let's add a post creation form to the home page. First, open the *src/views.tsx* file and `import` the `User` type: ```typescript twoslash [src/views.tsx] // @noErrors: 2307 import type { Actor, User } from "./schema.ts"; ``` Then define the `` component: ```tsx twoslash [src/views.tsx] import type { FC } from "hono/jsx"; interface User { username: string; } interface Actor { name: string; } // ---cut-before--- export interface HomeProps { user: User & Actor; } export const Home: FC = ({ user }) => ( <>

{user.name}'s microblog

{user.name}'s profile

Cancel
``` The `Compose` button in *app.vue* already points at */compose*, so the new page is reachable from anywhere. The `
` POSTs straight to */api/posts* with `enctype="multipart/form-data"`, which is the only required ingredient for browsers to upload binary data without JavaScript. ### Trying it out Open and you should see: ![PxShare compose page. An Image file picker, an empty Caption textarea, and a pink Post button on the right.](./content-sharing/compose-form.png) Pick any small image, add a caption, and click *Post*. The browser follows the `303` redirect back to alice's profile. The post itself does not show on the profile yet; [*Profile feed*](#profile-feed) adds the grid of posts that pulls from the new table. But you can confirm the row was written: ```sh sqlite3 -header -column content-sharing.sqlite3 "SELECT * FROM posts" ``` | `id` | `user_id` | `caption` | `media_path` | `media_type` | `created_at` | | ---- | --------- | ----------------------- | ------------------------------------------------ | ------------ | --------------------- | | `1` | `1` | `Hello from chapter 14` | `alice/5a1d45d6-74f6-48fe-a578-ed33e62d478b.png` | `image/png` | `2026-04-26 01:23:43` | …and the file lives where we asked Nuxt to serve it: ```sh curl -I "http://localhost:3000/uploads/alice/5a1d45d6-74f6-48fe-a578-ed33e62d478b.png" ``` ```console HTTP/1.1 200 OK Content-Type: image/png Content-Length: ... ``` The next chapter teaches Fedify to serve those rows as ActivityPub `Note` objects so other servers can fetch them. ## `Note` object dispatcher We have rows in `posts` and a way to add new ones, but other servers cannot see them yet. An ActivityPub post is a [`Note`] object with at least an `id`, an `attributedTo` actor, and some content. Image posts also carry a [`Document`] attachment. Fedify's `~Federatable.setObjectDispatcher()` lets us declare a URL template, give it a typed callback, and have all the routing and content negotiation handled for us. [`Note`]: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note [`Document`]: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document ### Adding the dispatcher Open *server/federation.ts*. Add `Document`, `Note`, and `PUBLIC_COLLECTION` to the `@fedify/vocab` import, pull in the `Temporal` polyfill, and add `posts` to the schema import: ```typescript twoslash [server/federation.ts] // @noErrors: 2304 2307 2322 import { Accept, Document, Endpoints, Follow, getActorHandle, Note, Person, PUBLIC_COLLECTION, type Recipient, Undo, } from "@fedify/vocab"; import { Temporal } from "temporal-polyfill"; // ... import { actorKeys, followers, posts, users } from "./db/schema"; ``` We also need the polyfill itself in *package.json*: ```sh npm install temporal-polyfill ``` Node.js 22 does not yet ship `Temporal` natively, and Fedify uses `Temporal.Instant` for ActivityPub timestamps; the polyfill bridges the gap. > \[!NOTE] > Once Node.js ships [`Temporal`] natively (currently behind a > flag in 22.x and slated to be on by default in a near-term > release), the polyfill goes away. Fedify keeps using > `Temporal.Instant` regardless, so this is the only line that > needs to change. Then add a final dispatcher block after the followers dispatcher: ```typescript twoslash [server/federation.ts] // @noErrors: 2304 2307 7006 import { type Federation } from "@fedify/fedify"; import { Document, Note, PUBLIC_COLLECTION } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; import { db } from "./db/client"; import { posts, users } from "./db/schema"; const federation = null as unknown as Federation; // ---cut-before--- federation.setObjectDispatcher( Note, "/users/{identifier}/posts/{id}", async (ctx, { identifier, id }) => { const postId = Number(id); if (!Number.isInteger(postId) || postId < 1) return null; const localUser = ( await db .select() .from(users) .where(eq(users.username, identifier)) .limit(1) )[0]; if (localUser === undefined) return null; const post = ( await db .select() .from(posts) .where(and(eq(posts.id, postId), eq(posts.userId, localUser.id))) .limit(1) )[0]; if (post === undefined) return null; const mediaUrl = new URL(`/uploads/${post.mediaPath}`, ctx.canonicalOrigin); const noteId = ctx.getObjectUri(Note, { identifier, id }); return new Note({ id: noteId, attribution: ctx.getActorUri(identifier), to: PUBLIC_COLLECTION, cc: ctx.getFollowersUri(identifier), url: noteId, content: post.caption ?? "", published: Temporal.Instant.from( `${post.createdAt.replace(" ", "T")}Z`, ), // Always emit `attachments` as an array; some peers (notably // Pixelfed) treat a scalar Document as an entirely different // shape and ignore the attached image. attachments: [ new Document({ mediaType: post.mediaType, url: mediaUrl, }), ], }); }, ); ``` Walking through the listener: `ctx.getObjectUri(Note, { identifier, id })` : Mints the canonical URI from the same template we registered the dispatcher with. Using the helper instead of building a URL ourselves means the URI updates if we ever change the template. `Number.isInteger(postId)` : Belt and suspenders: the route template only matches strings, but integer-shaped strings like `"1.2"` would still slip through. We normalize and reject anything that is not a positive integer. *The `userId` filter* : Even though only one local user exists today, we constrain the lookup to `posts.userId = localUser.id` so a future multi-user instance cannot have one user serve another's posts. `to: PUBLIC_COLLECTION`, `cc: ctx.getFollowersUri(identifier)` : Public posts are addressed to the magic `https://www.w3.org/ns/activitystreams#Public` URI on `to`, plus the followers collection on `cc`. Mastodon and Pixelfed both render this as a “public” post; either field alone is less consistent. `published: Temporal.Instant.from(...)` : SQLite stores `2026-04-26 01:23:43`, which is not an ISO 8601 instant. The dance with `replace(" ", "T")` and a trailing `Z` turns it into one before we hand it to Temporal. `attachments: [...]` : *Always* an array, even for a single attachment. Pixelfed in particular treats a scalar Document as a different shape and silently ignores it; emitting an array keeps interop predictable. See [PR #721] for the upstream wrapper that catches this for activities, but writing it idiomatically here saves us from depending on it. [`Temporal`]: https://tc39.es/proposal-temporal/docs/ [PR #721]: https://github.com/fedify-dev/fedify/pull/721 ### Looking the post up With the dev server restarted, ask Fedify to look up the post you created in the previous chapter: ```sh fedify lookup http://localhost:3000/users/alice/posts/1 ``` ```console ✔ Fetched object: http://localhost:3000/users/alice/posts/1 Note { id: URL 'http://localhost:3000/users/alice/posts/1', attachments: [ Document { url: URL 'http://localhost:3000/uploads/alice/.png', mediaType: 'image/png' } ], attribution: URL 'http://localhost:3000/users/alice', content: 'Hello from chapter 14', published: Instant [Temporal.Instant] {}, url: URL 'http://localhost:3000/users/alice/posts/1', to: URL 'https://www.w3.org/ns/activitystreams#Public', cc: URL 'http://localhost:3000/users/alice/followers' } ``` The attached `Document` lives at the same URL Nuxt serves the file from, so any peer that fetches this Note can also fetch the image directly. Posts are now individually addressable, but alice's profile page still says “No posts yet”. The next chapter renders posts in a grid on the profile. ## Profile feed Alice now has posts she can author and Notes other servers can fetch, but her own profile page is still stuck on “No posts yet.” This chapter renders the feed. ### A JSON endpoint for the posts list Create *server/api/users/\[username]/posts.get.ts*: ```typescript [server/api/users/[username]/posts.get.ts] import { desc, eq } from "drizzle-orm"; import { createError, defineEventHandler, getRouterParam } from "h3"; import { db } from "../../../db/client"; import { posts, users } from "../../../db/schema"; export default defineEventHandler(async (event) => { const username = getRouterParam(event, "username"); if (typeof username !== "string" || username === "") { throw createError({ statusCode: 404 }); } const user = ( await db.select().from(users).where(eq(users.username, username)).limit(1) )[0]; if (user === undefined) { throw createError({ statusCode: 404 }); } const rows = await db .select() .from(posts) .where(eq(posts.userId, user.id)) .orderBy(desc(posts.createdAt)); return { user, posts: rows }; }); ``` The endpoint mirrors the followers one from [*Followers list and collection*](#followers-list-and-collection): validate the username, resolve the user, return everything ordered newest-first. Real Pixelfed instances paginate this; we will revisit pagination in the closing chapter under “areas for improvement.” ### Surfacing the post count Open *server/api/users/\[username].get.ts* and add a matching `postCount` aggregate to the existing profile payload: ```typescript [server/api/users/[username].get.ts] import { count, eq } from "drizzle-orm"; import { createError, defineEventHandler, getRouterParam } from "h3"; import { db } from "../../db/client"; import { followers, posts, users } from "../../db/schema"; export default defineEventHandler(async (event) => { const username = getRouterParam(event, "username"); if (typeof username !== "string" || username === "") { throw createError({ statusCode: 404 }); } const user = ( await db.select().from(users).where(eq(users.username, username)).limit(1) )[0]; if (user === undefined) { throw createError({ statusCode: 404 }); } const [{ followerCount }] = await db .select({ followerCount: count() }) .from(followers) .where(eq(followers.followingId, user.id)); const [{ postCount }] = await db .select({ postCount: count() }) .from(posts) .where(eq(posts.userId, user.id)); return { user, followerCount, postCount }; }); ``` ### The grid on the profile page Rewrite *app/pages/users/\[username]/index.vue* to fetch both endpoints in parallel and render the post grid: ```vue [app/pages/users/[username]/index.vue] ``` The grid is three columns of square tiles, the same shape Pixelfed uses on its profile pages. Each tile is a `NuxtLink` pointing at */users/\/posts/\*; the route the next chapter will fill in. `object-cover` crops images that are not square so they fill the tile cleanly. ### Trying it out Refresh . Now the counter row shows the post count, and the grid renders a tile per row in `posts`: ![Alice's profile with the post count and a grid of square thumbnails.](./content-sharing/profile-feed.png) The counters and grid update on every page load; there is no caching layer beyond the database itself, so freshly composed posts appear on the next refresh. The next chapter wires up the post detail route the tiles already link to, so clicking a tile leads somewhere instead of 404ing. ## Post detail page The grid tiles from [*Profile feed*](#profile-feed) link at */users/\/posts/\* but that route does not exist yet. This chapter adds the page that goes there: a single image at full bleed, the caption, a timestamp, and the Open Graph metadata that turns the URL into a rich preview when it is shared. ### A JSON endpoint for one post Create *server/api/users/\[username]/posts/\[id].get.ts*: ```typescript [server/api/users/[username]/posts/[id].get.ts] import { and, eq } from "drizzle-orm"; import { createError, defineEventHandler, getRouterParam } from "h3"; import { db } from "../../../../db/client"; import { posts, users } from "../../../../db/schema"; export default defineEventHandler(async (event) => { const username = getRouterParam(event, "username"); const idParam = getRouterParam(event, "id"); if (typeof username !== "string" || username === "") { throw createError({ statusCode: 404 }); } const id = Number(idParam); if (!Number.isInteger(id) || id < 1) { throw createError({ statusCode: 404 }); } const user = ( await db.select().from(users).where(eq(users.username, username)).limit(1) )[0]; if (user === undefined) { throw createError({ statusCode: 404 }); } const post = ( await db .select() .from(posts) .where(and(eq(posts.id, id), eq(posts.userId, user.id))) .limit(1) )[0]; if (post === undefined) { throw createError({ statusCode: 404 }); } return { user, post }; }); ``` The endpoint validates both pieces of the URL (username + integer id) and constrains the lookup with both columns. That makes */users/alice/posts/2* a 404 if post id 2 actually belongs to *bob*; the URL stays canonical even if the schema later opens up to multiple local users. ### The Vue page Create *app/pages/users/\[username]/posts/\[id].vue*: ```vue [app/pages/users/[username]/posts/[id].vue] ``` A few details worth calling out: `max-h-[80vh] object-contain` : Wide-aspect images get the full viewport width; portrait images do not blow past 80% of the viewport height. The container's gray rectangle fills the rest, giving every post a consistent visual weight regardless of orientation. *Open Graph metadata* : When somebody pastes the post URL into Slack, Mastodon, or any rich-link target, the embed shows the image and caption instead of just the URL. `og:image` points at the same static-asset path Nuxt already serves; no separate preview pipeline. *Locale-aware timestamp* : `new Date(post.createdAt + "Z").toLocaleString()` parses the SQLite string as UTC and formats it in the visitor's locale. The closing chapter's “areas for improvement” lists relative timestamps as a natural extension. ### Trying it out Click any post tile from the profile. The detail page renders the image, the caption, and the timestamp: ![Post detail page for a sample image, with the image filling the content column and a caption beneath it.](./content-sharing/post-detail.png) The image still has the original dimensions we uploaded; the container handles the layout. The next chapter teaches alice to *push* posts: when she composes one, our server sends a `Create(Note)` to every follower's inbox so the post lands in their home timeline. ## Distributing new posts to followers So far alice can post, and other servers can fetch each post through the object dispatcher. But fetching is *pull*, and nothing makes a remote follower notice a new post unless they think to refetch alice's profile. Federation needs *push*: whenever alice composes, we should send a `Create(Note)` activity to every follower's inbox so the post lands in their home timeline straight away. This is the moment the rest of the fediverse stops being a curiosity and starts being a place: alice's friends on Mastodon or Pixelfed see her photos in their feeds without ever knowing PxShare exists. ### Refactor: a reusable `buildNote` The Note we construct inside `~Federatable.setObjectDispatcher()` in [*`Note` object dispatcher*](#note-object-dispatcher) is exactly what we want to wrap in a `Create` when alice composes. Pull that body into a small helper so the two callers share one definition. Open *server/federation.ts* and add the helper just above the existing object-dispatcher block: ```typescript [server/federation.ts] import type { Context } from "@fedify/fedify"; // …existing imports… import { Accept, Create, Document, // … } from "@fedify/vocab"; // …actor / followers / inbox setup unchanged… // Build a Note for a stored post. Shared by the object // dispatcher (so other servers can fetch a single post) and the // compose endpoint (so we can wrap the Note in a Create activity // and fan it out to followers). export function buildNote( ctx: Context, identifier: string, post: { id: number; caption: string | null; mediaPath: string; mediaType: string; createdAt: string; }, ): Note { const noteId = ctx.getObjectUri(Note, { identifier, id: String(post.id), }); return new Note({ id: noteId, attribution: ctx.getActorUri(identifier), to: PUBLIC_COLLECTION, cc: ctx.getFollowersUri(identifier), url: noteId, content: post.caption ?? "", published: Temporal.Instant.from(`${post.createdAt.replace(" ", "T")}Z`), attachments: [ new Document({ mediaType: post.mediaType, url: new URL(`/uploads/${post.mediaPath}`, ctx.canonicalOrigin), }), ], }); } ``` Then collapse the `setObjectDispatcher()` body to delegate to the helper: ```typescript [server/federation.ts] federation.setObjectDispatcher( Note, "/users/{identifier}/posts/{id}", async (ctx, { identifier, id }) => { const postId = Number(id); if (!Number.isInteger(postId) || postId < 1) return null; const localUser = ( await db .select() .from(users) .where(eq(users.username, identifier)) .limit(1) )[0]; if (localUser === undefined) return null; const post = ( await db .select() .from(posts) .where(and(eq(posts.id, postId), eq(posts.userId, localUser.id))) .limit(1) )[0]; if (post === undefined) return null; return buildNote(ctx, identifier, post); }, ); ``` > \[!TIP] > Why drop a typed `Context` in here? > `~Federatable.setObjectDispatcher()` already gives the dispatcher callback a > `Context` whose `contextData` is the federation's context-data type > (we have not customized it, so it is `unknown`). The compose endpoint will > obtain the same `Context` from `federation.createContext()`, and > that returns a `Context` of the same shape. Typing the parameter once lets > both callers feed in their own `Context` without copy-pasting generic > parameters. ### Send `Create(Note)` from the compose endpoint Now hook the fan-out into *server/api/posts.post.ts*. We want to: 1. Insert the row (already done) but capture the inserted record so we know its id and timestamp without an extra read. 2. Build a Note from that record using `buildNote`. 3. Wrap the Note in a `Create` activity. 4. Hand the activity to Fedify with the magic recipient `"followers"` so it expands into every follower's inbox. Open *server/api/posts.post.ts* and update the imports plus the section after the file write: ```typescript twoslash [server/api/posts.post.ts] // @noErrors: 2304 2307 2552 18004 import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { Create } from "@fedify/vocab"; // [!code ++] import { eq } from "drizzle-orm"; // [!code ++] import { createError, defineEventHandler, readMultipartFormData, sendRedirect, toWebRequest, // [!code ++] } from "h3"; import { db } from "../db/client"; import { posts } from "../db/schema"; import federation, { buildNote } from "../federation"; // [!code ++] import { getLocalUser } from "../utils/users"; // …ALLOWED_TYPES, MAX_BYTES, EXTENSION unchanged… export default defineEventHandler(async (event) => { // …auth, multipart parsing, validation, and writeFile() unchanged… const mediaPath = `${user.username}/${filename}`; const [inserted] = await db // [!code ++] .insert(posts) .values({ userId: user.id, caption, mediaPath, mediaType: imageType, }) .returning(); // [!code ++] // Fan out a Create(Note) to every follower's inbox. Fedify // [!code ++] // expands the magic "followers" target into the right inbox or // [!code ++] // sharedInbox URLs and queues the deliveries through the // [!code ++] // message queue, so the request returns quickly even for big // [!code ++] // follower lists. // [!code ++] const ctx = federation.createContext(toWebRequest(event), undefined); // [!code ++] const note = buildNote(ctx, user.username, inserted); // [!code ++] await ctx.sendActivity( // [!code ++] { identifier: user.username }, // [!code ++] "followers", // [!code ++] new Create({ // [!code ++] id: new URL("#create", note.id ?? ""), // [!code ++] actor: ctx.getActorUri(user.username), // [!code ++] to: note.toIds[0] ?? null, // [!code ++] cc: note.ccIds[0] ?? null, // [!code ++] object: note, // [!code ++] published: note.published, // [!code ++] }), // [!code ++] ); // [!code ++] return await sendRedirect(event, `/users/${user.username}`, 303); }); ``` Three details worth pausing on: `.returning()` *(Drizzle)* : Drizzle's SQLite driver supports SQL's `RETURNING` clause, which gives us back the row exactly as it was written, with the auto-incremented `id` and the database-generated `createdAt`. Without it we would have to re-`SELECT` after the insert. `federation.createContext()` : Inbox listeners and dispatchers receive a `Context` for free. Anywhere else, including a route handler outside Fedify's own routes, we ask the federation for one. `~Federation.createContext()` needs the request so it can resolve the canonical origin from the *Host* and *X-Forwarded-Host* headers. The second argument is the per-request context data; we passed `undefined` because PxShare does not use a context-data payload. `"followers"` *as a sendActivity recipient* : Fedify recognizes this magic string and consults the followers dispatcher we wrote in [*Followers list and collection*](#followers-list-and-collection). It walks the result, deduplicates by shared-inbox URL where one is advertised, and queues a delivery for each unique endpoint. The compose request returns immediately; the actual HTTP POSTs happen in the message queue worker, signed with alice's keys. > \[!NOTE] > The `to`/`cc` fields on the `Create` repeat the Note's > recipients. Some peers (most notably Mastodon) read the > activity envelope before they look at the embedded object, and > they decide a status is public when the *activity*'s `to` > contains the public collection. Mirroring the Note's audience > onto the Create keeps both readings consistent. ### Trying it out Make sure a Pixelfed account is following alice first; chapter 10 covered the round trip end-to-end. With a follower in place, restart the dev server, open the *Compose* form on the tunnel URL (so the canonical origin in the activity matches the keys alice signs with), pick an image, write a caption, and hit *Post*. The dev server logs the delivery the moment Fedify drains the queue: ```ansi [terminal] ℹ INF fedify·federation·outbox Successfully sent activity 'https://your-tunnel.example/users/alice/posts/7#create' to 'https://your-pixelfed.example/users/tester/inbox'. ``` Refresh the Pixelfed account's *Home Feed*: alice's image lands on the feed straight away, captioned and ready to like: ![Pixelfed's home feed rendering alice's foggy beach photo under her handle, with the caption “Pixelfed federation debug post.” beneath the image.](./content-sharing/pixelfed-home-with-alice-post.png) This is the round trip the whole tutorial has been pointing at: alice composes locally, the fediverse sees it within a second, and Pixelfed renders it as a first-class post inside its own feed. Mastodon and GoToSocial accept the same activity and behave the same way. > \[!TIP] > If alice's post does not show up, the most common causes are > a stale tunnel URL (the activity went out with the wrong > canonical origin), the Pixelfed instance running on an older > queue worker, or, on Pixelfed specifically, the > `firstKnock` setting in *server/federation.ts* not being set > to `"draft-cavage-http-signatures-12"`. Chapter 10 covers > that flag in detail. Once Pixelfed shows the post, the federation loop is real: alice posts, the fediverse sees it. The next chapter teaches alice to follow *back*: discovering remote actors, sending a `Follow`, and handling the `Accept` that completes the relationship. ## Following remote accounts Federation has been one-directional so far: remote servers can follow alice, but alice cannot follow them. This chapter closes the loop. By the end alice will be able to paste a fediverse handle into a form and end up with a recorded relationship that remote `Create(Note)` activities can address as inbox. ### A `following` table Mirror the *followers* table from [*Handling follows*](#handling-follows), with two new columns: `status` : either `"pending"` (the moment we send the `Follow`) or `"accepted"` (after the remote server confirms). Showing the relationship as pending until the `Accept` arrives matches what Mastodon's own UI does for the same case, and avoids surprising alice if the remote server drops the request. `followActivityId` : the `id` we put on the outbound `Follow`. Most peers echo this back as the embedded object of `Accept`, which gives us a precise match when several pending follows are in flight at once. Append the table to *server/db/schema.ts*: ```typescript twoslash [server/db/schema.ts] import { sql } from "drizzle-orm"; import { check, integer, primaryKey, sqliteTable, text, } from "drizzle-orm/sqlite-core"; export const users = sqliteTable( "users", { id: integer("id").primaryKey({ autoIncrement: false }), username: text("username").notNull(), name: text("name").notNull(), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => [check("users_single_user", sql`${t.id} = 1`)], ); // ---cut--- export const following = sqliteTable( "following", { followerId: integer("follower_id") .notNull() .references(() => users.id), actorUri: text("actor_uri").notNull(), handle: text("handle").notNull(), name: text("name"), inboxUrl: text("inbox_url").notNull(), sharedInboxUrl: text("shared_inbox_url"), url: text("url"), status: text("status", { enum: ["pending", "accepted"] }) .notNull() .default("pending"), followActivityId: text("follow_activity_id").notNull(), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => [primaryKey({ columns: [t.followerId, t.actorUri] })], ); export type Following = typeof following.$inferSelect; ``` Push the migration: ```sh npm run db:push ``` ### The `/follow` page Create *app/pages/follow.vue*. It is a thin form on top of `$fetch`, with a result paragraph that flips between idle, loading, success, and error. ```vue [app/pages/follow.vue] ``` The empty form looks like: ![The Follow page with an empty input box, placeholder “@dahlia@hollo.social or https://mastodon.social/@gargron”, and a Follow button on the right.](./content-sharing/follow-page-empty.png) Add a quick *Follow* link to the navbar in *app/app.vue* so the page is reachable without typing the URL: ```html [app/app.vue] ``` ### The follow endpoint Create *server/api/follow.post.ts*: ```typescript twoslash [server/api/follow.post.ts] // @noErrors: 2304 2307 import { Follow, getActorHandle, isActor } from "@fedify/vocab"; import { createError, defineEventHandler, readBody, toWebRequest, } from "h3"; import { db } from "../db/client"; import { following } from "../db/schema"; import federation from "../federation"; import { getLocalUser } from "../utils/users"; export default defineEventHandler(async (event) => { const user = await getLocalUser(); if (user == null) { throw createError({ statusCode: 403, statusMessage: "No user" }); } const body = await readBody<{ handle?: unknown }>(event); const handle = typeof body?.handle === "string" ? body.handle.trim() : ""; if (handle === "") { throw createError({ statusCode: 400, statusMessage: "Missing handle" }); } const ctx = federation.createContext(toWebRequest(event), undefined); const actor = await ctx.lookupObject(handle, { documentLoader: await ctx.getDocumentLoader({ identifier: user.username }), }); if (actor == null || !isActor(actor)) { throw createError({ statusCode: 404, statusMessage: `Could not resolve actor: ${handle}`, }); } if (actor.id == null || actor.inboxId == null) { throw createError({ statusCode: 422, statusMessage: "Resolved actor lacks an id or inbox", }); } const followActivityId = new URL( `#follows/${crypto.randomUUID()}`, ctx.getActorUri(user.username), ); const remoteHandle = await getActorHandle(actor); await db .insert(following) .values({ followerId: user.id, actorUri: actor.id.href, handle: remoteHandle, name: actor.name?.toString() ?? null, inboxUrl: actor.inboxId.href, sharedInboxUrl: actor.endpoints?.sharedInbox?.href ?? null, url: actor.url?.href ?? null, status: "pending", followActivityId: followActivityId.href, }) .onConflictDoNothing(); await ctx.sendActivity( { identifier: user.username }, actor, new Follow({ id: followActivityId, actor: ctx.getActorUri(user.username), object: actor.id, }), ); return { handle: remoteHandle, actorUri: actor.id.href, status: "pending" as const, }; }); ``` The interesting bits: `ctx.lookupObject()` : accepts whatever shape the user typed. An *@user@server* handle triggers a WebFinger request (resolves to an `acct:` URI, then to a profile URL); a profile URL is fetched directly. Pass `documentLoader` so the fetch is signed with alice's keys, which lets *authorized-fetch* instances (notably Mastodon with secure mode on) answer the request. `isActor()` : filters the result down to `Application | Group | Organization | Person | Service`. Anything else (a stray `Note` or `Image`) gets a 404 instead of crashing later. `onConflictDoNothing()` : makes following the same account twice a safe no-op. We keep the existing row's status because we may already have been accepted, and re-sending a `Follow` is harmless from the peer's perspective anyway. `new Follow({ id: …, actor: …, object: … })` : the bare minimum. The `id` is what the remote server is expected to embed back into the `Accept`'s `object` field so we can match the response to the right row. ### Accept inbox listener Add `Accept` (alongside the existing `Follow`) to the [`@fedify/vocab`][fedify-vocab] import, pull `or` into the drizzle-orm import, and bring in the `following` schema. Then chain an `Accept` handler at the end of the inbox-listener chain in *server/federation.ts*: ```typescript twoslash [server/federation.ts] // @noErrors: 2304 2307 import { createFederation, InProcessMessageQueue, MemoryKvStore, } from "@fedify/fedify"; import { Accept, Follow } from "@fedify/vocab"; import { eq, or } from "drizzle-orm"; import { db } from "./db/client"; import { following } from "./db/schema"; export const federation = createFederation({ kv: new MemoryKvStore(), queue: new InProcessMessageQueue(), }); federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut--- .on(Accept, async (_ctx, accept) => { // The remote server has accepted alice's outbound Follow. // Match the Accept against our `following` row by either the // original Follow's `id` (when the peer echoes it) or the // remote actor URI (Pixelfed sometimes mints a fresh id on the // way back), and flip the row's status to "accepted". if (accept.actorId == null) return; const followObject = await accept.getObject(); const followActivityId = followObject instanceof Follow ? followObject.id?.href : null; const remoteActorUri = accept.actorId.href; const matcher = followActivityId ? or( eq(following.followActivityId, followActivityId), eq(following.actorUri, remoteActorUri), ) : eq(following.actorUri, remoteActorUri); await db .update(following) .set({ status: "accepted" }) .where(matcher); }); ``` `accept.getObject()` resolves the embedded Follow. Mastodon and GoToSocial both keep the original Follow's `id`, which makes matching unambiguous. Pixelfed, in contrast, often returns a freshly-minted Follow with a different `id`, so even when an `id` is present it may not be the one we sent. Combining the two predicates with `or` lets the row flip to *accepted* whichever path the peer took. > \[!TIP] > If you ship multiple local users later (the closing chapter > lists this as a stretch goal), narrow the matcher to the > *follower* user too. The current single-user constraint > means an `Accept` from any peer always belongs to alice, but > with two local accounts you must look at *who* sent the > Follow before flipping a row. [fedify-vocab]: https://www.npmjs.com/package/@fedify/vocab ### Trying it out Restart the dev server, open the *Follow* page on the tunnel URL (so alice's signature points at the canonical origin), paste a remote handle, and click *Follow*. #### Following an ActivityPub.Academy account An ActivityPub.Academy account is the easiest first target: academy auto-accepts every Follow without manual intervention: ![The Follow page after submitting “@anbelia\_doshaelen@activitypub.academy”, with a green confirmation banner that reads “Sent a Follow to @anbelia\_doshaelen@activitypub.academy. It will move to ‘accepted’ once the remote server confirms.”](./content-sharing/follow-success.png) The dev server narrates the round trip in real time: ```ansi [terminal] ℹ INF fedify·federation·outbox Successfully sent activity 'https://your-tunnel.example/users/alice#follows/1f39…' to 'https://activitypub.academy/users/anbelia_doshaelen/inbox'. ℹ INF fedify·federation·inbox Activity 'https://activitypub.academy/users/anbelia_doshaelen#accepts/follows/16039' is enqueued. ℹ INF fedify·federation·http 'POST' '/users/alice/inbox': 202 ℹ INF fedify·federation·inbox Activity '…#accepts/follows/16039' has been processed. ``` Run a quick query to confirm the row flipped: ```sh sqlite3 content-sharing.sqlite3 "SELECT handle, status FROM following" ``` | `handle` | `status` | | ---------------------------------------- | ---------- | | `@anbelia_doshaelen@activitypub.academy` | `accepted` | #### Following a Pixelfed account Pixelfed is the second proof point and the more important one for this tutorial. Submit a Pixelfed account's handle in the same form. The dev server logs the same shape (just to a Pixelfed inbox), Pixelfed sends the `Accept` back essentially instantaneously, and the row in *following* flips to *accepted*: ```sh sqlite3 content-sharing.sqlite3 "SELECT handle, status FROM following" ``` | `handle` | `status` | | ---------------------------------------- | ---------- | | `@anbelia_doshaelen@activitypub.academy` | `accepted` | | `@tester@your-pixelfed.example` | `accepted` | On the Pixelfed side, log in to the account that just got followed and open the *Notifications* page: alice's follow appears in the list: ![Pixelfed's Notifications page showing one entry, “@alice@your-tunnel.example followed you.”](./content-sharing/pixelfed-follow-notification.png) > \[!NOTE] > Mastodon proper sometimes takes a few seconds to send the > `Accept`; Pixelfed is usually instantaneous. If the row > stays `pending`, check that the dev server's URL matches the > URL the actor was looked up from (canonical-origin mismatch > is the most common cause of dropped Accepts). Now alice can follow anyone in the fediverse, but the only proof of it is a row in SQLite. The next chapter renders that list as a Vue page so alice can see who she follows, and exposes it as an ActivityPub `Following` collection so peers can read it back. ## Following list This chapter is the symmetric counterpart of the followers list from [*Followers list and collection*](#followers-list-and-collection): an HTML page that shows who alice follows, plus an ActivityPub *OrderedCollection* peers can fetch. Both reuse the *following* table from [*Following remote accounts*](#following-remote-accounts), filtered to the rows whose status is `accepted`. ### A JSON endpoint Create *server/api/users/\[username]/following.get.ts*: ```typescript [server/api/users/[username]/following.get.ts] import { and, desc, eq } from "drizzle-orm"; import { createError, defineEventHandler, getRouterParam } from "h3"; import { db } from "../../../db/client"; import { following, users } from "../../../db/schema"; export default defineEventHandler(async (event) => { const username = getRouterParam(event, "username"); if (typeof username !== "string" || username === "") { throw createError({ statusCode: 404 }); } const user = ( await db.select().from(users).where(eq(users.username, username)).limit(1) )[0]; if (user === undefined) { throw createError({ statusCode: 404 }); } // Hide pending rows from the public list: an unconfirmed follow // is between alice and the remote server, not something to // advertise. const rows = await db .select() .from(following) .where( and(eq(following.followerId, user.id), eq(following.status, "accepted")), ) .orderBy(desc(following.createdAt)); return { user, following: rows }; }); ``` ### The Vue page Create *app/pages/users/\[username]/following.vue*: ```vue [app/pages/users/[username]/following.vue] ``` The *Unfollow* button calls a new `DELETE /api/follow` endpoint (written below). We track in-flight unfollows in a `pending` Set so each row's button can disable itself independently without freezing the whole list. ### The unfollow endpoint Create *server/api/follow.delete.ts*: ```typescript twoslash [server/api/follow.delete.ts] // @noErrors: 2304 2307 import { Follow, Undo } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; import { createError, defineEventHandler, readBody, toWebRequest, } from "h3"; import { db } from "../db/client"; import { following } from "../db/schema"; import federation from "../federation"; import { getLocalUser } from "../utils/users"; export default defineEventHandler(async (event) => { const user = await getLocalUser(); if (user == null) { throw createError({ statusCode: 403, statusMessage: "No user" }); } const body = await readBody<{ actorUri?: unknown }>(event); const actorUri = typeof body?.actorUri === "string" ? body.actorUri.trim() : ""; if (actorUri === "") { throw createError({ statusCode: 400, statusMessage: "Missing actorUri" }); } const [row] = await db .select() .from(following) .where( and(eq(following.followerId, user.id), eq(following.actorUri, actorUri)), ) .limit(1); if (row === undefined) { return { actorUri, following: false }; } // Drop the local row first so the UI reflects the unfollow even // if the remote inbox is slow. await db .delete(following) .where( and(eq(following.followerId, user.id), eq(following.actorUri, actorUri)), ); // Send an `Undo(Follow)` to the remote actor's inbox. Re-build // the original `Follow` activity (using the id we stored at // follow time so peers can match by id) and wrap it in `Undo`. const ctx = federation.createContext(toWebRequest(event), undefined); const aliceUri = ctx.getActorUri(user.username); const followActivityId = new URL(row.followActivityId); await ctx.sendActivity( { identifier: user.username }, { id: new URL(row.actorUri), inboxId: new URL(row.inboxUrl), endpoints: row.sharedInboxUrl == null ? null : { sharedInbox: new URL(row.sharedInboxUrl) }, }, new Undo({ id: new URL(`#undo-follows/${crypto.randomUUID()}`, aliceUri), actor: aliceUri, object: new Follow({ id: followActivityId, actor: aliceUri, object: new URL(row.actorUri), }), }), ); return { actorUri, following: false }; }); ``` Two details worth pointing out: `followActivityId` *(round-tripped through the database)* : Chapter 19 stored the outbound `Follow`'s id on the *following* row precisely so the unfollow path can echo it back inside the embedded `Follow`. Mastodon, Pixelfed, and GoToSocial all match incoming `Undo(Follow)` against their pending follow record by that id; without it the unfollow silently no-ops on the remote side. *The recipient is built inline* : `ctx.sendActivity()` accepts any object that implements the `Recipient` shape (an actor `id`, an `inboxId`, and an optional `endpoints.sharedInbox`). We have all three on the *following* row, so we hand them over directly and skip a `lookupObject` round trip. ### A counter on the profile Update *server/api/users/\[username].get.ts* to surface *followingCount* alongside the existing aggregates: ```typescript [server/api/users/[username].get.ts] import { and, count, eq } from "drizzle-orm"; import { createError, defineEventHandler, getRouterParam } from "h3"; import { db } from "../../db/client"; import { followers, following, posts, users } from "../../db/schema"; export default defineEventHandler(async (event) => { // …user lookup unchanged… const [{ followerCount }] = await db .select({ followerCount: count() }) .from(followers) .where(eq(followers.followingId, user.id)); const [{ followingCount }] = await db .select({ followingCount: count() }) .from(following) .where( and(eq(following.followerId, user.id), eq(following.status, "accepted")), ); const [{ postCount }] = await db .select({ postCount: count() }) .from(posts) .where(eq(posts.userId, user.id)); return { user, followerCount, followingCount, postCount }; }); ``` Then add a third pill to the profile header in *app/pages/users/\[username]/index.vue*: ```typescript [app/pages/users/[username]/index.vue] const followingCount = computed(() => profile.value?.followingCount ?? 0); ``` ```html [app/pages/users/[username]/index.vue] {{ followerCount }} {{ followerCount === 1 ? "follower" : "followers" }} {{ followingCount }} following ``` The header now reads *posts · followers · following*: ![alice's profile header showing “4 posts · 2 followers · 1 following”.](./content-sharing/profile-with-following-counter.png) Click *2 following* and the new page lists the accounts, each with an *Unfollow* button: ![alice's Following page showing two entries: a Pixelfed account “Tester” (@tester@your-pixelfed.example) and an ActivityPub.Academy account “Anbelia Doshaelen”, each with an Unfollow button on the right.](./content-sharing/following-list.png) Click *Unfollow* and the row disappears. The dev server logs the outbound `Undo(Follow)`: ```ansi [terminal] ℹ INF fedify·federation·outbox Successfully sent activity 'https://your-tunnel.example/users/alice#undo-follows/…' to 'https://your-pixelfed.example/users/tester/inbox'. ``` Refresh the Pixelfed account's *Followers* page (or query the database directly) and the alice row is gone. Re-click *Follow* on the Follow page to put the relationship back. ### The ActivityPub `Following` collection A peer that fetches alice's actor JSON should get a `following` URL back. Update the actor dispatcher in *server/federation.ts*: ```typescript [server/federation.ts] return new Person({ // … followers: ctx.getFollowersUri(identifier), following: ctx.getFollowingUri(identifier), // [!code ++] // … }); ``` Then add a sibling to the followers dispatcher that answers that URL: ```typescript [server/federation.ts] federation .setFollowingDispatcher( "/users/{identifier}/following", async (_ctx, identifier) => { const localUser = ( await db .select() .from(users) .where(eq(users.username, identifier)) .limit(1) )[0]; if (localUser === undefined) return null; const rows = await db .select() .from(following) .where( and( eq(following.followerId, localUser.id), eq(following.status, "accepted"), ), ) .orderBy(desc(following.createdAt)); const items = rows.map((row) => new URL(row.actorUri)); return { items }; }, ) .setCounter(async (_ctx, identifier) => { const localUser = ( await db .select() .from(users) .where(eq(users.username, identifier)) .limit(1) )[0]; if (localUser === undefined) return 0; const result = await db .select({ cnt: count() }) .from(following) .where( and( eq(following.followerId, localUser.id), eq(following.status, "accepted"), ), ); return result[0]?.cnt ?? 0; }); ``` Two differences worth pointing out compared to the followers dispatcher: 1. Items are *plain URL strings*, not `Recipient` objects. The `Recipient` shape exists so Fedify can deliver activities to followers; for *Following*, peers only need the actor URIs to fetch the actors themselves. 2. Both the dispatcher and its counter filter by `status = "accepted"`. A pending follow is not part of alice's public footprint until the remote server confirms. > \[!TIP] > Fedify validates that the URL on `actor.followingId` > matches the URL the dispatcher answers at. If they drift > apart (a typo, a route rename), the actor dispatcher will > throw at startup, which is the correct moment to discover the > mismatch. Verify the collection serves correctly: ```sh fedify lookup http://localhost:3000/users/alice/following ``` ``` OrderedCollection { id: URL "http://localhost:3000/users/alice/following", totalItems: 1, items: [ URL "https://activitypub.academy/users/anbelia_doshaelen" ], } ``` The list is real on both sides now: alice can see it in the browser, and any peer that walks her actor object can fetch it through the standard ActivityPub route. Next we make the relationship pay off: a *home timeline* page that shows posts the people alice follows have shared. ## Home timeline When the people alice follows post on their own servers, those servers send `Create(Note)` activities to alice's inbox. Up to this chapter we ignore them. Now we cache them in a small *timeline\_posts* table and render the result as the *home* page, the entry point alice lands on right after signup. ### A timeline cache table Append to *server/db/schema.ts*: ```typescript twoslash [server/db/schema.ts] import { sql } from "drizzle-orm"; import { check, integer, primaryKey, sqliteTable, text, } from "drizzle-orm/sqlite-core"; export const users = sqliteTable( "users", { id: integer("id").primaryKey({ autoIncrement: false }), username: text("username").notNull(), name: text("name").notNull(), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => [check("users_single_user", sql`${t.id} = 1`)], ); // ---cut--- export const timelinePosts = sqliteTable( "timeline_posts", { userId: integer("user_id") .notNull() .references(() => users.id), noteUri: text("note_uri").notNull(), authorActorUri: text("author_actor_uri").notNull(), authorHandle: text("author_handle").notNull(), authorName: text("author_name"), authorUrl: text("author_url"), caption: text("caption"), mediaUrl: text("media_url").notNull(), mediaType: text("media_type").notNull(), publishedAt: text("published_at").notNull(), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => [primaryKey({ columns: [t.userId, t.noteUri] })], ); export type TimelinePost = typeof timelinePosts.$inferSelect; ``` Two design points worth calling out: *Denormalized* : Storing *authorHandle*, *authorName*, *authorUrl* alongside *authorActorUri* means the home page can render entirely from one table. We pay a small write cost on every inbound Note, but reads stay flat and a remote profile rename only becomes stale until the *next* post from that author refreshes the row. *Composite primary key* : *(userId, noteUri)* makes redelivery a no-op. Mastodon and Pixelfed both retry deliveries when a peer responds slowly, and ActivityPub permits delivering the same activity to a shared inbox more than once. Without the unique key we would accumulate duplicates. Push the schema: ```sh npm run db:push ``` ### Sanitize remote captions before storing them Mastodon, Pixelfed, and most other fediverse servers send `Note.content` wrapped in HTML elements: `

` for paragraphs, `
` for line breaks, `` (often inside a ``) for mentions, hashtags, and plain links. Rendering that markup directly through Vue's `v-html` against a public inbox is an XSS surface, since any peer could push markup we did not author. We do *not* want to throw all of that markup away either; mentions and clickable links are part of what makes the home timeline recognizable. The middle ground is to run remote content through a sanitizer with a tight allow-list at write time, and store the cleaned HTML. Install [sanitize-html] and its types: ```sh npm install sanitize-html npm install -D @types/sanitize-html ``` Then add *server/utils/text.ts*: ```typescript [server/utils/text.ts] import sanitizeHtml from "sanitize-html"; const ALLOWED_TAGS = ["p", "br", "a", "span"]; // Note: `class` is intentionally not in `allowedAttributes`; it // is governed exclusively by `allowedClasses` below so peers can // only surface microformats values we recognize. const ALLOWED_ATTRS = { a: ["href", "rel", "target"], }; const ALLOWED_CLASSES = { a: ["mention", "hashtag", "u-url"], span: ["h-card", "invisible", "ellipsis"], }; const ALLOWED_SCHEMES = ["http", "https"]; export function sanitizeNoteContent(input: string): string { return sanitizeHtml(input, { allowedTags: ALLOWED_TAGS, allowedAttributes: ALLOWED_ATTRS, allowedClasses: ALLOWED_CLASSES, allowedSchemes: ALLOWED_SCHEMES, transformTags: { // Force a safe `rel` and `target` on every `` regardless // of what the peer sent. `nofollow noopener noreferrer ugc` // is the conventional set for user-generated remote links // and matches what Mastodon's web frontend renders. a: (_tagName, attribs) => ({ tagName: "a", attribs: { ...attribs, rel: "nofollow noopener noreferrer ugc", target: "_blank", }, }), }, }).trim(); } // Strip all markup from already-sanitized HTML so we can reuse // the caption in places where HTML is meaningless (`alt` // attributes, page titles, OpenGraph descriptions). `textFilter` // appends a space to each text node before the surrounding tags // are removed, which keeps block boundaries from running together // (e.g. `

one

two

` becomes `one two`, not `onetwo`). // The trailing whitespace is collapsed by the regex below. export function extractText(html: string): string { return sanitizeHtml(html, { allowedTags: [], allowedAttributes: {}, textFilter: (text) => `${text} `, }) .replace(/\s+/g, " ") .trim(); } ``` `sanitize-html` parses the input into a DOM, walks every node against the allow-list, and serializes the survivors back out. Anything outside the allow-list (including ` ``` > \[!TIP] > The `` tag uses `referrerpolicy="no-referrer"` because > some servers (Pixelfed especially) block hot-linked images > when the *Referer* header points at a different origin. > Stripping the referrer makes the request look like a direct > fetch, which most servers happily serve. ### Wire `/home` as the entry point Update the navbar in *app/app.vue* and the redirect in *app/pages/index.vue* so the home grid is the first thing alice sees on every login: ```html [app/app.vue] ``` ```typescript [app/pages/index.vue] if (data.value?.user) { await navigateTo("/home", { replace: true }); } ``` ### Trying it out Restart the dev server and post a photo from a Pixelfed account that alice follows (*Following remote accounts* covers the follow flow). Within a second or two alice's home page lights up: the 3-column grid renders one square thumbnail of the new photo, and clicking it opens the original on Pixelfed. We will rework this layout into stacked cards in [*`Like`s and `Undo(Like)`*](#likes-and-undo-like) so that hearts have somewhere to live; the screenshot below shows the later card form with two timeline entries, which is what alice ends up looking at after chapter 22. ![alice's home page rendered as a stacked single-column feed of two timeline cards: tester's foggy beach photograph from Pixelfed with the caption “Photo from tester for federation testing.” and a “0 likes” counter on top, and anbelia\_doshaelen's space scene with “1 like” underneath.](./content-sharing/home-with-pixelfed-post.png) The dev server narrates the inbound activity, and the database gains a row: ```ansi [terminal] ℹ INF fedify·federation·inbox Activity 'https://your-pixelfed.example/p/tester/953…/activity' is enqueued. ℹ INF fedify·federation·http 'POST' '/inbox': 202 ℹ INF fedify·federation·inbox Activity '…/activity' has been processed. ``` ```sh sqlite3 content-sharing.sqlite3 "SELECT note_uri, author_handle FROM timeline_posts" ``` | `note_uri` | `author_handle` | | --------------------------------------------- | ------------------------------- | | `https://your-pixelfed.example/p/tester/953…` | `@tester@your-pixelfed.example` | Posts arrive in real time: no polling, no scheduled job. The fediverse pushed the activity to alice the instant Pixelfed queued the delivery, and the only thing we added was a strict little filter on top of the inbox listener chain. ## `Like`s and `Undo(Like)` A `Like` activity is the fediverse's heart button. This chapter makes it work in both directions: alice can heart a remote post from her home grid, and remote actors can heart alice's posts. `Undo(Like)` reverses either action. ### A bidirectional `likes` table Append to *server/db/schema.ts*: ```typescript twoslash [server/db/schema.ts] import { sql } from "drizzle-orm"; import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; // ---cut--- export const likes = sqliteTable( "likes", { actorUri: text("actor_uri").notNull(), noteUri: text("note_uri").notNull(), likeActivityId: text("like_activity_id").notNull(), createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => [primaryKey({ columns: [t.actorUri, t.noteUri] })], ); export type Like = typeof likes.$inferSelect; ``` One row per (actor, note) pair, regardless of who is local. `like_activity_id` is what we use to match an `Undo(Like)` back to the row that should disappear. Push the schema: ```sh npm run db:push ``` ### Outbound: alice likes a remote post Create *server/api/likes.post.ts*: ```typescript twoslash [server/api/likes.post.ts] // @noErrors: 2304 2307 import { isActor, Like } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; import { createError, defineEventHandler, readBody, toWebRequest, } from "h3"; import { db } from "../db/client"; import { likes, timelinePosts } from "../db/schema"; import federation from "../federation"; import { getLocalUser } from "../utils/users"; export default defineEventHandler(async (event) => { const user = await getLocalUser(); if (user == null) { throw createError({ statusCode: 403, statusMessage: "No user" }); } const body = await readBody<{ noteUri?: unknown }>(event); const noteUri = typeof body?.noteUri === "string" ? body.noteUri : ""; if (noteUri === "") { throw createError({ statusCode: 400, statusMessage: "Missing noteUri" }); } // Find the cached timeline row so we know the author actor URI // without a fresh fetch. const [cached] = await db .select() .from(timelinePosts) .where( and( eq(timelinePosts.userId, user.id), eq(timelinePosts.noteUri, noteUri), ), ) .limit(1); if (cached === undefined) { throw createError({ statusCode: 404, statusMessage: "Post not in your timeline", }); } const ctx = federation.createContext(toWebRequest(event), undefined); const aliceUri = ctx.getActorUri(user.username); const likeActivityId = new URL(`#likes/${crypto.randomUUID()}`, aliceUri); // Insert the row idempotently. `returning()` is empty when a // row already exists for `(actorUri, noteUri)`, so a duplicate // request (a second click, a retry after a network blip) skips // the outbound `Like` and the recipient never sees a second // copy with a different `id` than the one we would later // `Undo`. const inserted = await db .insert(likes) .values({ actorUri: aliceUri.href, noteUri, likeActivityId: likeActivityId.href, }) .onConflictDoNothing() .returning({ likeActivityId: likes.likeActivityId }); if (inserted.length === 0) { return { noteUri, liked: true }; } const author = await ctx.lookupObject(cached.authorActorUri, { documentLoader: await ctx.getDocumentLoader({ identifier: user.username }), }); if (author != null && isActor(author)) { await ctx.sendActivity( { identifier: user.username }, author, new Like({ id: likeActivityId, actor: aliceUri, object: new URL(noteUri), }), ); } return { noteUri, liked: true }; }); ``` The unlike sibling, *server/api/likes.delete.ts*, mirrors this but wraps the original `Like` in an `Undo`: ```typescript twoslash [server/api/likes.delete.ts] // @noErrors: 2304 2307 import { isActor, Like, Undo } from "@fedify/vocab"; import { and, eq } from "drizzle-orm"; import { createError, defineEventHandler, readBody, toWebRequest } from "h3"; import { db } from "../db/client"; import { likes, timelinePosts } from "../db/schema"; import federation from "../federation"; import { getLocalUser } from "../utils/users"; export default defineEventHandler(async (event) => { const user = await getLocalUser(); if (user == null) { throw createError({ statusCode: 403, statusMessage: "No user" }); } const body = await readBody<{ noteUri?: unknown }>(event); const noteUri = typeof body?.noteUri === "string" ? body.noteUri : ""; if (noteUri === "") { throw createError({ statusCode: 400, statusMessage: "Missing noteUri" }); } const ctx = federation.createContext(toWebRequest(event), undefined); const aliceUri = ctx.getActorUri(user.username); const [existing] = await db .select() .from(likes) .where(and(eq(likes.actorUri, aliceUri.href), eq(likes.noteUri, noteUri))) .limit(1); if (existing === undefined) { return { noteUri, liked: false }; } await db .delete(likes) .where(and(eq(likes.actorUri, aliceUri.href), eq(likes.noteUri, noteUri))); const [cached] = await db .select() .from(timelinePosts) .where( and( eq(timelinePosts.userId, user.id), eq(timelinePosts.noteUri, noteUri), ), ) .limit(1); if (cached !== undefined) { const author = await ctx.lookupObject(cached.authorActorUri, { documentLoader: await ctx.getDocumentLoader({ identifier: user.username, }), }); if (author != null && isActor(author)) { await ctx.sendActivity( { identifier: user.username }, author, new Undo({ id: new URL(`#undo-likes/${crypto.randomUUID()}`, aliceUri), actor: aliceUri, object: new Like({ id: new URL(existing.likeActivityId), actor: aliceUri, object: new URL(noteUri), }), }), ); } } return { noteUri, liked: false }; }); ``` A few design notes: `returning()` *short-circuits duplicate clicks* : `onConflictDoNothing()` keeps the existing row's `like_activity_id` when the same `(actorUri, noteUri)` already exists, but the in-flight handler has already minted a fresh UUID for this request. If we naively fell through to `sendActivity`, a second click (or a retry after a network blip) would deliver a `Like` with a different id than the one we record, and a later `Undo(Like)` would only retract the first delivery. `.returning()` is empty on conflict, so we return early and the duplicate POST becomes a no-op. `isActor()` : `lookupObject()` is typed broadly because the URI could in principle resolve to anything (a Note, a Collection). `isActor()` from [`@fedify/vocab`][fedify-vocab] narrows it down to `Application | Group | Organization | Person | Service`. If the lookup somehow returns a Note we silently skip delivery; the local row still flips, which is the user's intent. `Undo(Like)` *wraps the original Like* : We rebuild the `Like` with the original `id` so peers that match by activity id (Mastodon does) line up with the row they recorded earlier. ### Inbound: receive `Like`s and `Undo(Like)` Two changes in *server/federation.ts*. First, add `Like` to the [`@fedify/vocab`][fedify-vocab] import (alongside `Follow`, `Note`, etc.). Second, prepend a new `Undo(Like)` branch to the existing `Undo` listener so the `Undo(Follow)` body from [*Handling unfollows*](#handling-unfollows) keeps working unchanged. A separate top-level `Like` listener at the end of the chain records inbound likes themselves. ```typescript twoslash [server/federation.ts] // @noErrors: 2304 2307 import { createFederation, InProcessMessageQueue, MemoryKvStore, } from "@fedify/fedify"; import { Follow, Like, Undo } from "@fedify/vocab"; import { and, eq, or } from "drizzle-orm"; import { db } from "./db/client"; import { followers, likes, users } from "./db/schema"; export const federation = createFederation({ kv: new MemoryKvStore(), queue: new InProcessMessageQueue(), }); federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut--- .on(Undo, async (ctx, undo) => { const object = await undo.getObject(); if (object instanceof Like) { // [!code ++] if (undo.actorId == null) return; // [!code ++] const likeActivityId = object.id?.href ?? null; // [!code ++] const objectId = object.objectId?.href ?? null; // [!code ++] const conditions = []; // [!code ++] if (likeActivityId != null) { // [!code ++] conditions.push(eq(likes.likeActivityId, likeActivityId)); // [!code ++] } // [!code ++] if (objectId != null) { // [!code ++] conditions.push( // [!code ++] and( // [!code ++] eq(likes.actorUri, undo.actorId.href), // [!code ++] eq(likes.noteUri, objectId), // [!code ++] ), // [!code ++] ); // [!code ++] } // [!code ++] if (conditions.length === 0) return; // [!code ++] const matcher = // [!code ++] conditions.length === 1 ? conditions[0] : or(...conditions); // [!code ++] await db.delete(likes).where(matcher); // [!code ++] return; // [!code ++] } // [!code ++] if (!(object instanceof Follow)) return; if (undo.actorId == null || object.objectId == null) return; const target = ctx.parseUri(object.objectId); if (target?.type !== "actor") return; const localUser = ( await db.select().from(users).where(eq(users.username, target.identifier)).limit(1) )[0]; if (localUser === undefined) return; await db .delete(followers) .where( and( eq(followers.followingId, localUser.id), eq(followers.actorUri, undo.actorId.href), ), ); }) ``` ```typescript twoslash [server/federation.ts] // @noErrors: 2304 2307 import { createFederation, InProcessMessageQueue, MemoryKvStore, } from "@fedify/fedify"; import { Like } from "@fedify/vocab"; import { db } from "./db/client"; import { likes } from "./db/schema"; export const federation = createFederation({ kv: new MemoryKvStore(), queue: new InProcessMessageQueue(), }); federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") // ---cut--- .on(Like, async (_ctx, like) => { if (like.actorId == null || like.objectId == null) return; const likeId = like.id?.href ?? `${like.actorId.href}#${like.objectId.href}`; await db .insert(likes) .values({ actorUri: like.actorId.href, noteUri: like.objectId.href, likeActivityId: likeId, }) .onConflictDoUpdate({ target: [likes.actorUri, likes.noteUri], set: { likeActivityId: likeId }, }); }); ``` The `Like` handler does not gate by *is the object one of our local posts*. Storing every like is cheap; the post-detail page only counts rows whose `note_uri` matches its own canonical URI, so cross-Talk never leaks across posts. > \[!TIP] > The fallback id `${actor}#${object}` exists because some > peers (Pleroma, older Friendica) drop the `Like.id` entirely. > Building a synthetic id keeps the upsert deterministic so the > row stays unique even when nothing global addresses it. ### Show like counts Update *server/api/home.get.ts* to fold the like aggregates into each timeline row. We compute alice's canonical actor URI from the request URL so the same code works behind a tunnel or on localhost: ```typescript [server/api/home.get.ts] import { and, count, desc, eq, inArray } from "drizzle-orm"; import { createError, defineEventHandler, getRequestProtocol, getRequestURL, } from "h3"; import { db } from "../db/client"; import { likes, timelinePosts } from "../db/schema"; import { extractText } from "../utils/text"; import { getLocalUser } from "../utils/users"; export default defineEventHandler(async (event) => { const user = await getLocalUser(); if (user == null) { throw createError({ statusCode: 403, statusMessage: "No user" }); } const rows = await db .select() .from(timelinePosts) .where(eq(timelinePosts.userId, user.id)) .orderBy(desc(timelinePosts.publishedAt)) .limit(60); if (rows.length === 0) return { posts: [] }; const origin = `${getRequestProtocol(event)}://${getRequestURL(event).host}`; const aliceActorUri = `${origin}/users/${user.username}`; const noteUris = rows.map((r) => r.noteUri); const likeCounts = await db .select({ noteUri: likes.noteUri, cnt: count() }) .from(likes) .where(inArray(likes.noteUri, noteUris)) .groupBy(likes.noteUri); const myLikes = await db .select({ noteUri: likes.noteUri }) .from(likes) .where( and(eq(likes.actorUri, aliceActorUri), inArray(likes.noteUri, noteUris)), ); const countMap = new Map(likeCounts.map((r) => [r.noteUri, r.cnt])); const mySet = new Set(myLikes.map((r) => r.noteUri)); return { posts: rows.map((row) => ({ ...row, // `caption` is sanitized HTML for `v-html`; `captionText` // is the same content stripped to plain text so it can ride // along into the `alt` attribute and other text-only spots. captionText: row.caption ? extractText(row.caption) : null, likeCount: countMap.get(row.noteUri) ?? 0, likedByMe: mySet.has(row.noteUri), })), }; }); ``` Apply the same trick to the local post detail endpoint so alice's profile shows like counts on her own posts. Pull `count` into the drizzle-orm imports, `getRequestProtocol` and `getRequestURL` into the h3 imports, and `likes` into the schema imports, then add the lookup just before the response: ```typescript [server/api/users/[username]/posts/[id].get.ts] import { and, count, eq } from "drizzle-orm"; import { createError, defineEventHandler, getRequestProtocol, getRequestURL, getRouterParam, } from "h3"; import { likes /* …existing imports… */ } from "../../../../db/schema"; // … const origin = `${getRequestProtocol(event)}://${getRequestURL(event).host}`; const noteUri = `${origin}/users/${username}/posts/${id}`; const [{ likeCount }] = await db .select({ likeCount: count() }) .from(likes) .where(eq(likes.noteUri, noteUri)); return { user, post, likeCount }; ``` ### Wire the heart button Replace the contents of *app/pages/home.vue* with a single-column feed that carries a heart per post. The script types the timeline entries with a `TimelineEntry` shape, pulls `refresh` out of `useFetch`, and adds a `toggleLike` helper that POSTs or DELETEs */api/likes* depending on the current state: ```vue [app/pages/home.vue] ``` `prose-content` is a small UnoCSS shortcut that gives `

` margins and a brand-tinted hover for embedded `` tags inside the sanitized caption. Add it to *uno.config.ts*: ```typescript [uno.config.ts] shortcuts: { // Styling for sanitized remote `Note.content`: paragraph // spacing for `

`, brand-coloured links for ``, and a // graceful break behaviour for long URLs. "prose-content": "[&_p]:mb-2 [&_p:last-child]:mb-0 [&_a]:text-brand [&_a:hover]:underline [&_a]:break-all", }, ``` > \[!TIP] > `$fetch` from Nuxt is the unauthenticated client helper. > The dev server's session cookie rides along automatically > because `/api/likes` is same-origin. If you ever move likes > to a separate API host, switch to `useFetch` with > `credentials: "include"`. A small addition to *app/pages/users/\[username]/posts/\[id].vue* surfaces the count on alice's own posts. Add a `likeCount` computed alongside the others in `