From 3bffc1fdfd82248f3d26cd9cbaa076ac8ccef01b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 21 Apr 2026 21:43:29 +0900 Subject: [PATCH 01/35] Start the threadiverse tutorial Add docs/tutorial/threadiverse.md with the Introduction, Target audience, Goals, and Setting up the development environment sections, and register it in the tutorial nav. This first chapter walks the reader through `fedify init -w next -p npm -k in-memory -m in-process`, the Next.js 15.5.x pin workaround, and verifying the dev server and federation middleware with `fedify lookup`. Subsequent commits will add chapters in step with commits in the paired fedify-dev/threadiverse example repository. Assisted-By: Claude Code:claude-opus-4-7 --- docs/.vitepress/config.mts | 4 + docs/tutorial/threadiverse.md | 270 ++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 docs/tutorial/threadiverse.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 832917284..20b75970f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -99,6 +99,10 @@ const TUTORIAL = { }, { text: "Learning the basics", link: "/tutorial/basics.md" }, { text: "Creating a microblog", link: "/tutorial/microblog.md" }, + { + text: "Building a threadiverse community", + link: "/tutorial/threadiverse.md", + }, ], activeMatch: "/tutorial", }; diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md new file mode 100644 index 000000000..9d247e689 --- /dev/null +++ b/docs/tutorial/threadiverse.md @@ -0,0 +1,270 @@ +--- +description: >- + In this tutorial, we will build a small threadiverse-style community platform + that federates with Lemmy, Mbin, and NodeBB using Fedify and Next.js. +--- + +Building a threadiverse community platform +========================================== + +In this tutorial, we will build a small threadiverse-style community platform +that federates with [Lemmy], [Mbin], and [NodeBB]. The server we build will +host federated *communities* that remote users can subscribe to, threads +posted inside those communities, and threaded replies to those threads. We +will use [Fedify] as the ActivityPub framework and [Next.js] as the web +framework. + +This tutorial focuses on how to use Fedify rather than on Next.js itself. If +you have never used Next.js before, don't worry: we'll only touch the parts of +it that we need, and in a very shallow way. + +If you have any questions, suggestions, or feedback, please feel free to join +our [Matrix chat space] or [GitHub Discussions]. + +[Lemmy]: https://join-lemmy.org/ +[Mbin]: https://joinmbin.org/ +[NodeBB]: https://nodebb.org/ +[Fedify]: https://fedify.dev/ +[Next.js]: https://nextjs.org/ +[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 readers who want to learn how to build a +community-centric ActivityPub application, something shaped like Lemmy rather +than like Mastodon. + +We assume that you have experience creating web applications with HTML and +HTTP, that you understand command-line interfaces, JSON, and basic JavaScript. +You don't need to know TypeScript, JSX, SQL, ORMs, ActivityPub, Next.js, or +Fedify. We'll teach you what you need to know about each of these as we go +along. + +You don't need prior experience building ActivityPub software, but we do +assume that you have used at least one piece of threadiverse software such as +Lemmy, Mbin, NodeBB, or [Piefed]. That way you already have a mental picture +of the kind of product we are building. + +If you are looking for a tutorial that builds a Mastodon-style microblog +(actor- and timeline-centric) instead, see +[*Creating your own federated microblog*](./microblog.md). + +*[JSX]: JavaScript XML +*[ORM]: Object–Relational Mapping + +[Piefed]: https://piefed.social/ + + +Goals +----- + +We will build a multi-user community platform whose local users can host +federated *communities* and subscribe to remote ones. It will include the +following features: + + - Users can sign up and log in with a username and password. + - Local users are federated as `Person` actors: other fediverse software can + look them up by their handle. + - Local users can create and host federated *communities*. A community is + federated as a `Group` actor with its own inbox, outbox, and followers + collection. + - Users can subscribe to a community on any threadiverse-compatible server + (Lemmy, Mbin, another Fedify-based app, and so on). + - Users can unsubscribe from a community. + - Users can create a text thread inside a subscribed community. A thread is + federated as a `Create(Page)` activity addressed to the community. + - When a local community receives a thread, it redistributes that thread to + all of its subscribers as an `Announce` activity. This is the pattern + threadiverse servers use to fan discussion out to every follower of a + community. + - Users can reply to a thread or to another reply. Replies are federated as + `Create(Note)` activities. + - Users can up-vote (`Like`) or down-vote (`Dislike`) any thread or reply. + The community redistributes these votes the same way it redistributes + threads and replies. + - Users see a front page that lists recent threads from every community they + subscribe to. + +To keep the tutorial focused on federation mechanics, we leave out the +following features: + + - Link threads (threads whose body is a URL instead of prose). + - Thread and reply editing, deletion, and `Tombstone`. + - Moderator roles, removals, bans, and reports. + - Ranking algorithms such as *Hot* or *Active*. + - Private communities. + - Media uploads. + - Direct messages. + +After finishing the tutorial you are encouraged to add whichever of these you +want; they're all 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/threadiverse + + +Setting up the development environment +-------------------------------------- + +### Installing Node.js + +Fedify supports three JavaScript runtimes: [Deno], [Bun], and [Node.js]. +Next.js itself runs on Node.js, so we'll use Node.js here as well. + +You need Node.js version 22.0.0 or higher. There are +[several installation methods]; pick whichever is most convenient. Once +Node.js is installed you should 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/ +[several installation methods]: https://nodejs.org/en/download/package-manager + +### Installing the `fedify` command + +To scaffold a Fedify project we'll use the [`fedify`](../cli.md) command. +There are [several installation methods](../cli.md#installation); the simplest +is to install it as a global npm package: + +~~~~ sh +npm install -g @fedify/cli +~~~~ + +Check that it works: + +~~~~ sh +fedify --version +~~~~ + +Make sure the version number is 2.1.0 or higher. Older versions of the CLI +don't know how to scaffold a Next.js project. + +### `fedify init` to initialize the project + +Pick a directory to work in. In this tutorial we'll call it *threadiverse*. +Run the [`fedify init`](../cli.md#fedify-init-initializing-a-fedify-project) +command with a few options so it picks all of our choices non-interactively: + +~~~~ sh +fedify init -w next -p npm -k in-memory -m in-process threadiverse +~~~~ + +The command scaffolds a Next.js App Router project that already knows how to +serve ActivityPub. The options mean: + + - `-w next`: integrate with [Next.js] using + [`@fedify/next`](../manual/integration.md). + - `-p npm`: use `npm` as the package manager. + - `-k in-memory`: keep Fedify's key–value cache in memory (good enough for + local development). We'll swap in a persistent store in the *Next steps* + chapter. + - `-m in-process`: run Fedify's background message queue in-process instead + of on an external broker (Redis, RabbitMQ, and so on). + +> [!WARNING] +> At the time of writing, `create-next-app` (which `fedify init` runs under +> the hood) defaults to Next.js 16, but `@fedify/next` 2.1.x still requires +> Next.js 15.4 or later in the 15.x line. After `fedify init` finishes it +> will try to run `npm install` and fail with an `ERESOLVE` error. Open +> *package.json*, change the `next`, `react`, `react-dom`, and +> `eslint-config-next` versions so they point at Next.js 15.5.x, then run +> `npm install` again: +> +> ~~~~ json +> "dependencies": { +> "next": "^15.5.15", +> "react": "^19.2.4", +> "react-dom": "^19.2.4", +> ... +> }, +> "devDependencies": { +> ... +> "eslint-config-next": "^15.5.15", +> ... +> } +> ~~~~ +> +> This workaround will go away once Fedify ships support for Next.js 16. + +After a moment your working directory will contain something like this: + + - *app/* — Next.js App Router pages and layouts + - *layout.tsx* — root layout + - *page.tsx* — home page + - *globals.css* — global stylesheet + - *federation/* — ActivityPub server code + - *index.ts* — Fedify `Federation` instance + - *public/* — static assets served as-is + - *biome.json* — formatter and linter configuration + - *logging.ts* — logging setup + - *middleware.ts* — Next.js middleware that hands federation requests off + to Fedify + - *next.config.ts* — Next.js configuration + - *package.json* — package metadata + - *tsconfig.json* — TypeScript configuration + +As you may have guessed, we're using [TypeScript] instead of plain JavaScript, +so every source file ends in *.ts* or *.tsx* instead of *.js*. TypeScript is +JavaScript with type annotations, and Fedify leans heavily on those types to +guide you into writing correct ActivityPub code. If you've never used +TypeScript before, don't worry: we'll introduce each piece of syntax the +first time we use it. + +*[TSX]: TypeScript XML + +[TypeScript]: https://www.typescriptlang.org/ + +### Checking that the dev server runs + +Now let's make sure the scaffold actually runs. From inside the *threadiverse* +directory, start the Next.js development server: + +~~~~ sh +npm run dev +~~~~ + +The dev server will keep running until you press +Ctrl+C: + +~~~~ console + ▲ Next.js 15.5.15 + - Local: http://localhost:3000 + - Network: http://192.168.x.x:3000 + + ✓ Starting... + ✓ Ready in 971ms +~~~~ + +Open a new terminal tab and use the `fedify lookup` command to confirm that +the ActivityPub side of the server is responding: + +~~~~ sh +fedify lookup http://localhost:3000/users/testuser +~~~~ + +You should see Fedify print out a `Person` actor with `preferredUsername` +equal to `testuser`. That placeholder actor comes from the default +`setActorDispatcher()` call that `fedify init` generated in +*federation/index.ts*. We'll replace it with one backed by a real database +in [Federating your user](#federating-your-user-the-person-actor). + +> [!TIP] +> The dev server responds to any `/users/{identifier}` URL with a generic +> actor whose only information is the identifier itself. That's intentional: +> it lets you verify that the ActivityPub middleware is wired up correctly +> before you have any real user data. + +If you ever see an `EMFILE: too many open files` error from Next.js on +Linux, you've hit the default `fs.inotify.max_user_instances` limit. +Restarting `npm run dev` with `WATCHPACK_POLLING=true` in front of it makes +Next.js fall back to polling-based file watching and sidesteps the problem. From 6d337566da3f1169bb48a7e79eaeec26cb205302 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 21 Apr 2026 21:49:26 +0900 Subject: [PATCH 02/35] Tutorial: swap ESLint for Biome Add the *Swapping ESLint for Biome* subsection to the Setup chapter. It walks the reader through deleting the two ESLint configs that `fedify init` leaves behind for Next.js, turning on Biome's linter, rewriting the `lint` and `format` scripts to call Biome, and cleaning up the unused `getLogger` call that `noUnusedVariables` flags in *federation/index.ts*. Matches commit 839def1 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 99 +++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 9d247e689..151c585c7 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -268,3 +268,102 @@ If you ever see an `EMFILE: too many open files` error from Next.js on Linux, you've hit the default `fs.inotify.max_user_instances` limit. Restarting `npm run dev` with `WATCHPACK_POLLING=true` in front of it makes Next.js fall back to polling-based file watching and sidesteps the problem. + +### Swapping ESLint for biome + +The Next.js scaffold that `create-next-app` dropped into the project comes +with [ESLint] for linting, while the Fedify side of the scaffold prefers +[Biome]. `fedify init` tries to accommodate both by shipping them side by +side, but that means you have to install two tools that disagree with each +other on style. Since Biome can do both the formatting *and* the linting we +need, let's delete ESLint and let Biome run the whole show. + +Open *biome.json* and turn the linter on with the recommended rule set: + +~~~~ json [biome.json] +{ + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { "recommended": true } + }, + "files": { + "includes": ["**", "!.next", "!node_modules", "!public"] + } +} +~~~~ + +Delete the two ESLint configs: + +~~~~ sh +rm eslint.config.mjs eslint.config.ts +~~~~ + +In *package.json*, drop the `eslint`, `eslint-config-next`, and `@fedify/lint` +packages from `devDependencies`, and rewrite the `lint` and `format` scripts +so they call Biome instead: + +~~~~ json [package.json] +"scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "biome check", + "format": "biome check --write" +}, +~~~~ + +Then reinstall dependencies so the lockfile reflects the smaller dep tree: + +~~~~ sh +npm install +~~~~ + +From now on you can format and lint the whole project with a single command: + +~~~~ sh +npm run format +~~~~ + +Running it once right now will flag one pre-existing issue: +*federation/index.ts* imports `getLogger` from LogTape and assigns the result +to a `logger` constant, but nothing ever reads that `logger`. Biome's +`noUnusedVariables` rule flags it. Delete the unused import and declaration: + +~~~~ typescript{2,4} [federation/index.ts] +import { + createFederation, + InProcessMessageQueue, + MemoryKvStore, +} from "@fedify/fedify"; +import { Person } from "@fedify/vocab"; + +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; +~~~~ + +We'll add logging back later when there's something worth logging. + +[ESLint]: https://eslint.org/ +[Biome]: https://biomejs.dev/ From e5288e77c9587f3ffce7c0e31ec06276b6e017d6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 21 Apr 2026 22:07:30 +0900 Subject: [PATCH 03/35] Tutorial: layout and navigation Add the *Layout and navigation* chapter. It walks the reader through replacing `create-next-app`'s demo landing page with a minimal root layout (top nav with brand + Home/New community links), a one-paragraph home page, and an ~80-line plain stylesheet for typography, forms, cards, buttons, and the nested reply tree. The reader copies the stylesheet once and never touches CSS again, which keeps later chapters focused on federation rather than presentation. Matches commit 4d31dab in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 244 ++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 151c585c7..e5ee43966 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -367,3 +367,247 @@ We'll add logging back later when there's something worth logging. [ESLint]: https://eslint.org/ [Biome]: https://biomejs.dev/ + + +Layout and navigation +--------------------- + +Every page we build in the rest of the tutorial shares the same shell: a top +navigation bar with a brand link, a centered content area underneath it, and +a single colour palette. We'll set that up once now so later chapters don't +have to re-specify it on every page. + +Open *app/layout.tsx* and replace the `create-next-app` boilerplate with a +minimal root layout: + +~~~~ tsx [app/layout.tsx] +import type { Metadata } from "next"; +import Link from "next/link"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Threadiverse", + description: "A small federated community platform built with Fedify.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
{children}
+ + + ); +} +~~~~ + +> [!NOTE] +> A *root layout* in the [Next.js App Router] is the top-most React tree that +> wraps every page under *app/*. Whatever it renders shows up on every +> route. The `children` prop is the page for the current URL, rendered in +> place. We haven't written any pages yet besides the home page, but as soon +> as we do they'll all inherit this nav bar. + +Replace *app/page.tsx* with a temporary welcome blurb. This is the page +rendered at the root URL (`/`). We'll revisit it in a later chapter and turn +it into the *subscribed feed* once users can follow communities: + +~~~~ tsx [app/page.tsx] +export default function Home() { + return ( + <> +

Welcome to Threadiverse

+

+ This is a small federated community platform built with Fedify and + Next.js. In the next chapters of the tutorial we'll add user + accounts, communities, threads, replies, and votes. +

+ + ); +} +~~~~ + +Next, replace the whole contents of *app/globals.css* with the small +stylesheet below. You can copy and paste it verbatim; we won't touch CSS +again for the rest of the tutorial: + +~~~~ css [app/globals.css] +:root { + --color-bg: #fafafa; + --color-surface: #ffffff; + --color-border: #e5e5e5; + --color-text: #1a1a1a; + --color-muted: #666; + --color-accent: #4a6cf7; + --color-accent-hover: #3453d8; + --radius: 6px; + --space: 1rem; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + line-height: 1.5; + background: var(--color-bg); + color: var(--color-text); +} + +a { + color: var(--color-accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +nav.site-nav { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + padding: 0.75rem var(--space); +} + +nav.site-nav .inner { + max-width: 800px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 1.5rem; +} + +nav.site-nav .brand { + font-weight: 700; + font-size: 1.1rem; + color: var(--color-text); +} + +nav.site-nav ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + gap: 1rem; + flex: 1; +} + +main { + max-width: 800px; + margin: 0 auto; + padding: var(--space); +} + +h1, +h2, +h3 { + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space); + margin-bottom: var(--space); +} + +.muted { + color: var(--color-muted); + font-size: 0.9rem; +} + +label { + display: block; + margin-top: 0.75rem; + font-size: 0.9rem; + color: var(--color-muted); +} + +input, +textarea { + display: block; + width: 100%; + margin-top: 0.25rem; + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font: inherit; + background: var(--color-surface); +} + +textarea { + min-height: 6rem; + resize: vertical; +} + +button, +.button { + display: inline-block; + margin-top: 1rem; + padding: 0.5rem 1rem; + border: 0; + border-radius: var(--radius); + background: var(--color-accent); + color: #fff; + font: inherit; + cursor: pointer; +} + +button:hover, +.button:hover { + background: var(--color-accent-hover); + text-decoration: none; + color: #fff; +} + +.reply-tree { + list-style: none; + margin: 0; + padding: 0; +} + +.reply-tree .reply-tree { + margin-left: 1.5rem; + border-left: 2px solid var(--color-border); + padding-left: 1rem; +} +~~~~ + +Finally, delete the four leftover files that `create-next-app` shipped but +we're no longer using: + +~~~~ sh +rm app/page.module.css +rm public/file.svg public/globe.svg public/next.svg public/vercel.svg public/window.svg +~~~~ + +Reload `http://localhost:3000` in your browser. You should see a nav bar +with a *Threadiverse* brand on the left, two links (*Home* and +*New community*), and the welcome blurb below it. Clicking *New community* +will 404 for now; we'll build that page in the *Communities as Group actors* +chapter. + +[Next.js App Router]: https://nextjs.org/docs/app From 3e875ff1602fd9e9e11b6c9969d7fe9180faa3f1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 21 Apr 2026 22:16:48 +0900 Subject: [PATCH 04/35] Tutorial: users table Add the opening of the *User accounts* chapter. It introduces Drizzle ORM and SQLite, walks through installing `drizzle-orm`, `better-sqlite3`, and the dev-time `drizzle-kit` + `@types/better-sqlite3`, and declares the first table (`users`) in *db/schema.ts* with `id`, `username`, `password_hash`, and `created_at` columns. Also covers opening the database with WAL + foreign-key pragmas, wiring up *drizzle.config.ts*, adding `db:push` and `db:studio` scripts, and gitignoring the SQLite files. Matches commit d3aa008 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 186 ++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index e5ee43966..79114c8bd 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -611,3 +611,189 @@ will 404 for now; we'll build that page in the *Communities as Group actors* chapter. [Next.js App Router]: https://nextjs.org/docs/app + + +User accounts +------------- + +Before we start federating anything we need *local* user accounts. A local +user is just a row in our own database; we'll only turn those rows into +federated `Person` actors in the next chapter. Getting accounts working +first gives us something concrete (a user, a username, a password) that the +federation layer can then point at. + +### Drizzle ORM and SQLite + +We'll keep data in [SQLite], the single-file embedded database. SQLite is +ideal for a tutorial: the whole database lives in one *.sqlite3* file in your +project directory, there's no server to set up, and it's plenty fast for a +single-node app. In production you would pick something like PostgreSQL, but +the code we'll write is almost identical; swapping databases is a matter of +changing the connection string. + +To talk to SQLite we'll use [Drizzle ORM]. An ORM (*Object–Relational +Mapper*) lets you describe your tables as TypeScript values and then query +them with chained function calls instead of writing raw SQL strings. The +benefit over raw SQL is that TypeScript understands your schema, so a typo +like `users.usernaem` is a compile error rather than a runtime mystery. + +> [!NOTE] +> If you already know SQL, you'll find that Drizzle barely hides it: a +> Drizzle query reads almost word-for-word like the SQL it generates. If +> you don't know SQL yet, that's fine; we'll introduce each piece of syntax +> the first time it appears. + +[SQLite]: https://sqlite.org/ +[Drizzle ORM]: https://orm.drizzle.team/ + +### Installing dependencies + +Install Drizzle, the SQLite driver, and Drizzle's CLI: + +~~~~ sh +npm install drizzle-orm better-sqlite3 +npm install -D drizzle-kit @types/better-sqlite3 +~~~~ + +The first line adds the runtime pieces: *drizzle-orm* is the query builder, +and [*better-sqlite3*] is a synchronous SQLite driver well-suited to +server-side rendering. The second line adds Drizzle's CLI for managing the +schema, plus TypeScript types for *better-sqlite3*. + +[*better-sqlite3*]: https://github.com/WiseLibs/better-sqlite3 + +### Declaring the `users` table + +Create *db/schema.ts* and describe the first table: + +~~~~ typescript [db/schema.ts] +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: integer("id").primaryKey({ autoIncrement: true }), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; +~~~~ + +Reading the file top to bottom: + + - `sqliteTable("users", { ... })` defines a table named `users` with four + columns. + - `id` is an auto-incrementing integer primary key. + - `username` is a required text column with a `UNIQUE` constraint; two + users can't share the same username. + - `passwordHash` is a required text column; we'll store a *hash* of the + password, never the password itself. + - `createdAt` is a Unix timestamp with a SQL default of `unixepoch()`, so + SQLite fills in the time on insert. + +The two `type` exports are a Drizzle convention. `User` is the type of a +row as it comes out of the database (every column populated). `NewUser` is +the shape of a row ready to *insert* (so `id` and `createdAt` are optional +because they have defaults). Using these types means you never write out +column types by hand. + +> [!TIP] +> The ``sql`(unixepoch())` `` bit is a *tagged template literal*: it embeds a +> raw SQL snippet in a Drizzle schema definition. We use it here because +> Drizzle doesn't ship a helper for SQLite's `unixepoch()` function, and +> `unixepoch()` is the simplest way to default a column to “now” in seconds. + +### Opening the database + +Create *db/index.ts* next. This is the module every server-side file will +import from when it needs to query the database: + +~~~~ typescript [db/index.ts] +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import * as schema from "./schema"; + +const sqlite = new Database("threadiverse.sqlite3"); +sqlite.pragma("journal_mode = WAL"); +sqlite.pragma("foreign_keys = ON"); + +export const db = drizzle(sqlite, { schema }); +export * from "./schema"; +~~~~ + +The file opens (or creates) *threadiverse.sqlite3* in the project root, sets +two pragmas that every SQLite app should set (`journal_mode = WAL` for +better concurrency, `foreign_keys = ON` so foreign-key constraints are +actually enforced), and wraps the connection with Drizzle. The +`export * from "./schema"` re-exports every table and type so callers can +`import { db, users, type User } from "@/db"` from a single path. + +### Wiring up drizzle kit + +[Drizzle Kit] is Drizzle's companion CLI. It reads your schema and either +generates migration SQL or pushes the schema directly to the database. +Create *drizzle.config.ts* at the project root: + +~~~~ typescript [drizzle.config.ts] +import type { Config } from "drizzle-kit"; + +export default { + schema: "./db/schema.ts", + out: "./drizzle", + dialect: "sqlite", + dbCredentials: { + url: "threadiverse.sqlite3", + }, +} satisfies Config; +~~~~ + +Add two npm scripts to *package.json* so the CLI is a short command: + +~~~~ json [package.json] +"scripts": { + ... + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" +} +~~~~ + +`db:push` synchronizes the database schema with *db/schema.ts* in one +step. `db:studio` opens a small web UI that lets you browse the database +contents; you don't need it now, but it's handy later when debugging +federation state. + +[Drizzle Kit]: https://orm.drizzle.team/docs/kit-overview + +### Creating the database + +Create the database file by running: + +~~~~ sh +npm run db:push +~~~~ + +Drizzle Kit prints a short summary and exits. You should now have a +*threadiverse.sqlite3* file in the project root with an empty `users` table +inside. + +Finally, add the SQLite database file to *.gitignore* so every developer +starts from their own empty copy: + +~~~~ [.gitignore] +# sqlite database (regenerated locally) +*.sqlite3 +*.sqlite3-journal +*.sqlite3-wal +*.sqlite3-shm +~~~~ + +> [!TIP] +> Throughout the rest of the tutorial, whenever we add or change a table, +> you'll re-run `npm run db:push` to sync the change to the database. For +> a real app you would switch to generated migration files +> (`drizzle-kit generate` followed by `drizzle-kit migrate`) so deployments are +> reproducible; push is fine for local development and for tutorials. From 28d1b2bdb847790f17836e0bfa6d76758f438ed2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 21 Apr 2026 22:24:32 +0900 Subject: [PATCH 05/35] Tutorial: signup form Add the *Signup form and password hashing* subsection. It introduces Node's scrypt as the password hashing primitive, shows how to build a Next.js App Router *server action* (a server function called directly from an HTML form via the `action` attribute, with no client fetch or API route), and walks the reader through the full validation chain: client-side HTML pattern, server-side regex re-validation, Drizzle duplicate-username lookup, scrypt hash, and a redirect back to the login page with a success message. Includes a screenshot of the rendered form for visual confirmation. Matches commit b170aa5 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 177 +++++++++++++++++++++ docs/tutorial/threadiverse/signup-form.png | Bin 0 -> 22447 bytes 2 files changed, 177 insertions(+) create mode 100644 docs/tutorial/threadiverse/signup-form.png diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 79114c8bd..2e36c40ff 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -797,3 +797,180 @@ starts from their own empty copy: > a real app you would switch to generated migration files > (`drizzle-kit generate` followed by `drizzle-kit migrate`) so deployments are > reproducible; push is fine for local development and for tutorials. + +### Signup form and password hashing + +Now that we have a `users` table, let's give visitors a way to create a row +in it. We'll keep authentication minimal: a username and a password. No +email, no email verification, no social logins; you're welcome to add any +of these as a follow-up project. + +Create a helper module for password hashing first. Passwords must never be +stored as plain text; instead, we store a one-way *hash* computed with a +deliberately slow function, [scrypt], built into Node's standard library: + +~~~~ typescript [lib/auth.ts] +import { + randomBytes, + scrypt as scryptCallback, + timingSafeEqual, +} from "node:crypto"; +import { promisify } from "node:util"; + +const scrypt = promisify(scryptCallback) as ( + password: string | Buffer, + salt: string | Buffer, + keylen: number, +) => Promise; + +const KEY_LEN = 64; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const derived = await scrypt(password, salt, KEY_LEN); + return `${salt.toString("hex")}:${derived.toString("hex")}`; +} + +export async function verifyPassword( + password: string, + stored: string, +): Promise { + const [saltHex, hashHex] = stored.split(":"); + if (!saltHex || !hashHex) return false; + const salt = Buffer.from(saltHex, "hex"); + const expected = Buffer.from(hashHex, "hex"); + const actual = await scrypt(password, salt, expected.length); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} +~~~~ + +`hashPassword` generates a fresh 16-byte salt, runs scrypt, and returns +`salt:hash` as a single hex-encoded string. `verifyPassword` parses that +string back out, re-runs scrypt with the same salt, and uses +`timingSafeEqual` to compare the results (a constant-time comparison that +doesn't leak timing information to attackers). We'll call these two +functions from the signup and login server actions. + +Now add the signup page itself. In Next.js App Router a page is a React +component exported from *app/some/route/page.tsx*; when you visit +`/some/route` in the browser, Next.js renders that component on the server +and sends the resulting HTML down: + +~~~~ tsx [app/signup/page.tsx] +import Link from "next/link"; +import { signup } from "./actions"; + +type SignupPageProps = { + searchParams: Promise<{ error?: string }>; +}; + +export default async function SignupPage({ searchParams }: SignupPageProps) { + const { error } = await searchParams; + return ( + <> +

Sign up

+ {error &&

{error}

} +
+ + + +
+

+ Already have an account? Log in. +

+ + ); +} +~~~~ + +> [!NOTE] +> The `searchParams` prop is how an App Router page reads the query string. +> We use it to show a flash error message when something went wrong: the +> server action redirects back to `/signup?error=...`, the page receives +> `error="..."` via `searchParams`, and the template renders it above the +> form. + +The `action={signup}` attribute is what turns an ordinary HTML form into a +[*server action*]. When the user clicks *Create account*, Next.js +serializes the form fields into a `FormData` object, ships it to the +server, and invokes the `signup` function there. No client-side fetch, no +JSON endpoint, no API route, no `onSubmit` handler needed. + +Write that server action in a sibling file. The `"use server"` directive +at the top marks every exported function as a server action that's callable +from client components and from `
`: + +~~~~ typescript [app/signup/actions.ts] +"use server"; + +import { eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { db, users } from "@/db"; +import { hashPassword } from "@/lib/auth"; + +export async function signup(formData: FormData): Promise { + const username = String(formData.get("username") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + if (!/^[a-zA-Z0-9_]{2,32}$/.test(username)) { + redirect("/signup?error=Invalid+username"); + } + if (password.length < 8) { + redirect("/signup?error=Password+must+be+at+least+8+characters"); + } + + const existing = db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, username)) + .get(); + if (existing) { + redirect("/signup?error=Username+already+taken"); + } + + const passwordHash = await hashPassword(password); + db.insert(users).values({ username, passwordHash }).run(); + + redirect("/login?message=Account+created,+please+log+in"); +} +~~~~ + +The action re-validates the input server-side (browsers can skip HTML +validation, so we can't trust `pattern="..."` alone), checks that the +username isn't already taken by querying the table with +`db.select(...).where(eq(users.username, username)).get()`, hashes the +password, inserts the row with `db.insert(users).values({...}).run()`, and +redirects to the login page with a success message. + +> [!TIP] +> The `eq()` helper from `drizzle-orm` builds an SQL equality comparison. +> Drizzle has a whole set of comparison helpers (`and`, `or`, `inArray`, +> `gt`, `lt`, …). They compose by nesting, so you can write something +> like `and(eq(users.id, 42), gt(users.createdAt, lastWeek))` for more +> complex `WHERE` clauses. + +Open `http://localhost:3000/signup` in the browser and you should see the +form: + +![Screenshot: the signup form](./threadiverse/signup-form.png) + +Fill in a username and password and click *Create account*. The browser +will redirect to `/login?message=...`, which currently 404s because we +haven't built the login page yet. That's fine; the signup half of the +flow worked, and the row is in the database. We'll verify that, and build +the login half, in the next section. + +[scrypt]: https://en.wikipedia.org/wiki/Scrypt +[*server action*]: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations diff --git a/docs/tutorial/threadiverse/signup-form.png b/docs/tutorial/threadiverse/signup-form.png new file mode 100644 index 0000000000000000000000000000000000000000..ef90a91ee34280bf4d33bba2b8b35f3aa78a52a0 GIT binary patch literal 22447 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 ZN@mSKY@9Uu99}`)x^ee Date: Tue, 21 Apr 2026 22:31:36 +0900 Subject: [PATCH 06/35] Tutorial: login and sessions Add the *Login and sessions* subsection. It introduces the server-side `sessions` table (indexed by an opaque random token, with `onDelete: "cascade"` on the user reference), walks through writing `createSession`, `getCurrentUser`, and `destroySession` helpers in *lib/session.ts*, and discusses each cookie flag (httpOnly, sameSite, secure in production only, 30-day maxAge). It then builds the login page + login/logout server actions and turns the root layout into an async server component so every page can branch on whether a user is signed in, showing `@username / Log out` or `Log in / Sign up` in the nav accordingly. Includes a screenshot of the logged-in home page. Matches commit b5e8ba7 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 301 ++++++++++++++++++ docs/tutorial/threadiverse/home-logged-in.png | Bin 0 -> 31480 bytes 2 files changed, 301 insertions(+) create mode 100644 docs/tutorial/threadiverse/home-logged-in.png diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 2e36c40ff..3778978bd 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -974,3 +974,304 @@ the login half, in the next section. [scrypt]: https://en.wikipedia.org/wiki/Scrypt [*server action*]: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations + +### Login and sessions + +A user who signed up last section ended up at a `/login` URL that doesn't +exist yet. Let's build login and cookie-based sessions so the account is +usable. + +There are lots of ways to do session management in a web app. We'll use the +simplest one that's safe for our purposes: a server-side `sessions` table +keyed by an opaque random token, and an HTTP-only cookie that stores just +that token. The browser never sees the user ID or any other data; when a +request comes in, we look the token up in the database to find the user it +belongs to. This keeps the cookie cheap to invalidate (delete the row, the +cookie becomes useless) and means we don't need to pick or rotate a cookie +signing secret. + +Add the table to *db/schema.ts*: + +~~~~ typescript{16-27} [db/schema.ts] +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: integer("id").primaryKey({ autoIncrement: true }), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +export const sessions = sqliteTable("sessions", { + token: text("token").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type Session = typeof sessions.$inferSelect; +~~~~ + +`onDelete: "cascade"` on the `userId` reference means that deleting a user +automatically deletes their sessions too, so there are no dangling rows. + +Re-run the schema sync: + +~~~~ sh +npm run db:push +~~~~ + +Install the `server-only` package so that importing server-side code from a +client component fails at build time instead of leaking secrets: + +~~~~ sh +npm install -D server-only +~~~~ + +Now write the session helpers. Create *lib/session.ts*: + +~~~~ typescript [lib/session.ts] +import "server-only"; + +import { randomBytes } from "node:crypto"; +import { and, eq, gt } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { db, sessions, type User, users } from "@/db"; + +const COOKIE_NAME = "session"; +const MAX_AGE_SECONDS = 60 * 60 * 24 * 30; + +export async function createSession(userId: number): Promise { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + MAX_AGE_SECONDS * 1000); + db.insert(sessions).values({ token, userId, expiresAt }).run(); + const store = await cookies(); + store.set(COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: MAX_AGE_SECONDS, + }); +} + +export async function getCurrentUser(): Promise { + const store = await cookies(); + const token = store.get(COOKIE_NAME)?.value; + if (!token) return null; + const row = db + .select() + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date()))) + .get(); + return row?.users ?? null; +} + +export async function destroySession(): Promise { + const store = await cookies(); + const token = store.get(COOKIE_NAME)?.value; + if (token) { + db.delete(sessions).where(eq(sessions.token, token)).run(); + } + store.delete(COOKIE_NAME); +} +~~~~ + +A few notes: + + - `randomBytes(32).toString("base64url")` produces a 43-character + URL-safe random string. That's our session token. + - `httpOnly: true` hides the cookie from JavaScript running in the page + and rules out a whole class of cross-site scripting attacks. + - `sameSite: "lax"` means the cookie is included on top-level cross-site + navigations but not on cross-site embeds, which keeps CSRF exposure + small for our purposes. + - `secure: process.env.NODE_ENV === "production"` sets the `Secure` flag + in production (the browser will only send the cookie over HTTPS) but + leaves it off in development so you can log in over `http://localhost`. + - The join in `getCurrentUser` does `sessions ⨝ users ON user_id`, and + the `where` filters out expired rows so we don't treat them as valid. + +Now the login page. Create *app/login/page.tsx*: + +~~~~ tsx [app/login/page.tsx] +import Link from "next/link"; +import { login } from "./actions"; + +type LoginPageProps = { + searchParams: Promise<{ error?: string; message?: string }>; +}; + +export default async function LoginPage({ searchParams }: LoginPageProps) { + const { error, message } = await searchParams; + return ( + <> +

Log in

+ {message &&

{message}

} + {error &&

{error}

} + + + + + +

+ Need an account? Sign up. +

+ + ); +} +~~~~ + +And the login and logout server actions in *app/login/actions.ts*: + +~~~~ typescript [app/login/actions.ts] +"use server"; + +import { eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { db, users } from "@/db"; +import { verifyPassword } from "@/lib/auth"; +import { createSession, destroySession } from "@/lib/session"; + +export async function login(formData: FormData): Promise { + const username = String(formData.get("username") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + const user = db + .select() + .from(users) + .where(eq(users.username, username)) + .get(); + if (!user || !(await verifyPassword(password, user.passwordHash))) { + redirect("/login?error=Invalid+username+or+password"); + } + + await createSession(user.id); + redirect("/"); +} + +export async function logout(): Promise { + await destroySession(); + redirect("/"); +} +~~~~ + +Finally, change the root layout into an async server component so that +every page knows whether a user is signed in. Replace *app/layout.tsx* +with: + +~~~~ tsx{3-4,15,16,35-47} [app/layout.tsx] +import type { Metadata } from "next"; +import Link from "next/link"; +import { logout } from "./login/actions"; +import "./globals.css"; +import { getCurrentUser } from "@/lib/session"; + +export const metadata: Metadata = { + title: "Threadiverse", + description: "A small federated community platform built with Fedify.", +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const user = await getCurrentUser(); + return ( + + + +
{children}
+ + + ); +} +~~~~ + +Append a few more lines to *app/globals.css* for the new nav elements: + +~~~~ css [app/globals.css] +nav.site-nav .session-controls { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; +} + +nav.site-nav .link-button { + margin: 0; + padding: 0; + background: transparent; + color: var(--color-accent); + border: 0; + cursor: pointer; + font: inherit; +} + +nav.site-nav .link-button:hover { + background: transparent; + color: var(--color-accent-hover); + text-decoration: underline; +} +~~~~ + +Reload `/signup`, create an account, and you'll now land on a working +login page. Sign in with the same credentials and the nav bar flips over +to show `@yourusername` and a *Log out* button: + +![Screenshot: the home page after logging in](./threadiverse/home-logged-in.png) + +Clicking *Log out* submits the logout action, which deletes the session +row and clears the cookie, and the nav bar flips back to *Log in / Sign +up*. + +> [!TIP] +> You can inspect the cookie with your browser's developer tools (usually +> under *Application → Cookies*). You should see a `session` cookie whose +> value is the same 43-character token as the `token` column of the +> `sessions` table. diff --git a/docs/tutorial/threadiverse/home-logged-in.png b/docs/tutorial/threadiverse/home-logged-in.png new file mode 100644 index 0000000000000000000000000000000000000000..c81dd6c63b3d37e3fb1b4518b79a32de4aa317c6 GIT binary patch literal 31480 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

Dk40vX52{g^Q)osi*i6iOF71Pr*)8 zcZF15D@_!{dFsG%eG7MQaOGdbvD?iXj*geVC&A5CljAfrCG<(*uY0S~4-6-?Q{_9W z!$;3@`4O@@(oE3% Date: Tue, 21 Apr 2026 22:37:37 +0900 Subject: [PATCH 07/35] Tutorial: profile page Add the *Profile page* subsection. It introduces Next.js App Router dynamic segments (`[username]`), server-side `params` as a Promise, and `notFound()` for 404 handling, then builds a simple profile page that renders the user's handle, join date, and a placeholder for future threads. Also updates the nav-bar `@username` label into a link pointing at that page. Ends with a teaser: the same URL currently serves HTML to browsers and a placeholder `Person` JSON to ActivityPub clients, and the next commit will swap the placeholder for a dispatcher backed by the `users` table. Matches commit a0ed553 in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 80 ++++++++++++++++++++ docs/tutorial/threadiverse/profile-page.png | Bin 0 -> 22981 bytes 2 files changed, 80 insertions(+) create mode 100644 docs/tutorial/threadiverse/profile-page.png diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 3778978bd..5a8479094 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -1275,3 +1275,83 @@ up*. > under *Application → Cookies*). You should see a `session` cookie whose > value is the same 43-character token as the `token` column of the > `sessions` table. + +### Profile page + +Every user needs a page to call their own. For now we'll keep it simple: +URL path, display name, join date. Create *app/users/\[username]/page.tsx*: + +~~~~ tsx [app/users/[username]/page.tsx] +import { eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { db, users } from "@/db"; + +type ProfilePageProps = { + params: Promise<{ username: string }>; +}; + +export default async function ProfilePage({ params }: ProfilePageProps) { + const { username } = await params; + const user = db + .select({ + id: users.id, + username: users.username, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.username, username)) + .get(); + if (!user) notFound(); + return ( + <> +

@{user.username}

+

+ Joined{" "} + {user.createdAt.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} +

+

Threads and replies by this user will appear here.

+ + ); +} +~~~~ + +A few things to notice: + + - The square brackets in the folder name `[username]` make `username` + a *dynamic segment*. The Next.js router will match any path of the + form `/users/` and pass that `something` as + `params.username`. + - `params` is a Promise in recent versions of Next.js, so we `await` it + before destructuring. + - `notFound()` aborts rendering and shows the nearest `not-found.tsx` + (or, if there isn't one, the default 404 page). + +While we're here, turn the `@username` label in the nav bar into a link +that points to the current user's profile. In *app/layout.tsx*, replace +the `` with a ``: + +~~~~ tsx{2} [app/layout.tsx] +
+ @{user.username} + +
+~~~~ + +Reload and visit `/users/alice` (or whatever username you signed up with). +You should see something like this: + +![Screenshot: the user profile page](./threadiverse/profile-page.png) + +The same URL right now, when you open it with a browser, renders the HTML +page above. But if a fediverse server asks for it with an +`Accept: application/activity+json` header, Fedify's middleware intercepts +the request and returns the default placeholder `Person` actor we saw +earlier. In the next chapter we'll swap that placeholder for a proper +actor backed by our `users` table, so searching `@alice@` in +Mastodon or Lemmy actually finds the account we just created. diff --git a/docs/tutorial/threadiverse/profile-page.png b/docs/tutorial/threadiverse/profile-page.png new file mode 100644 index 0000000000000000000000000000000000000000..c5f1cfa1410eb4903cb9c5ae3b1f4826af216a7b GIT binary patch literal 22981 zcmeIaXIPY3w=If|qOAy`k|ebtiXcfONbFXU2`q9h1<9d6ax85`1O!ASCnaYj=PWr& z&N-(d$D-=axA)%X_u2d2ea=14`F`}%tzA`bSTl?{##qbexq{rq^ViOkkdRz_BrmN* zLUIQFlk|^szrnw@k2Eq!NG_2)lKxA@DQb1>Y#8<25ZUCAEEP5CblEw5^^6y|i)H#4 z{z8+F*UEfy8fZM(_Xf)vOe}KtHBeMyG*_=)ICm~3KaS%1gzFyF{ zMR+1y!qp+hIr-~AU!NgdY~zV~%qbF*KYu&@0{-;*4-!%ml24~k(UFk6xpA%<{_4hG zBxEEcfBgO%AN=9br8n?l`M*iX;VYN_GhcaNN`}8DPB@ssm2WG+58_2mzwkhN z-+9q!nG_Bg`H7FFDkraouB30;Ycq=;))<>#`&hMJxojC27ImT`vHSR$-LWFAx)2?9 zid)r{X!=<8N{m~H(SX!b=aZ9R@<}-wESj)sy=#ii7N6`?E)>(>!gNNx-V;>a@4&-6ij$XXKB1~SqSz%ZuCG~nubWr%g2@jP95%X!Z7~@xYZb#2I21~$ zJ8jQnV>dX1?MR)cH*q$lg6r;qA=R5hnI#=w7LWZg$L+UsA=*|;iJ?|@1aD@S#qKA( zCp$fg1KOAhPfAHAANG(BK`ib&zeHCuu8{KIevhfx?dL8Woc^k;;KSc3dAuq(ptIe= zm7R~7@bIEm6PbE+2EAA}0G@6#quAvf@P+Nw@5ZJ>RMA4lzuqu0yl&2XW#GnkY&} zAnaGkwYEDZIpW4+-S>u0on`DwSI=HlGBh;2qo=HFOE_GR+WdKi1&g0wwk|?EXlysyf*_UMoVIrfy4D=I!VwsuR*(3Jjl!Yg#)_5C5-)Swf@2ztGAB`aPoFkmW(O-|45mrSHZ?jvO;ZO1YDg=oidRJ^Rr1uqgt zcR!+--Qp+SK{w;qnDWhrV0Aru(pH`_3q01NqPBOj?FnziRf(aO-b~YTAE?+I#+?^x zshhF~T;WH2X%A!RCF9mZ&ZbY$YiTQg;qIxhTX@z=gFYx@^k{PqzR;;@He9tU!hx4Uch0Y`~_GY`K z?m%bmC5N7C&h(5jO;5JDus2j zb9pEG_&1^BX(#*G%E)}3z-}}~o0&S04y_z%X?rlswx8i-F281r>1~@RvBPDyEZ}rr zH(RRWJe0&1EZ9$k?zFmg58uZYJ&~Cxs2X9y4_xURm{51`JXChI9dqyFtwh-#SW)Nr z9gg2B;JTw%I-o{9MiVw1u6}kP#>sRp_*Q{Cab~zmPimL3s%lJ$n#gnbaKzEOU(Zgv zZ#TJMZFH$D{&zOnfi=hFtL*yU3bF|5efry>G|3ZxuU`m>dHq7h%|5$8e@Xv>!pX?S zZjn9FC3il!$Xt@3Z}!%4;aQ90cz)2C3uyF(HnCl?<(?q>O=?gjP9 z1|BD^54)yK9Q_a?WbEiAS+r9>=`2oLX2F_961h!Yifx`D{CpF^pn%a1vrRU*g)2rK z-b@c&Q^mgRJZ3RgEZ4DpQMF|`Kudox_k85KE{Y%l*_K_poCXC=N>6su?O@8=&zSC> zN!Kqq7N#L5NKzk;u(KUf#KTTZ)rrHqg^NOilPlR(gc%kh9b0;OdVE2aZkcSeol`}* zJcGa7m>^l)US#q_$}|NOccr-=MX52B#yIh^wZajj-S3Xw36J{yVmdueT+D7@D_WQ*u&T#VQZKr5Z`idUN0=}1u9#D@ub@k?#OCAnN1-Z4 z0@vf_9T>h!34ObHc_v@E5v%Q}<6h)ATzn^P=9A)zeyI8CfIW&w&nTyRfZ%$4Xa(07 z5^R`FnVOl7eYjbvUzN7M^9g-(=c4>Ze36Dz|9QPhvm_K zNq!|PrBSihVtCd9O;z@$kzo1Q!hf3Hg&XBET%$~ieRUu}X(=!v&c2eZMI%)phsG1O z%H_y<^DQIq*D;y|f52v!E0+!QE6->esyfu2^qs{!w^(7LE}xZk6gPU!NT!>sZOUj{ zzMqP#v{WFt_US6BWS6T9{eG-!QR#H1eNor5H+$E9CLRt^I}Th~Vh33)L)8^o{N}0u zVz*Dd^Fs2&_vE)@UP9Xuo&LbKHI>&P%0yVPR36?Rq)9%>IRS^pJ z$t7j0(yF5kiOTe#j-H&^{oOkAj87FuR%<_7R)35oSFYx{(KJp@;J-N}A59+>?5j1R z*QJPs&pe`Bn`NGKR2DABcbb*)ig=zS#ATJqO>8BjQ%fd8(w-2z3fn246#iNhnqyyJ z>AShdFg;hw6?`(^b~^`69qt@#_!#F@F8Z6<&JF#;o9->lOwX?zE1~)WLiz7@&*!I? z&95)d(Z-f81npzjsGs1R@{QUKN}JMcdaIneOSwW%_S$YMRw(7wm`$w3@91UO1#-8n zoNYmH*ZcHfnF?0DtjY&NGrR-R=g_Zzb@z7+eO&BwmMaJ^o9P|TD1Nn+tr)Y_GE%^> zd+6bv+7rdzx*sKKKb6H!^?o}D)#I_67%_I_vG0jJnu{1TvD7gq%a?UqP|t6k=Hh>k zDX%&j`b*Ge_TJ+%N-4C!QdNi|rbC5l z#095|1WwDrvemyM)20Uv-IgkL)GQglNG$AIZjlY3Ggmiklkmqe*(gVk(=?7>NsYsH^@&Ha(vFt_O!R zetV;~oo-5c(*CR)Pzv|GrSJd@95gI)>a(f{M{Z_UF>KZie`}s3mUun+TV2yIIH@co z6AJoRnPcZN3q=(4*#&1n4r>JiR*#HdKjAJ=d1()D%bW4FJbC#>?S)?lTIQL%>FAT? z&rf)Ktc-U%P$k&ECYFEanqet%@2yHV+oxw9Cv{N7Dz#4$6sc1YhmQ>0+_VDGGe#|z2J7G;G) ze~ATS`P0@5y&0Izv4?9B1unt!OTu>H$*+d$AIJMfK6^HAc;%KM4tv-BV3*P?RJ;7% zg9Cpf<=Rl?bq2Q$7P1tvV>=m7OTScJ-VC}mLKVKgS8ro2KpjoHx1YsN-sg6U zXlU;3buD2}DX8P^Q!?6od4C@=*-ia@Z?0&C=s=zoUZnge#$$VzhT=_O8k78&^L$TL zy9Y0_`pM1D^`xw1{ir>E(a3zHaP6nh6-x1qYVoA3!eKL%D6_EDDB#SYZ_(3aE6d9f zxX!&13>F43$*m@~g(%yOD~jS&Y>vaG$xS&uYfs}(I+yF#U^KAZZWor5xPKWlyU@t& zY618j^`V2IZQ0-uzN1;LvZ-5X6zo;{RY)XGe6a#Yyo?pN&ZR>eu_C(P`)6cldx`d3ai2pkg%ZG@Aj&+uH#C2ji36Gla69>N z8EcepRjkyxGDK(&HKSQn%C&QBeD$_JT;%@crC#3eyj5w(Mi0i$OH>_S^C{O7(bCe| zI&tjw!}RL7V(fb{t*v?NG(6l~;e`FZd~ERf=}6*CXR_pYcFn3z#nf>X>U-5Fp1u3HQ$?ibjQLCeNces&V z=rr?%|KI}FhId!uXgI{oz)_qudx}Z{KG}`9tAYJ@t>u!-d$Q*)ClMy>gUL zkKFcF_R)bA@2i1kZu(EUwYqGPP*DslGiRI=;Y6Me+x$C+j`|#QInt;3EaE+;rc6{n&7?w>n}x zvpm&^@gNox^O;w6KqmD*alpOibD(5qG&`mJ$J1p4A#2Ld>U*?o7!R{cs7X}P#F zHotV5bG1~BOf6RE3{Ac)$fn5BbxuviK#hE2ZkCIcP`g6q5_EX&_pBWAT$Q5kMl!dm zTbFlzIZUwkldOgx)k$W%62A;fwh}ca77`OR%N&L_f+le6?i-KNJEL1M?YhTx?oH3s zc^L#Mp%kaxdYUrgWPggzq@c+j{W1JVu6RY9-EzbTM{&HaZS-?7^~}`K$aACEk15*4 zE4`TEPPx856=N|=yzY^I;(HH*Cnis}v)#AqF>mFrXVv2c9d&B#Z~7uqk)jltG?j;J z7L8N2pTi~pD1l)UxGpvs$MbHh^~*;cU*}g&@U5i>455m?myTy!1qHe~R@ahZUQjb| z#$N4PZHbGNQOJx+zq0)#;Wu*b+!`_l=|_8&$LnGhdN1)nAY3}rC+U79y%Q~Bqc7bf z*ssCnTX85;f95QU+Xj>4D8JQcvH9}c#d(<-Ba5Oj2T!t#EyjLRG!99>l1d$Mm9^)& zq?`BST)Z}qA@!iyGLhbjankSgm~dIi#K#Plq>u}57bmeaD@1;kASks5Ehr4>ujQWm zL@BNDMh#P+?px^8q1S6S zl_x4kf?4*9TCVV>j{a~Q3bV1_m(;zIDk*g_+evBFb&P1yKJE8a2zwF?~^a>e91ii-pSvt_Q2%h7Vjm@{U;chaX*}M@z1a7-FmP z%+`Wi=2xF@P1^Pe%H;6-CbGDz9ru4qbu{D9li5Tk0>D%(SK0nWTX1Y8wwv9o#T7EP zfm^=9lZEdSnzQ73JbqHVm(>Vhs@yFxQOid9F6-h7j`)XnqieW(;b)VDhU4^^V=2Pl z<(nRB4|)47%Jeh|>VNdR4t(ir)O%hsbj^@zp2aE2sCG8Vthc=&&m}w89G6L0*;PFe z;t8a~H(e04h4!Ov!x-njQa3y>p01l>{%E1ZO=i3GJ46!#`59eTbG&Mo?zY~so${ic zcMOTZ5)P4Bl%v~dx1gtDfBbkcK9EPxq4IDZnPNlG9)}r~kKGo(d#@Kwc=YHRcyun9 zdPm}(DrZ$5Eyo9la+Y4JlW^G?1lYw}Jo)z*MalcIrCTjr6VNWGGPgs2H6w9(*J0B$ zh$uXry{qY6=%cho?Wl&{3QQCE;lB6XGNP1_Mz5Wv?C>*&TA78)&Sq}TQLXX%;g`K^ z4skihn=%sHKgKwH12=g~|7=EMCz5hG#SU{@I4H`+GWIscxb%&=VnufYA|##|U^1+; zzXpd!lnNIPzkZg~AGZCIlv5IB0^&tgM{&Kp&1;6n%tvEv0S4W%d)ahqQcFo^(?T^8 zW;@TSf5nH1-8!~JR|2wH7*pA8^y5rVTRH0JvUx}9z=2&m88&&jAvb|rw)~-`scJtz zR8{?P>27E5jEV5L@UFt~v4wJ_b!a!+v3i4}!GvHd#c1VpqN5qb@e170ov}E=(bql8 zJOK*RFIeZjs#LQ?_SW+g-I^yadr&jW1Z&jXfK<7p5P>t=T_e)L^6_+bx1pn2`#{NT zoI#cAd*?{s)oi(ZAK7VhVm=oqi%aEA!p>^(qm|0*=xn0?9YSdAoxE0~Ih&3B=C^{z zy2{kve-!B!xe=Kv#Rf$CU5_v$c9B)H{oZMv6**?j^^f;cq@Ip0vixl{tZ6;|+0!x2 z$#}7sLdR9n-lAGa2Hj9*rN_+VQ}x{@Owbek>9go9VUi8vZ0T_ti^uo*5P{*46dn?R z**xAA3JK$ur1Vgxe1iv+N(>TNS;d^L$83E?k~$~Zgf+L&TD$vauDv0ijrC7E)>j!L z?NpPK7-DANtD{*}>Lz{=GDa)!mgOZ|^p;uVoO!aH{Z&z}-=9&r2t6>``;p`X1YfrDCa4?!UFQ+C?8zw76 z!(dVcmDRQ5{z4nUTM|Tz2S*o?)(oPQ5#)x)x5C1`+*TfK<7oR1&?!uNaQO`ZOe0lT z;%>h-Zh}Z~+>?R<`9ixur?^)rS7yEv@?cNS&{gikLo=Oq?HSPv(Xsjue{SQ z4m!TxR^9`;Q624G;56$1I>#?rhTY{p^B@_T+sslP_=CSZ$s~4rm4(Obl9CAZ4A zJAfAz!*A5(5q)4N*lS?Gw83+Q*qp!Tb}KEleyKn= z$x&MeGl76Om%TO@VzC5}pFTt0Nl9>4^YuNXl(2(2obWc}E`WMeUPmGJHS~BKa@V-o;M0p`|LlI?6nHBSSh4h*y9n&&wyZy->g($h!vuU1f-Hz1y)fNzW={0jL zB@S!DYk>NGbpmOeS(9*}usBggR8`mQGf`)IfQDBf;kpNJd2&IP{cJ8gMCB(BP+2yL zs!lRRPAHhg58g9aA-wj+R9*ZyLqQj`;2cn)u*BnuZF+|x!%z^kZf&iRF<5-^ZjcXz znGR!9h~JJSy0r@q-0Sg9A;$tY4h`S+cyBFF6O=NAVkF2TM4h*07dy*vl?>Zozka>i z5Xd5hh@AGG7ry(|vx=?d_aF6lKAABwb>5nI!14!<7NF@mOH%yUPSQ<0**aJN_O>{^H%dMza9)EQQQkx z7;V zdu}u8@95Zc-CO!y2eNK5B=0jsGS=Y@drct&PexvcnO#tPprp%ikhVpR%*zANakC<&o#=qamO?y<8J1D|iQ%Hh9 z6drDX%67buLyH@$`~`m=E3sWjihyw~_CP0iwilroq$StP|- zcoeEOg?Ov*TeXqgTwG%FVFnIk_5-!#UXdQBNUA%%%-=j3^JekTpyToQhU$F+h43ro z{@TcXevd*VB;a;vpcLW2<9JQjQkhB2;iI*f{?88?lIpaIoV>g^&FmUhNOpJP$-ZAB zu+QIriOCm@LJ}$AUk{sH8vP$!K-N0cCPWDRxvN*;U>dJ3?seV`0>3Ad=4&R<-ZaN#Hq3^>11$?BC&otg&XxJ=0 z^O}UNGtfdEq}Bv1X+c2&aOVg`7`Lvas=5o+N*sPH53jHq>$dgirO0oGNV$0NUcS&c z%0|{RNIB{0?XQ2=73}63$@tQMTi}46>dhnF-WM0@Y}@$dB>8Nne?Is)YMLDF+k>(C z@=VC|9tkaTmKc;uP?I0n2rjoFwHZ|1El`jRrE z%9HL5$?H$g%IeTEZBRQ+LPA8JexanUGZxMFvNm7fs?<%#((|{AtZS^Lr;)_0UvixB z7hWGJ!qr~jEua1T#9?)CvrV8gs^AntJ&tB8nDMPx7Mw-{$LqvE{KP&}e41kD#^Zfe7h9(HY0;eG380VyeARwM!#yU;=)HVS>BEZ5is=*~} z()DdR6e9BkMxIc??;=rHNWm|+6g#xOp{qQYMC~}r%a@WOkM?n~v7Wl>>ibP>f%rLI z?CqzzK))cJ#mxz{9L|q{qs*E`BBX%~!aOYlh!FVQoB0~f3j7GZ{dY-74i2r)-%DNj z72k+V?)4)XgwylaAHKf>*}`8A>7fU#%%M2;ppFH|o~|Oy#D{N4asu?w9)0k01}r8V zuU}u+kWE+xHs@ur(>ze5{QZ}6Y!Q<~LVSVI5sd`Bi1iCeMz3j5K3_ner~q8__(`Og z9W`bFOaP+m#WUv(c;JHRS*2!)!D`n8jGlTnQm|N8pYobYAp1*=_tPqg~fukRi_{@dzsqhKhFYPDn}l zNPnKo_G9frBdY;o>n3)~fP(hJw4CUJ`{;QUh*h=-X3?OoTu=5?br|4qYL+ z?RFR6Zeg3()H#1I(S^-M78X#=t+8(QJR!=vns$bH8I5ZHw$v4w|FJS-DGs<(KEI4 z04z5Cx|LhZe)(!$Vq&7Isw#jG9e5zN8UnzZ5iIc}{=~FMHiKumnP0oS#_hdZtKIsj zGZMXN2LLLWPGI7hTxfvT+y@Fg7Fs*4-a14~(Cd)`^H2bORaX_>zE4G7X{~!?-qDrs z@j}OtpomL~*Xul=n=zFBlcQZO@$(`@n+*)mh8&>?;D+*yJtLVLaUFpE_u%0h#JMtl zJb?ay&;Px8qXN`){HKQ$raR0{Yr_QyP%j01Rq1~0bRW zEjg>Z5^@Y0D|IlCQs_PgsHcFE0lkDklE+B6@|4VehWDXG{t6yRG3MWFXDtFS?#aLT z&wJ4R*Nj*XhM*CIo!X573)2Oj;upX$n;XR-=ovC^X}rP}9xpc>phvch`LoQN+}tf- zkUSvOpn`aU9G$VtjKy!RWR+R@3-)vH@=5`kgb}0)<|;+8k#B!_LSMq-K|(%)S)j~e zO%%kN`I*L#^oT!d0+5Ansh#GFw`U?WQI?i#Fd|&GmE7rin4@A5>+9=Vn}9?!v~dJx zHwLmylb3MD8F*{SCc^ZU4#jBt4JpMSHOfB-29`TF8!F&SON z4zzjMt9V!-T8$TPRn;`vx3q6GgU2gc3j;5z1iWBz#i9T-P!E{wft?Ts-3&(Gk>26>8l+oq)KLa%I6*r`w4lVq z38oxTh!@suWT=73E)23a4g+u9<2wdAcw-%<6r(__UH2oz--oytp+pL93pqmTa=PFZ zio&&yXe7%U*S+!GbCl?X1unu44h!R^%32Gha7KH0v}7q z`dHb*7YI@o>Pf&OMgiyf?(r=9Ba1ql4{31gaXbR5q2fxcSs>UM%AkE4f8%QLOaO2B z#)I!Fu5be~KmA7;W)S=XuU|wl_*WBREJ_U36OHiKplcWEdkbT>VD~Nn&O#vOXQ3l6 zL$5A_5QOFMfx*P(=L~p<@{1QB=8oYdMi)HIhyy%*XuabM0fcf0n4(FVcn;P5=jA6? z=L1R4LUL-ySj2~vP5ym>rjwC+9;%U0l}fL_!OSeJJPW)s1gC`Y8vvXLyAS26SUznf zBTwPRAl~q*iow&XS`2M8OF1yI^ItL?L?8%EMZTecnSDOe+7Ce8G~8Xd&f+*~gSx0^ z6mA%Y*ESJqkP`|>ytYDME62kyfRq zlDB>9ex+yZBBnzGMilg^MdsAo=A1zsg(;j1IQN#Xw=Y`hQKw2$R=3~Th2|(bCGh|U zGrRE1+ILM2s8fWVSVAwp9!B6Uaq1T)qcAbCInd}eY^0##S;GXwGOg=oFHLc8=~b@6 z2!zzCi9RvVh11j0^f$w<66J>Se#mcH=QPEEZc+nNeN@%Qw;bNGY+m$n_jE_)rCVxy zr+Er=cJPQz;9bdxRhgjx=lLcCt1a(t@#j4AEsM@hfnm0tmpVtPuo{Ms;$EdE^+8^q z6rvo<3(uHt0|`Q-bn2#QkP0l`IgrB;pu9$?XwB_r^W&nyi?a$(UC(Cl zG~QNd_`z4Kfzr>~=^&uwWfN-LoEl<$d%rrN!IUb)JZyYm`jASK7h1U0SSgZWUO}5# zh;lPM1RV&a(_&?p+Oz?r7LQIiqf|6l)#l|cK?=W5CHa!i;p71|Zl=M%72Qw`@fu9Y z3zQPdle_H@BC9^({(M+Gdpi$M_`g)%m9j6(6gbqr`~5H%v9)>{F<4IZsiW8W(g$A3 zxa_TJ>iouSHG8r@5ttz&5ZGJ+&Dfig1-Sq%51CbF$_io#-DJjYM^0R|>JRQy??5m+ zAVV?iApomJ-}8yPNg-PBA*k~LFw5W#zhdPA7&YCJc_1)PaaP~3B)9-AFxIb)IYA-_ zh&A)f0yRlf@PiaX>sSakj*-u!ngYtF5=j~_`vG8|gtp9jF~Y+p@lAt}hqch_hbmn6 zKZHVvcK`nz9ad(T@WuYE34F;vryf+v zY9iC$zKnZJ{YWIX7hoSWAK|+T5XK|VK@6+GX$r@BuIj&6Ub1YNNlNFVSUV{h~|W%Z0l zn``A{fp5zZf3SI$+3CgR0ZeI}1aNJZT`-ugV6pj0)l+yWavGr7m~pJTny!7{^d_6^ z#}6GptAUqNr7M9lhk;Dy{{alEbVDT|f8YfTYHDe$j8Z?eR1r^bY}|4WI&h~7;WrnW zS)i}};%NWlCETc4(ajy) zgp^S+)OiSyidC!?G=L_tjVi>fvIgC8C~&Es7X6VM-+U|f=f;nhb^VYC=2;~-BV`bn zH4`W$8JQSyNC$2hQiQ$&3-#9%d^9vB2B;ecn=^SVm2=av1D(TxoA6FN^i*arQEQOF0u9rL3sMlI>@H*6AMf5DnQ$4 z_MjqY@RE`o%)LH;@96{}bO#uY5fd(A;fL$Q24m;(HA5i|ejWCEDxk0l?cLp2dwY6Co4xPFm-LeF^^H;jOVW-5tj*S#jwHA#hD&30)_5Gk7rDbJh z{dh6k>4g4u0J}6^14cA@*3{{V%vOl^9fm!l#jfxCTYyGHmQ&)9wLvx(>_dnH$EPE{ z{pJ+u9^4bMlK?4J2h)D>w|ayk9dW=#x1nVl<6IF4Y2gb6X%l!_jy6qIRq7F;@yV@L zUY0NVt;Wy(VKe}?40eEVtqCCVR!Ds8ttjgUn--gTaW&JKL4ClM1KZ23Z)vysfh~aJ zBWA~-YjS525QRMeQNNDH(g5+TCfG(`_{^etTELf5Z1u+keqOl8J@T35nyi%X833iv zp!Q&(lHm>AC}s>Za`@`hv@L@d#_7eTh)`dB{L6trlVO?OzVCLhFPhw?+xCEuzMivo~hD>fx$^O(M%1LTlF;UGPJ7J&ixL- z-PV!e2|F0IpOU;^3KMtU0s`~$)P^GVsHZ82&0xS8<~y%cs4othnwmtyi7-qMPKRX^ zC+olgd?@i2kz!x@;^JK?Vfd5Lu_ zgbiq0b6@lqj@7G%V&R=+S9UAQ2Y<>v2adFT#$(;8;w-58oy3Fe$(-D#Lc`Y8U7xFMRXoC7vdOYa#D+46lD>;}#< zT2~tcN~oC#neT-Po3~>`Nj8+{ubPAffX(U27*rU#^`2zK)4j+NL+ZOr3Z@w&=_<)6 z)=%{pTSZ2G_v1~NPKrMMq)u$o;7pgQ$_H7GQ;VO`c?PK*vq5rkkLi!sgItS1GdV|k zsFdTh`fb5dR^AE7UPQ@B@vLu^m!?t&SU>UY3vkutfRW=0qtK;{_T^0^zdD=b56Mw~ zxB7+W95bUhTQqadvhcD!Xp`6H>9*O@2Y(r>jp24Jp;J0XM2KU@;fcU%1an??&LoU|#A>kXLkqUa@WkhlI$! z15~*-J@^-a%z&%@*fKvF(U1ae&qlHyT{0{kEJRiM#cI7H9?;9E8z@;2KWI?3UAw~^ z$rIvVq~<2+w$0+H)7u?CdhL6Wa8~SsVCDvZnSxKkRa0= z7)9eZ3a@YTcO951k$@1{x{SFMd41D4;2U#vYD!AevZ6`g+9A*ya}1p3djfnwH|RP;kKGY z%ZbUk!g9QxQ^dqx3YKTiA$gS?qAsF16(r1+6Vcv!e3tf+XZRi^M(y{gHAL45Ow1jXCjDnvxp$A zG4}hds7tCl^0lpZ@}6$an|)6jpSLS=*p$n8dtf15?grh(K;$xpQFaM1Hk62{z5y-J z9@mU0obU0DV{X(%L_`eMZr1o2oYy=-Mb?tHyOB*-TxHS!0J!pSA;@>emZ8Fz)oseQ z5+qyu#jfkvJXo6T5k%2z>%lbHI_WiI2>&Dbl71uaK@IA5$c&{PxV28_86%GX;;#Kl zf978oK)n1V|MernSPx*>-u~=xA6M{@v^||REd$({mWkBkZ#gfnTCh>bkc)x0aO*cC zQgwiYP-~g$JLTf!0TGy55;-0Fzfx=n8CjGpmTyK*+sPjTyJ-6H& zQ-pQoG1v0=YaZHn*9$zbHEii1W$_2~5RL|_kU1DdAMjurCCAifBF0}((^{MJCS?d= z?{nhhpHtnA4+$%HilhRgRmb$ollUye6cF{gohZ_{>;d8j+@52ZykW*?(m*zm;G^DrH~P{7i+&fv(Y;5==5VMVw_T-?YXju) z`s?u44g(uBwJ2U{>H+o;jcMqhoU6uiHm0*pL5Bzt5SK!ibDgskB^?QsDAJRjXOkUf#ZBwKhMuU*pUDh1TCdS#G%!;oI`ZRs2h9C{2cA)zyeK zeu0Lw+xRe_4CMix@;4&PBfs zPd@ohN+LxCZi`mwc^`=b`4ZHaf5o=&xYG=9a(>rdTwO;DfP=8}8cp~dx)mp{p|djr z7+wMBaSkdRXtq>wtTEiVO{|9r@(EbNb}YhK0uj0f==cE4-}F?%TIN5NQ}$)g93ZkF z=&>}{IfpS*s?kB=h>ybr55TMsbdI4r5^Rije7=3uK zk{s)Ct)Q{#K2$|Zm1H)7SG(a->4CERNyT58>cDET9ZT3rmd^w);s;;HcSO1--cJG3 z&eGoyk78{D(2$q3XTl5uO!EzLcBkDD`UI6)2E@gT^`9OJudR?m4x4^qXk`$;oT{-S z09%5jYF|!uPb~TJg-od2{;eDFhakHL2FkygwxG4Lzm~F2y%Es+htQ|np23{27!H3j`-W2|iHeWVj z?hB0xc7z#uIBSd9;v*Vyy_rSh!?4MioaDfV>dng#Q_#X!cabbc1|l|eJ2KpX+v8_& z2BAYDN0G6tH7C9b8W$cmD+iv;Q?bd;UYHl&ssL=ndL(<#_v9F{4#Zv+JA!ehUWQgT zAMod^-7_N}R1ZtO)w9~vyC7iugCbQa>q8-XcMDROM(qgJ5gbCYDq=w8>tiZwJU>KU z)~$da(ywqPvVFp@VX#~j3qj3@G6N)%6sJLb=&Gzc2JoHuZtp2ms^I_N0+i#QGL0k@ zx62<>q1hN^nMCfo%_brPa5|+0(s2-R7c_$U9M7;@Lh8!G$Ns)LKNB=`?_FN;CD$2E zAC!dITEm0JTp42t!b|Bwj4Or62y+*t9BL$Yc$9EY}hQn$!zdYP!x-Q6DL^?KdJ|W1)>&A zEvO#6nvaVGL>r*@EJoZJF8_hj!L!jsaF-r1){V-Y?k5J_Fy1rLAA@;+2)fYrBb#@u zqKquZc-bygWx!hsCei#?{sKwRSo%9gO;Qot)&&&vVs(a*|On z7Q+(W0q6!9<>Kirb&BNEZ^?iDb4~*Fi+?!}|7E=VZ#pvLznfqCFQ4V{uN(XS8&&$R zrTnj*O7pKs{@)hKe~^;=apwOd&-{Ppw4;B;)&I7*`v3ais(&rzzn1bpb<)}Y=^S1Z zvuxEzj+hg`G>}@G+u~-MzkoeW3hd%^F+eqlg7X>-8XaQ z3fOIcoEQewpEVreg6mCHe)g;#_VsM_$sGA_VN6YzKd0M;O@_yQ_i}ckB#K!M2EER!M1R)OL{pE&V znkp$RbpdiOfULEpr4VSYrI-tA>s|1rr|PBxJxP%s+mTulumcO(9@Xn%EO{~?7i7YD z?W0lrW^HDkQBpd5^hQ=Q$*5bxop;^f7DT8YYHDiGV%zULsw#ocB1YVM%;_9@Gav1d zxA;?#`HeQ+P2i=#S>FtT?p&v6i&3hHHJDa^Ic2b_^?sK5=RqU7j#4;z56n?r1I zq_Dmn9;co25O;M(N=;hwh%j89Lv1*%gdxhCv0wj7 zZ?vhI(7<4VbH)VhH0_~S*e#->q;z*c$5v3(9+++mprZu*3WJ5PCfUPOt7-2_kf$JHA_A4d#xh^Po#hRpd=;dlaCFeQW6YS8UMj<3cq#DHhX z0#-H>lW-iVwX*Z;VRNhPP{^IvU`mDD$umNfkd`W7?IvJ1SRPoks1<#et$Mwak2exD zWb!Yiw_aQ3qUnc}1>Xxe zm83k}w*4A8^<{EO!=%jJ)xrMzN96kLu4jf#Aelmt%WM|1U$+Gd6{6yRKMi54*aAf2 z_`uYLxUiB2yfsi6JZi(eab-}^G+~l#Sc?oc=NrCa{qVVVs z*hp~LBu;{b^PhTjO*1&8(4~JN%lXYKiENTaPgd;9{v8f>IH=Qnci0N(yO+;f$J}BIa#-o-jx&X`4%2A@$=yJfgBGn*!f9Nk&y{3Y_q1=~<$%DGc$RWbOd#rwcqYsB^=hw5e;n z^jhj*N&moUlaX~uc;#CS-@IpeL_I0nWAIT2b`ZUXY6k(Av5OpnF&}QMrml_wdrFUg zSB@{#Nj|j!6-6Y9!eTzdDH`|a`aW-AB+J3VBqz!w>2_eJXkgU~1YtkiDoA7q3HPjB z8O}aIEX=Tt4U~tL`_}?hfBOD&@!xxXuWpaEF9#tG*};jGMfJN-xg(_k@o7Oq`FC7umxbKX zKs`C?Avb;uF?++RfvcM8HnM{ijJ-dt7doQz=r7YB0CjF@W;SjCDSD#@4q@dJFj~I4 zxV5zf`#QcsO+$?EIb|;e+5V|b8ZzcAg^F|H+n=CARCuz!WZG+z@Q5jNRs@P zJ0b!#ZOaWS$- z2)swAW6Mj90{Z+=9#?o>-{^aFgE6d7l;Y3`$*?JYZNuF8A&VuT;$of+v=F|5p_`!EG3w@-X zxOumw3V3UE{Dg_MCEq_s6=jx)8qJL?3n*77P6&T=Y!op~{eoM)ccDT$M=R ze!2F0b*9V5F1uLy-p|V4w72*YLhNo{L8_Ta?G9HpcV}#UPysuCarltYi$u7Wu8;pD zvFP_2)k?3>`k6?Up~cn)vCX6D?sq3v*LrX|w$ zwBpKt9g7?Hj4qed=tf6Y3EIV=81BS8Q+(1=(tc|#<4il;(x;^G(hGJ%4}I)P*IqrY zgs-)ku|qa%_NXrl+5Uxls<%J#%3CYsT`(58$>4s~H<3Q^c?m`%wL Date: Tue, 21 Apr 2026 23:18:37 +0900 Subject: [PATCH 08/35] Tutorial: Person actor + tunnel + Academy test Add the *Federating your user: the Person actor* chapter. It walks the reader through: - The `keys` table, keyed by actor identifier so the same table serves both user and community keys when we add `Group` actors. - A real `setActorDispatcher` that looks the identifier up in `users` and returns a `Person` with `inbox`, shared-inbox endpoints, `publicKey`, and `assertionMethods` populated from `ctx.getActorKeyPairs`. - `setKeyPairsDispatcher` that lazily generates and persists both RSA and Ed25519 key pairs as JWK JSON. - A minimal `setInboxListeners` call (URL templates only; handlers come later) so `ctx.getInboxUri` resolves. - Local verification with `fedify lookup` and a direct WebFinger `curl`. - Running `fedify tunnel`, why `x-forwarded-fetch` is needed when the app is behind a tunnel, and how to rewrite *middleware.ts* with `getXForwardedRequest`. - A screenshot of ActivityPub.Academy finding the local actor via WebFinger through the tunnel URL. Matches commits ad9ae55 ("Actor dispatcher and key pairs") and 4d462c1 ("Use x-forwarded-fetch in middleware") in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 415 ++++++++++++++++++ .../threadiverse/academy-search-alice.png | Bin 0 -> 88132 bytes 2 files changed, 415 insertions(+) create mode 100644 docs/tutorial/threadiverse/academy-search-alice.png diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index 5a8479094..a5993d3e5 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -1355,3 +1355,418 @@ the request and returns the default placeholder `Person` actor we saw earlier. In the next chapter we'll swap that placeholder for a proper actor backed by our `users` table, so searching `@alice@` in Mastodon or Lemmy actually finds the account we just created. + + +Federating your user: the person actor +-------------------------------------- + +All the pieces we've built so far have been local. In this chapter we turn +a local user into a *federated* `Person` actor: a server-side entity that +other fediverse software can look up by handle, send follow requests to, +and verify signatures from. This is the first chapter where ActivityPub +itself shows up. + +### The keys table + +Every federated actor needs a pair of cryptographic keys. HTTP Signatures, +the scheme that authenticates server-to-server requests, uses an RSA key. +[Object Integrity Proofs] (sometimes called *LD Signatures* or *FEP-8b32*) +use an Ed25519 key. We'll generate one of each per actor and store both. + +Open *db/schema.ts* and add a `keys` table: + +~~~~ typescript{3-8,30-48} [db/schema.ts] +import { sql } from "drizzle-orm"; +import { + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: integer("id").primaryKey({ autoIncrement: true }), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +export const sessions = sqliteTable("sessions", { + token: text("token").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type Session = typeof sessions.$inferSelect; + +export const keys = sqliteTable( + "keys", + { + id: integer("id").primaryKey({ autoIncrement: true }), + actorIdentifier: text("actor_identifier").notNull(), + type: text("type", { enum: ["RSASSA-PKCS1-v1_5", "Ed25519"] }).notNull(), + privateKey: text("private_key").notNull(), + publicKey: text("public_key").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => [ + uniqueIndex("keys_actor_type_idx").on(table.actorIdentifier, table.type), + ], +); + +export type Key = typeof keys.$inferSelect; +~~~~ + +A couple of things about this schema worth calling out: + + - `actorIdentifier` is a plain string rather than a foreign key to a + specific table. That's deliberate: in the next chapter we'll add a + second kind of actor (communities, i.e. `Group` actors). Keeping + keys keyed by identifier lets the same table serve both user and + community keys without a schema change. + - The `type` column uses Drizzle's `enum` option, which in Drizzle + + SQLite produces a `CHECK` constraint that rejects rows whose `type` + isn't one of the two values we listed. + - The composite unique index `(actor_identifier, type)` makes sure + each actor has at most one key of each algorithm. + +Apply the schema change: + +~~~~ sh +npm run db:push +~~~~ + +[Object Integrity Proofs]: https://www.w3.org/TR/vc-data-integrity/ + +### Actor dispatcher and key pairs dispatcher + +Now rewrite *federation/index.ts* to replace the placeholder `Person` that +`fedify init` left behind with a real one backed by the database: + +~~~~ typescript [federation/index.ts] +import { + createFederation, + exportJwk, + generateCryptoKeyPair, + importJwk, + InProcessMessageQueue, + MemoryKvStore, +} from "@fedify/fedify"; +import { Endpoints, Person } from "@fedify/vocab"; +import { and, eq } from "drizzle-orm"; +import { db, keys, users } from "@/db"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + queue: new InProcessMessageQueue(), +}); + +federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + const user = db + .select() + .from(users) + .where(eq(users.username, identifier)) + .get(); + if (!user) return null; + + const keyPairs = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: identifier, + inbox: ctx.getInboxUri(identifier), + endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }), + url: new URL(`/users/${identifier}`, ctx.url), + publicKey: keyPairs[0]?.cryptographicKey, + assertionMethods: keyPairs.map((k) => k.multikey), + }); + }) + .setKeyPairsDispatcher(async (_ctx, identifier) => { + const user = db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, identifier)) + .get(); + if (!user) return []; + + const pairs: CryptoKeyPair[] = []; + for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) { + const existing = db + .select() + .from(keys) + .where( + and(eq(keys.actorIdentifier, identifier), eq(keys.type, keyType)), + ) + .get(); + if (existing) { + pairs.push({ + privateKey: await importJwk( + JSON.parse(existing.privateKey), + "private", + ), + publicKey: await importJwk(JSON.parse(existing.publicKey), "public"), + }); + } else { + const pair = await generateCryptoKeyPair(keyType); + db.insert(keys) + .values({ + actorIdentifier: identifier, + type: keyType, + privateKey: JSON.stringify(await exportJwk(pair.privateKey)), + publicKey: JSON.stringify(await exportJwk(pair.publicKey)), + }) + .run(); + pairs.push(pair); + } + } + return pairs; + }); + +federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); + +export default federation; +~~~~ + +A walk-through of what changed: + + - `setActorDispatcher` is the hook Fedify calls whenever an + ActivityPub client wants a specific actor. The `path` argument is + the URL template the actor lives at (`/users/{identifier}`), and + the callback returns an actor object or `null` for “no such actor”. + Returning `null` makes Fedify respond with an HTTP 404. + - `ctx.getActorKeyPairs(identifier)` is how the dispatcher gets the + actor's keys in the right format. It calls our + `setKeyPairsDispatcher` internally, wraps each pair with metadata + that Fedify needs, and caches the result. The returned + `keyPairs[0]` is always the RSA pair, which is what `publicKey` + wants; `keyPairs.map((k) => k.multikey)` produces the `Multikey` + array that goes into `assertionMethods` for FEP-8b32 verification. + - `new Endpoints({ sharedInbox: ctx.getInboxUri() })` advertises a + single *shared inbox* URL the actor is reachable through. Large + fediverse servers use the shared inbox to deliver one copy of an + activity to many followers on the same host. + - `setKeyPairsDispatcher` is the hook that reads (or lazily creates) + the two key pairs for an actor. The first time you look up an + actor, both keys are missing and we generate them; every subsequent + lookup returns the stored pair. + - `setInboxListeners` registers the URL template Fedify should use + for the per-actor inbox and the path for the shared inbox. We + haven't attached any `on(Activity, ...)` handlers yet, so the + inbox accepts no activities for now; we'll add those in later + chapters. But registering the paths is what lets + `ctx.getInboxUri()` resolve in the actor dispatcher above. + +> [!TIP] +> Fedify's inbox and actor URLs are derived from the templates you pass +> to `setActorDispatcher` and `setInboxListeners`. If you later want to +> change the URL scheme (for example from `/users/{identifier}` to +> `/u/{identifier}`), you only have to change the template; everything +> that calls `ctx.getActorUri(identifier)` or `ctx.getInboxUri(identifier)` +> follows along. + +### Verifying locally + +Start (or restart) the dev server with `npm run dev` and sign up if you +haven't already. Then in a second terminal, ask Fedify to look up the +actor: + +~~~~ sh +fedify lookup http://localhost:3000/users/alice +~~~~ + +You should see output that starts like this: + +~~~~ console +Person { + id: URL 'http://localhost:3000/users/alice', + preferredUsername: 'alice', + name: 'alice', + inbox: URL 'http://localhost:3000/users/alice/inbox', + ... + publicKey: CryptographicKey { ... }, + assertionMethods: [ Multikey { ... }, Multikey { ... } ], +} +~~~~ + +Check the WebFinger endpoint directly too: + +~~~~ sh +curl -H 'Accept: application/jrd+json' \ + "http://localhost:3000/.well-known/webfinger?resource=acct:alice@localhost:3000" +~~~~ + +The response is a JRD document pointing `rel="self"` at the actor URL: + +~~~~ json +{ + "subject": "acct:alice@localhost:3000", + "aliases": ["http://localhost:3000/users/alice"], + "links": [ + { "rel": "self", "href": "http://localhost:3000/users/alice", + "type": "application/activity+json" }, + { "rel": "http://webfinger.net/rel/profile-page", + "href": "http://localhost:3000/users/alice" } + ] +} +~~~~ + +Peek at the database too: every time the dispatcher runs for a user +with no stored keys, two rows appear in the `keys` table, one with +`type = "RSASSA-PKCS1-v1_5"` and one with `type = "Ed25519"`. + +### Letting the wider fediverse see your actor + +Right now the actor is reachable only at `http://localhost:3000`, and no +remote server can verify a connection to `localhost`. To let an outside +server discover this actor we need two things: a reverse proxy, and a +bit of code that tells Fedify to trust the `Host` and `Proto` that the +proxy forwards in. + +#### Running the tunnel + +Fedify ships a convenience command, `fedify tunnel`, that wraps [Serveo] +to give you a free public HTTPS URL pointing at a local port. In a new +terminal, run: + +~~~~ sh +fedify tunnel 3000 +~~~~ + +After a few seconds the command prints a line like: + +~~~~ console +✔ Your local server is now publicly accessible: + + https://.serveo.net + +Press ^C to stop the server. +~~~~ + +Leave that terminal running as long as you want the public URL to exist. + +> [!WARNING] +> Tunnel services come and go, and occasionally a given provider is +> unavailable or drops your session silently after a few minutes of +> idle traffic. If `fedify tunnel` hangs on *Creating a secure tunnel* +> or your tunnel URL stops responding, the easiest workaround is to +> restart the command (or fall back to [cloudflared] or [ngrok]). The +> URL usually changes on restart, so expect to re-paste it anywhere +> you typed it in. + +#### Honouring X-forwarded-\* headers + +When a request comes in through a tunnel, Next.js sees the tunnel as a +reverse proxy. The real public host is in the `X-Forwarded-Host` and +`X-Forwarded-Proto` headers; the `Host` header itself says `localhost:3000`. +Without any changes Fedify builds its actor URLs from `Host`, so remote +servers see `https://localhost:3000/users/alice` and can't fetch it. + +Fix this by wrapping the request with [*x-forwarded-fetch*] inside the +middleware. Install the package first: + +~~~~ sh +npm install x-forwarded-fetch +~~~~ + +Then rewrite *middleware.ts*: + +~~~~ typescript [middleware.ts] +import { integrateFederation, isFederationRequest } from "@fedify/next"; +import { NextResponse } from "next/server"; +import { getXForwardedRequest } from "x-forwarded-fetch"; +import federation from "./federation"; + +const federationHandler = integrateFederation(federation); + +export default async function middleware(request: Request) { + const forwarded = await getXForwardedRequest(request); + if (isFederationRequest(forwarded)) { + return await federationHandler(forwarded); + } + return NextResponse.next(); +} + +export const config = { + runtime: "nodejs", + matcher: [ + { + source: "/:path*", + has: [ + { + type: "header", + key: "Accept", + value: ".*application\\/((jrd|activity|ld)\\+json|xrd\\+xml).*", + }, + ], + }, + { + source: "/:path*", + has: [ + { + type: "header", + key: "content-type", + value: ".*application\\/((jrd|activity|ld)\\+json|xrd\\+xml).*", + }, + ], + }, + { source: "/.well-known/nodeinfo" }, + { source: "/.well-known/x-nodeinfo2" }, + ], +}; +~~~~ + +`getXForwardedRequest()` returns a new `Request` whose `url`, `protocol`, +and `host` reflect `X-Forwarded-*` headers when they're present. We then +pass that rewritten request through to either Fedify (if it's an ActivityPub +or NodeInfo request) or the normal Next.js pipeline. + +> [!WARNING] +> Only call `getXForwardedRequest()` when you know that every HTTP request +> reaches your app through a trusted proxy. If your server also serves +> requests directly from the public internet, a malicious client can +> set its own `X-Forwarded-Host` and impersonate any domain. + +#### Searching from the academy + +With the tunnel running and the middleware fixed, fetch your actor +through the public URL to confirm the IDs now match the tunnel host: + +~~~~ sh +curl -H 'Accept: application/activity+json' \ + https://.serveo.net/users/alice +~~~~ + +The JSON's `id`, `inbox`, and `endpoints.sharedInbox` should all say +`https://.serveo.net/...`, not `http://localhost:3000/...`. + +Next, open [ActivityPub.Academy] — a throwaway Mastodon instance the +fediverse community runs for exactly this kind of testing — in a browser. +Sign up for a temporary account, then paste `@alice@.serveo.net` +into the search box: + +![Screenshot: the academy found @alice via WebFinger](./threadiverse/academy-search-alice.png) + +Academy looks your account up via WebFinger, fetches the actor JSON, and +shows it as a result. That's all we need from Ch. 8: the wider +fediverse can now *see* the user we created. In the Community chapters +we'll pair this with a Follow handler so the academy (and other servers) +can actually subscribe. + +[Serveo]: https://serveo.net/ +[cloudflared]: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ +[ngrok]: https://ngrok.com/ +[*x-forwarded-fetch*]: https://github.com/dahlia/x-forwarded-fetch +[ActivityPub.Academy]: https://activitypub.academy/ diff --git a/docs/tutorial/threadiverse/academy-search-alice.png b/docs/tutorial/threadiverse/academy-search-alice.png new file mode 100644 index 0000000000000000000000000000000000000000..da3646a862927ec16b3cb115706915dc467b3655 GIT binary patch literal 88132 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 z zmLyr@e7*okrkd|!tR&ke7}Tv6l>Y3#xJDJ#WXOHv<8Z8jN9CWR%(gd;r7`C`@daP)*b%=)8&&N+p_avQ{i5B6Ud&` zDv^nv`TG+Tcuzx(#0Yswq8|?_(Am_OXn%(Tps)b(NLo%IZs~?IKjBTk;avxJ!}*7* zDmUBFp+1=k3ymqRXEk<* zhQB}b+!XBQP7*d44L52~z(i1KUo&?kqkD7n z3~>3$pO5{hsycO+BWBfkIGcBV7ma(_fxIoNi8oeM%!-PdUi*a1);ayZ=Nc;!phgq^ z1u!YkAu*Nvn%tBbW{7wX(>c`6jWMhbJW-1+1)0KLk1Y;$HJ6sY-?_gGvy~NwMLrd_^U}NN zH-QG))0z~Q!pcZ?fF(lwW{ryS)deDMS1w$x<6$F@F}@dxIM+@U*K=1?!fWt!HrH!+ z@?GgNb*ty~w|#)8emQ4AsrXtS%6W1RN!ESdbhb1BfmWHv*zJyl{MV)%;{?dLooIrM z?qq~MzyT%Bw&`$#`YuQZH$9|od^SPMJ9KnyB$}$3%X@9=&l=kbwPs0i_bW1` z_r>~@bewS~;Y{HV?bYQ==JI-8KK6ZJJRvpNjxXEO9hsVUcqum2ydWk*$?+?awwpFQ zQLcs$RKk_nE$Jm~ZLD?t0s`;f)6QnrIPc>6i2BcFA(;}HL}<>NcJ(>pNL9Q;qTv9J z;qC(dg%6tZuFmCPjtJ05|02@y=Cc+eu9{P3s~8zo7?qB)zI~aGf)exQ-wO(b4b`60 z;{meI@4+#69i9RLqC0Mg>7uAVL;0fRSW@*>HQX<;ZZhs%zi`c^$k4eFSuo+36qkW* zhi-&ZY1xr~%13P#p26wwRBsFBal!rP#~n*^e*)JXK1Xh~Tl2f@1>mjv(r_>eFbUS5 z3PeTaN4oJJQeUpu9p=D(07@H!rE=T1cOR%Zwb_!n019UZN8z;~hiY-6(bWB(kmCjd!b^9AL}1M{ecx`q{ZF>f?FmFRL)CM1#fmN(6J z+DbtyaY>nFZFWlsjl2b)cjco$}OWLP99`4OTpbY9dI}z zE3(&VJz*?fRm|dzDb)A0^fU-AN|T3z(o)_iWO2Lo)`ro9;2G8$_sl*LA8++Ld*0kQn=9nyp4LL~HOjy{ zM?11`e=1?>MA^ryexdIeS5!Pse^+$VzXvV=OWGmiTMh@LUF=Ggh6^1KSpEQPO?ZKW zo6e1`uq9aKn6-~+hjVgy#faVAg>xH=0_{>Ep`}7WZk8=YLw0mUkp=^k$rlc-FXLLZ zMFD-T)v;!TNkWThDKb6W5HM8mW#LOQ9qzV!Inld~mb0^P_f;>`5twCig%w_}vy(%S zyJD*RuuM5&91)xA)3|WMT3|dY+)4w036z{_mJ-TF`<#sQ3?jY z-Hlryu3_II{taIesQ&f*GvlKCs*8Ig9h7^QtyD58(72l*a6WGtrR{rpFVh5`X)%A~ z^491O{j89hWEl?No$Yg=< z5Y0u567MCox$WO4U09PI>%S!ccq9%N2MTrC!%H#J?+?aAKL`l$2+?bHEH8B}jrIYC ze2*_*Xn_mp3iDuYwzE^Z(BR9sZr99#9#b{MA*ak=16tFUEa zN4eHnsGq1EAFD~1_#A9gyIM45HN0P>Du-c@MChn!kh>zWoyCBt|7`nE;XYm3b?x7^ zRG_c=RoUaAi#A^$CBTdJ4;aAty5c3uei2{7;YD&T9H%wYxSwR=qflbKd5Q&m8X1{b z$#?F~S35<7PcAR+Ie^R#w%a$`#!;Iy_%_xO&6l~DKp2|ByvD(EcT$bTe4c^%f7fV{^hM@@dkKAk1yC_b~kqH?JT7dlehjj;3hM3 z{2VA@-o|W-kP47yU}O2KsMwipiQ)kR422(S5&KxE5*KEDC+u|bW>O~@9XV}i?myx9NZJxb3? zGCQ)O>bAZ_YWb`}F%ow4u9)#e1L{$gRxV!G+jNa7MT<8$ z%=hv|bV#y0si@H|)Hmrsy6UHxYd}m(+lxgBJC*J26!OCnrbO*86)nz#3lZi@2{-A& zVoooCN2>o>6c9@P-#G%0uYgXjq3dX6Ai8^?ovEonx8Xf%pf&5p!K+{C?LH6ofRp9_ z-2vR+<@H?Gk5~1`t=4Qzdnt3}$;)ua1V?8_d%70q?~P2{vfUOcWJ#kt%M^&!j5klv zX79YnQc1$X2LR0S)7B+431k8tH8BRW#6Tykx*+ikpKl`AwO)wQ`2!Q(!_xky);^RR zm+yV?o~#RQcPJoszGhEJ({>^yPtxx;JmNAEL(-bR0Uq16{l8bum5`W#ef+DE2Y-No z2`5j$jmOrbvP8D5e}LV+j%z$yjaF@7u()R@_?UI?T`A*Z^)RLuZL&RdU8WJ-1Om<|e$+>%YTlMz?>KR`Jgch0@!OADi9Y_)vZi&p z@iBGMoi$wervmQQ&uXtvty9qgktzOB@#)EB-_H}B`$#-kWU#SY5R@LJF zzy;A?!aR?X5${>jiVb-{?exb3uF$mK%2qzi6@p(ax$Bcd>ONYSzPH@9>)&)Bxe@96 z$joFSjAoen*@IL5-8KO(++>)N1u}k;-@B9WlMAba3)2K7v*UQ&^dVThpqU`kpYxNA zEydXQI}sM>*zA1kuUc&#`x!M*IZK6&)sx?Ur-UmdD(lKb^xrc49Sn=W9F&%^@Ogz{ z&i7caJBL!~u)Sy=1CMyo0$BSoi1FAT-^y9*6o|(rXOg%fjkGx0Q6JQd(7F|u??UuD z5Xz_y0O;|%AiDkasXsRya1m&$f&ee+Jr+WUipLXJ<2R?s#ncd4D-$C-+w`+x2vCc{GGG3Te029ldvTI+Thn64ZmYP>orB!Ed4SbOl&LcfuaD(ci7Zufaj9|gcj)!2tM4Q@4kBAwasK!O$!3BX9BqXv z-8%~Mga`{W9t62pjqX@Ae>a7e^%+MD65h`K1QqjeG=!&BcTu!l*itPC@i4Lpyen+3 z?)o!Sy;@n-q(vg-_Nb*M*WQtyJYr^^&oSJ$c^Vv@ZD?khnHBfO^Bgm|kB6r&qurtB z1|Nf_Jm|y8++TTxDGNLQfRN~PO1FzHz$KO9|IKfg;}u^K(SWc``O1TDNaW2!o@Pp4 zWJHM4TpiYK48^bgPTb`;7VBqD6AHtntferrY=sMxC>L4+OyH^p0P4}%f{uYt0*J8w zt*%c%02>M(Fty?`&I)M>lFuP{QR^?s{kd%2;R4TQhR7E>J+R>QP|{(uR4d4HI}vHV z8ZZ4mwIME&b7L_jjgzh%1Ajxx&Dps!PL(Rz_&oQ#YvdUFysGHL>BG$_I34xy*RJ2< zD3qy;gkzg6F-_lMBHj>}uf5*SR_Nwwz5Zi2M7BwOR@$`;K=`;Fj2|D|;PY7byPlub zRJk*;v&%$BFtM{jr^A$~Ut!v3XAf;f|QVouo%kqQmgL>$!SLm>~a{n?s+xWM&7aB z&&iqJsYF{2O|^v0Jg>t4AT&~R7V-<;=dUuxP2NeQ!`8MmQnK-hNs1Q*)8@O`JLl$D z7EpG9k%0hx!|axoef8Hs_{84WVe{mD5#2s~HVHF}!*CWr)Z_}eG_5EWHS44# z!;hACy28HK^@f?=0i2ma?PkB0SSYkXUno(Vg4i>!y0!E)F^6xdXF0@=o8#52se0lR z^=q=Rn7O}mwE!E@DC}Daq(ZhpHar6RU<$smp9d2L*tGSRBgrG=<82Hv^m_sp*LCL* z@@3&+mQsA>uQqS%s+?gf z{?|eH{)g_0*5dPXW6Eim>%qi{Oh!bGD0P-Ga#D|xgIqgxAfFMGLgtW9dW z15sX|oWIM1qg?|USFvV79bOOlH7yO$CUr@x*Xo1t%;h~+;3x7$o1_>8dfI1vDrZ(& z#);0cqS#c~2rLiOCrIis7t=2HUOT42*dU&*B&^1YC9{s(* zP{`a+h&ZPz=lY!H3^Yp%Zm;A&_@Jiy)w)rIk9~Dy`7UU+aprIF1mE(Dn(X=WU3*Pki2|Zq?Ix)IVd!JaWcH$-`CK7yLVjd83JC;+aTrp*&iOLcb<2^I;RH(IB ztb?>P_+QsW039~p;K+ja5;EW02h<)?n=BHO^2 zq6MRxYSPx2Znp-25rR^|dg>)0f;VO?UcUk0+WRB~sc`WJ_K*#ZXRY;_KpXZsz~F#4 zBhJq7Fk6aQU4*=^>>u4;@v*C`C-6A)y2(NfG%YGB>LTb(QhoN-1VZ92=^e3ixU_UsAd-zYEq->Nqd0O<4IZHBKMS zX)cjn;?+>Haxq&LSJxIFlo%D3`o*o6bwSF&&NT0CD4&-8smmT9cc7puSjL-p*QoUI zt~^=|?_#SOsE3u-`>O$rP4{2wfN=!W!4RhWTO0|0GH*h>TL@XtnHk0A+# z^uxO19m<5;rcdDCE#u|F&+v_?Gr%G?6krCd#AJXL+&4-8f%aXRAA}q%T*&T|HM$$o z6Gw?AsazX_+tS-!mDT61Yp<>NN>hHdF;z(|8!+(Is@Mu$MZV}zoOZnp7}jnb znCepPwkUG`54*>`D1hW0-|hS6C*zI7{gZTDh%s5c0X{W3*umODIQH2pQB^~C{;z@% z!w=aWMS#I0xVQ6rObJ||%K4a<)#l}o?8f`Y-KZ@{oUVLDBhuE!mLaC}*u3mMAJF-c z5AOOK`4~|3XfP4s|+eYiD4(_xLW5@J?4dm_M0$ro5UYP8OV(B&bV59*YXnx<+W@*5efoVIX%P7KoL+)%g;X65*RIKcv+ zPkVy|3vDhD#aBi5gcM#pMQSm74z@h~LlbFw$le|0j4|&RIu=QD+xj21V zmzRg5Jco7TtkL^XJK2#rw(t&zYLxi-jPGNWY|byIC4j5$y3g@;Ybr-O!laOvYN08N zF2f$2#qa(?y#pF3)zc3c<%4uZl;9G5qraZJ-@rJ``PiA-9sml%)ZQBDg!u_W*f&rP zsL|v4q3%*Bddg8eE7{fpmM!l^;tvB0!@2K`rxsF8UPagd?{i`iKTd`Qa$CWCT4l3=E zc8NX!u@ojE;|(z73HW1RD-&Y_9u1c6n-c={&t(>m_Q{Hv>C`yR5ji4yezt0_jf%wO zSOJH2ZN$5KtLJd}9QnrMQCC-g2AR`X$ivDP{5**07oq1H)b^^`2&TjKyA;+WRqs_p zW3d9wOLjL2yO09Prg2KNfeCRA%g*m+%EUgieFXiLdvrJ zTw}_KHB9MS;9;VD^bxf{fR?K$L5=MWXKxk8c57DjIdtg} zS;~|kv5M)Xf(jK!p{*%H?P&l_arZFsFjcP0&*OZG1;Fp;(QwN31-mu=nzLx{ z6VII{v#%B;<})EDOI8Y~TyBxQ&jV(HWlQbb9ADi?ocQs3+K4vAv++pTw1igF_3s8G~>b zuaSdMkXs7!(f5svi1N?|-C&SALqd>y@6z1zuvQ zcsC)JyCgYJf-L>09>G}sWK>BqslE~umCCCax}xH|pM#gSx})VjmI}k+1C=0#L3xFh z`$0_&-b(-p7*9$z*VM15-Z)FAc3!$=d{dn;;-bt*NoU zDb&r-pr9~lQ@&X|;~T&Nq)f}YvCxy>XyK)klD3&$Gogkanes6wLY#%x&}s|o2k+$w zh^jD`N+e;>1jJXE|9KL$TG$C)P7;QrRH_No(&PWM@yfPKQe?tB~)Hijo>VCHuJk2xU0*hL3VKqCqV$TODWNiNk-+~*d2$P zS`c2>>TEQW=H*J*99KWS!R&~02VAYHT8`{nCAnz>XU4y4enWruHU%iXB~ z7Xh8TfNoHqh?0?^h>lU06 zN``os^AoDGG~FF?N%MJ$F?^yO<@SSeRCDDHYuVuMX%Rl=Hag^MoavCJt7s=pEV$?T zi>Cnd-Vzd76-F{_V^FjV7gs7+mdUwzNCT#k(7Ur2zpjypP!uoLa$a8^e=+mIM9!$)ny#7@H^?c-zX|Xj_Oh}oLkyA%+FS?M9TtTTa{qQ_ z&gyX6wo);y!T3bZ;|2?=F`oygrKh(z%~$9DS?rj~6m)O$;V>EOjT-76`-}BN*zjM9 zJijE2WP>#v>6Q^-+WEI^JMl}ZuBV=0YYG{2(NsO04&WvQ<0dVXVivKJm{YHE^dF5e7?X%9d-I+`m}iupzptdQZlIWb=&b*Vb;ci2oN z=yteR=(`JWYLqXWR%cefP{Z+J-IyDvO}ND0rPk*k$*R`A4E^k)uL8+nejRbVC?jcQ zq+Z~b{Dk&?5t%UC?hq|aU05qIt)eTZWr@UqRY{GjF~(8<)c@F5?u)Jxo`>B}m+Pfc z=J)q&foA&K+vh5-X)954CuC6c1rY+;3mp96_8$T4MXhZRi4wpv&6)o#M@0fnWA?Gh z%Ah5!?xAihJvE7ZcYjsG%1B#7H6t@t_L;fl9kd0hl7nOfW*FQ?oCE~|9JuBEN$yWV zs0`G+1Ob*hq*UaMMAkwfK==>n3Rp_#)m19)9@0Yha1zuHDXVE6(V&-qKuEP)OGXK( zW7&u`73UQ@WiA$LN=u8>KV!fhA1w+o5z&PH$gaM}dfBXgg**Df9sX3kZVN7)iMhlM z+(ATDf)tu;vYsvft&8*iHI-OL!C>}cwWG6^mgQBJ$exmdjAQ@Ut8T#HX}P=y1&q6L zQTe{Nm&=t8?z%GbqoW2idQ z%lVv5w?$Au08+Rj=(_O(Tu#wFy3lKOHpSTDe&pondU1L&+&?VfyJ6C_(xD*Yb#Zoa zJ~+}Z>VGTm=*WR~;sH!O4KUlpdit%6j-usZc+Nlw{wji0l3`tw>6%z>0W4rWGs9mN zAKyDKZeYW(c0fCQXQt7ldePzA#7KF39)zB1?pG==DkZ0;oL`Nt9wPYk>dgn2(wZ7? zwF!2$^Xa4keQrY&BOn_E?BQlR?pBou`zBMDJwj*Z#b43O57+x#Zr9YzWG#|{EX<6Y zADZu;E%XnUd=P`=R|Xi->#&zy+`>xaq7=fd+LzScn(Jo{6cdCD49XtpCgFU$zspR~ zRZXxrG|lsto&>Bg1XVzIFe4;3Jzw-xTf3N56hZ#sexL~-_QeiRUS8VI2GOzD!WbZ# z_ulksHG>9ALthXNAa#weUl(-+oqyN?@E@)$zjj;5(JWgAh+zv9IZdTr@-Cr!+o||y zLA6S^qb;`-(Vn@rYcj5I_4Br>vfi}MVShoq(HLJ#v;EO;9Tvpw#hLN?renu zlMp3)VLaXnL#+Gm9gT0Z%QR<^2}uM*f-Rn03Pelh&ygS9ZSR8$fy@cH;8rfA)ZJm! zFkUlqq}LpxQICuGo~MNCYfx0_V{XD34HN*hIiqcZF$@h1#>UiMOQ7xe@slc;e(4FW zv(_xto^G*U<$@?qr+us5gXsIM!#BOxVFGg2@TxxlQvD9QVNx=(blz{DP@k-Jbp_J> z*`S!74g?%1M1({DC_x1asQ?%<^TTQ&qmABP6KXB>#|CI8XNk;G(In0fFfAIW%kN37 z2Ac)IPHtu8Z#G{oqgMDMBv?aTeb~?HC@il}%;$JnQZE#zQ^fnq?+$sjX=FU1sumJM zDqz3fr9P;c4}oZ<=#1HbLhGxm`iDo{T->rm%#W5*xr&)e@`ERoI&LS1msz*&Z$3!` zvuHKL+8S0`2?CvA>s6hmC@5*8LtS)iWk33MQ|EyrH-ZN}P${~|2&{+QpnC~ft817f zViACy{?3{hJf-c~R%o7y(9XN@dn~IVVt+ogBx|qBhJB+zTk^|4#=H6P?OCIy|U!cc4B^(3+ZdPykJR zMp(6gaYZzfAhPJTFH<+1hI^=$#7Xb#c!g^rRU%umwOA<8#_YV^CA0cVafMQ44x2 z9WUk!$h9uk8e3r!3}sTAi-Rn#9=hkeNH0D(j&R9$11hPb75C7*bmHxzqXlPWdut7P z8!-gFi_1m6pBI9xmshpC&qTyDN1n{i(I8|?MVYBf~G*$apKlTOYuIj)+! zIVRjT_RWe2wx}R14FZ%s6yr9HQwm?Sd98e8qTu)9v`q_9`Un&+CF&qL)(37Q#aGVi zKrT@G08rjOWqK{?`3Gg_a4CLmJ0!yQ(`6uNx_ep~@^Vr!_s3r4h{)aK!>famzRqzlHMjrt~7r3_PNC+lVbk3xUsQ5$Q>>k zjvlOvxw4@Dv%D=OEuv`J)BoU+(AnwAjNYibVsUO#G@KWv^NH#`06{Z+28@`?;fvbI z!5bQLocwI`Lg*#w$C|O@w4j175~JE$+8hcs-n%1Yv*jx2yyea*$T-u%qWinEb7o+; z5t2Adr`@|RI@Q^%rK#n3wdE@H8J4Wd+f6m*7SxCxkrvTA zoHrlrHvUwI-oY~YRy+)Q!U2mwf8=}(1vv$fyaNgN<>JBgV7BjNqIyaD-NE4}i4YGj zFP<}Td4<%gi-uK90r#VM2(j%^`gL>ppiL09Z9JsBU0ao@rMiOR__pzFVn;+()wjx(5eA^M4VzJXaHej-Re#nG zUDWt1TSL2W_{TybS+*b_6Y|;Vkph0J8fWCYSliL{fmo3;f9|7eYf9xIV%rZ$+4qI6 zep8U`FZw%vq9lALrQj*+{aH)p(5l7xfn7J3vTfPO*Gv z{`d+XBRVa=ya}ve6<0ry;K%%XemJ67i5?G5D(nR`v-#bDA=N7qyR81--7f8QAJNpD ztF7Axvjkcj9z##zf>-$`@}3fJOLrSjr~b@IwD4U}2)nKQl_4mxV4mx4wPs{x%=qB_ z!Zd4Qqf6Jn)NC*efJssD`q5n|k?d%QN0ua0wT*)Cc^hiekVA5yjl2aIP~qJ*weCR6 z6ltz_Jb8g2vV5apGj}nThOe(gxg_GX(AvY^Hae9d;OR1&E#Iw3wIavpJD?_KDagnc z*W|opStHoAINEB;O`)mQS-LOlYjAj+S15XMFQh-w4U5#dvzA;eCf=SyTjW!I;j-PL zU&BSU+)_6q3=H_mY`)4{YrZ6d&+#UBTPn#82h1sm2quNE=*;?xc?^LTxEEB5-vNUI zJ61-taD2-T>@+0~kIBYztg}DCip&sb1;o0y2Yn%KwyI17bu$W#$p!B}r)n+i1&;`N zI_%EvBQ_QyT*v!dkM$3&DF|D=C)0o_FeAQ~gtrtq)d$gHenyLj$OMgq(H>;B13WAk`=eiMUk#Lw&|J)2`8zMyV*2%wo&Q3+=T`zbVEl73Xl7QOToNQL0R zDY<+_;f%jxTwLa+OLprvJu<}}_~7(!L|jFE^O>0kfIL*`3TGPrgmhyc-m}SbZ96|a z9MwcMoH_W$=XPA`kk8iZ=!TH0^}PH68;OAU;fU|c1^qG45$A^9ynISZi z4<5>H8Z)bR_K({F{zpsEW&S{Ky`YIJg(T$qk3Ws0FsX+b0c%iDZOHXoH3c{iN(QaY$~BUc<*!v-w{8kez| zS=z@JU#xT7ho-vX!VRuklRg?)AIs}6x$aV@>C7vp)LC>ZWvh_q2I{H1769cg`%j?a z+>{)D&qZLu3s1dJJ@ z4p@=TrFi(fsGMrJ60ax)hBi-aD0C0o?S^@5Fs(CtWWUIT%>{^z-?kUkm;UT8`pNHj zxlysITi}h6HXT-;eOB$=t5Cob>2)`XmFqg|;`nNCH zyiS*qI>0cf>!vUusszU8UWi%N3C?OB?-u94txS;f=KC}XJX>C3qnlRIK+=| zT!D;S^bwe;RrJ=bgQscFRpjdA7$jkZTSp3#5m{RZR!c?}{1V~0opmlQS<%P$1sXKH zBe8xFuI4POcEryuuTqbUW>4~qi&>Hf(*%RXYgjzI#)q1mNxhPCm*-+>Km)y^2igAT zQJH1%NPxKD1+cdOD9G(WvAU?AAB|5PZh1l9f10)0@lc;bMa4#OrOE#h zNRY)I5o&3L6NlgOr2LWl5CDqw2@jYd<^uq?DnuubI&w-eH5yP!9vYSn$H&w zF!~D=%8a~-mK(&VpNPNk+0Hbg)hbaiZ||&k!#Z7nnuaF(8xfp7&FgXt^AEUPx;qqv zO7jV_O*}QACcv00*Ak)~6ovcRYmRI(J4YW6@{j|jK7&Dun4)yJw6s#*zkCi1J_m+# z@FB_?ULi!Tk2C!v(}b@k<~Lu>vQ_RU2Li_p{D~aHED!fb6`XKSOnR`>|w|n~S+NRwYd!J|3 zo$le^e#x_zdLgJZ)KyMV_LHFkAx*I(*3c{G>9KzmP*}~y$j6KG&gkWgW#4FuxtUVj zRBx&H3Cle-S+UjNa#{^y+f84wZlE1Vcf04TcZ`itk-81S=-oU=$Dx?`iwzj>x+SBR zI=t~Fx&3Wdmi#*di1qw#PTs*^ZX<4z2e%0c3Be;G=BuHAvzChu53D02^Fm15khR>- z4q$9K8HsoO1D}A~VQXtA$b;!Ccv)j(iu3V2bzIt{Cn#zMr$F`QVevSWHV!}#xV|{L z1yFSExJL^KC6Z5sQ2{#q$uE9#G^pd1U$+v45X(!i5yAj^HG{GdeB}?S^FN-%KQsYi z1Z8Zy)jIRhCp2$tV~6QP4|Y};9}OrITQedjHq7*$Hgh6c9ihfCerh{n5Dln3SlGE{ zC%l@qD-xo~v{n7Rg_ld|hatY%f_4`4R?OMs zM*67yP7a$d^7Hn;Y}0@r6$~U@$m+LI*65?K)krE3};2>-X>gpn97G1k4J}a9F<( zS}rtaF4kv?c_-OXSMfLM@S(4rj%}cQ$R0hvx;j5Yn4o02oW6LWqB1_v2Ya47gwS#1 zc!laF{B@wwr}+hgEfayPx|ZVU_NrXD4Z+t&k_HmP$^XOLdq*|Ze%+!`1XNI7rGtQ| zNbkL?2qL|MROub0cY>fGARr)JIsxg@h0u#gZ=sif^xi@VCEN$~{hd3`7~dH8JLmi3 zZvSxvNV4~S_IlQuYpyv>1C^{g(i1rpe=dgJn)Y1c5_RICsFzZYNhKYCHx`Gg>V%NSvPj>H$GnW;cHPhg<-kp;euW@aT(^NROH1G5WA&vQ zrTY=5ah{MTL0k*1iSx0P=5}_NnHMz0+V!=yb4@b`ut#+E6Fa9*OAJiYg-=aFC!y2TV$`8j=J(w+AlIaiHVsaNqcuEt1!H-&F|un?&lFM>tXUA zm)X4&JwHT*WqPuR0)P5)mxcn%$$Bbz4&FEyi?jD^f$v(MOS0=!J1&-*!szIxYuvVn zbGCD6y!(&ueCVlSV}jrsD9I=YG+r3|788s zmoIzki7x9bafS;2d3n;d3j>0sKQDP~x(q|<+)8LweaPLE|GjGKU> zjh|yNG>a&ZSH?rq%>67-ur}}6du_TR`wtX18-eAWyf_??5o1`Ks0H4xT-j)ee>5XVrl5{QWDS}u2_rFTrmW~aND)JkI#oX#6aqM2|#zyJVK%I2$qdJFHHVn0p#FVQQmM@%5`mvh7vHg zgs6VPfNjWh!y3WF^pDfKKyPkM!cji=X85!x9O;kzL?93yZGs}@Dc0Zk_nz^_J#yyc=EWnx8+yNg`fJzY%9Xch z5047A!fQKkDl(paTk1{-M&T9yiu#=Q2jRY7U0=zHf%9gyg!QH06Erq3#z5ihDHUuqS89kgJt zYq4Em3T3H2>|XW~>h1k8{u~0?`LM9O%<5(4@Z>cZ46(9&SteDC>(`DKXIXOWjc<6X z5CJ`&Qw)jdk%oKqM6G;C+IWBMK>0>|b!y_H?$K~6)>$O~`Q4O5^{tdpy10%a1B|`Z zEO0)tl6)8R@ce4I^UqI^3-Sax0QJztb@5W%stSDmfRlscHMPAp6l2$nNWi?^v=E}> zRJic3lk4$QR~v`1*0`;F!WkA)siC!kX}fhhO%yJi3s;hmE&v`oIC_A$#SRSv3kwS? zD-5E0#7G@s4!*mY*$-uyKt==SRE&GrWIRc6cICcmC z@l!+y7y`-H{v%@AwXtDS!_Uuee)%)Vtf56y!0RjJEMOm%munF6@bHk7l>Gfk5D|Q^ z{Y6NFXkjPx__5j1*0i&MTO}dAf^M~=BsAAkk@!+00pho0wRuH=8UFeRGt`ZBBUJiB zxV&MSl%(lFcwJ5>a~|1O#ivi->ayho4-Tq?)W@+KxW0L_b8t{7oC6##oSjjDw|9t~C88F-1$aS1mI zKV6O6GVe>7u69}mR-K0>#_cC)^uU)5+97a}Y~25x74%o|t;Tz|+go}##W<6F;-eDP z_4Ucgle@(V@?uSwiH_X`xqFC+qtB+-HaE&;V6ieZN3xX|LOmgL6&}hYF*hS)Z4`Ml zKB?n#4A?vMTgda;!h+f;%V{9*3_QeCqkY4|!WfZ!V&Xj#oMQt6yJIDj!uNrg=pVu2 zWAl^+iL^RVD#9o`+}WL<&)qan7e05dS=rLDveAaM&wJJgmw*9{j$Zxm9>>RYOgn2N z>iN8K@`n4J2V~W{CC1yi1shi>t+|CM@vPX{7{#HC8$W})JkSRb zn$JV0u7?W0Irg{n`MEo!8Lh(gi*V?xs0dTZ$O8m%-OWO>Q;x@5Bk9I*W0sxK<(2J< zdL2FRbH~y|btX1OV;Yr#0~8vtjIPXhU5Lq2KGTUz*7>@wo2xc(1~VnrEP6pB;GI%v zNssKz;;nY(jq*QFu7cHQXZygLAKd+#tRFs;5vuvX!a+|Hv59=au3}uVaR_@wEVKB5 zb~;Cq!o<*y%U8|5`dLC`LOU>_g0!{Z!xsUK!?T`0uVD-x2U>Sx{Q#-z9eD-UM(8Mj?F7gCc9Y)mb!?5#Zx zpM8~qt&QeOXecx-S}SYXwK5Rp_3yk3cp*{aa^uqnNg{dVsdKVSZc#zOO#SIXVfjW; z@jj)j5SUDc5w316!uGjsvg%_ob#->ZEqN*t6NzXxzzV%-ePA!0; ze$F|%Z3w>7S0 zH01WCH^aE^VUJn8Uuv`tYB}7vl8@xJtH$T(2DGL1n`OT2$5K4|t4{oOf%K@gnb^UD z?7|mp`eEJd42S!!oc!Eo!f{-28TYLY4&I#T26h|Dy5dfA*;r>g+Npu zWF&Tsk?DXuv4#y;Bnr?=#?zyP8fQ`zbZ^s((e|`=333Yh?ADf4d$iqAen~Zne)JgT zZN6k~R7PehH^MM>uj2B~din35Nl}n^$Xf*B#^dZ^@H#sZFA=r{o z>S(yGbJ2Q0Y=*6m?aPxP`d-~zVbUh%L*vJzcVsG2YdoUgq04S!CxuS9;!H1wBqSs{ zx|q#WSQ9l|+S%oGO8>W5T+5cIRs5$y<a{TLFrYxIL|0u~8ztJW9mG@vBKK!siHCQ;`pYm1J>DV_-IzIW zwHFPTR1p>AWrfcjJ%PI%WE&WnS+y!CjZ{?d<)CDT)FiSu3WXE3+XrS62if6y&IW2K zQjs4+K!a_Hx_xZ0`8m|r^DuP#!iI81(7^_H$7Du45N>*`BX!!;Px3Cz`dhjtf4ZuY zQe$s7Le1}g1?8I@yL-+ua-9sUh1abzS))%p;3On5`UGK_PuABLRkj}_CK4q^CLE1d zB_&hYZrHF=K!Ypa>a&aSpChW1z{8#F*!!^~K&TS#WlQP0@=%p9x2WyO12(o?i!^Re z%iie}tlDedn4Xk9H17m)<1yPZCCmeylDnRUx%dRZ;2vA}EnQ`coQC>A%(0 zX}9eR9>28a;^bs1Rl<1et5-ycZodD!2XiYPyZHHtB$qnj8U3=axP=&TXXI4D5%WFC z%M7T{n$pS`b6cqmg12a=gq*))}2#umaB!lS^TI6RE~=ez^n zO{RsLFqvE--!%$Vr-6srW;RT)XRp-hh9u#2xP9uHU8x3Kl8^3wf?Sa~0Pm!zyB`w;pw;LHfWG=IJHG zTR(fO>;qO!SH@!MAdm1bq8J@{`7B?tqI+!S9eAniDg2ezIeZ8)r_O)!PE_y~MMM1Y zmOmB^53d;3t>hL%CuD}T^N88?1kzUno+)p|NVkH(x^{J-RFZJiUhH@RF0PtNX(a+z`WWN7^7HfCj5zGARck$~U? zM-SkAYt|o5m*KUq|0pmcj{B@yGTx2QYADImj*Us)UU~hHNM!Jw!UHX-W}IWw_oU_7 zvu9SsZ~MLos;aAB{Isg`JjiqD-v1f-@POt=J3VTZ|D6Y!Tu&ivFxHdx(wq?3lmqJh z4`4I+JhyO_RL-NBJpe_Z{Mp5!v~beS*7QgCyMn$a4~pE(sBXe~2vRz2C$e@1RS0n_5b)`26CJBa4O^CcUi_ZHXF;!K%6_9 zk}cGEeIyJ$`g2izagppRSPa@6723d;j_20Zo%!`?#gUM<3?OJ}mbRD~Vt0W#&%OR7 zHI@DA(^qaATAPw(T-@B_v}siL?m25d145QT;dt+#y?D_Mb-Bi}X}M0JYXUS*v^nsF zuR`R5AZN!XTV*l$B2V$H0InJn7YAr3pfFO#F|*36uUN`*m!mUM9eTkT^*c&FS--4v zzP_|p)6h`4n@z?N-C;4x8`P5S$xiIc_^6B&_fLi$1g2VM8=6So{fVvaYJl=|Sx`XI z)Bzpj_y7Kw3jY4DHZ~Yy`+Zmt|95|pVt#dz*ZG(uaje4FT*}iE&=dpUZka*WzUT_T z=(*w-iv&rY6p*R;4gIAu#{mV#Q@|34C{Nb_c^~LSPgNK@**|0!R%ROcoUFVD z8ZtMByc;m}V&eSU!=wf#klCl8G`oBkp2uJHbZQi3WLQ`k=Nzkn;ysE(L}a5LuUGGz zUuRnd#f4S_21x%KLS`HOno=qy1!T^DzcD{ceIawSiQH}-m{(Sg+z03s>PMIrP%#NN&m7RLb4;+z{ysJ~I^)^Z+cb9&r=K0ftnaP-;O_a}P#=$|=f+t5 z`Kiw2n2D*Gkf5+=#p|Zi?OLBh#HzL^YGe9z^9UIc8un6Fo=VJWq4qtVCaXbYO3IHz zCBR>@GmK1(a@^RtBbIuxx}55DiUufR_C@2{eG(S;`54V-_A@3BZMr|J#&1I7;%t6P zPuTKT0!$XbmAT<3K-sXrg&*mo`RFbv`p>H^xkW#Bn4mq-VJP6g)=bUV)7jfQgBcwe zjEb^vINM7m5KDVdm;|Tvn5$l_xmzU1mda(P>)=q5nK`tK^saO7D66>2(KbJd^koKW z3sw5iWaIo^d`7(vq$x2i^SXT>Oa#EIv=s74L+l)rskv>?L;3=J!Ie4 z*J1p>7L3<18qIH7`FSC0FzW8oTP^KZVr4etMO4Dw-97s5tBW~iy#-&tzD~V+^L@>D zZJm!Ale)H59;VcRlaohr(sk3>_!Xe&kph%zX#wGrjzT%-rmJ1}*~-f1?}a$xq^bV` z1I^$_GjCoQ@PWJ0h}w|7SM+q>Y4r8{?mY0|aNGcjNuVCIohcYU+LU1-Iog=kxma3V zmE0_@#{?GCcy1;dKu@-CC32m|_nDnT@u`JunubCPY;Ayzp{O_Ft15l?XlFjK=rGF2 zh%3~!v@v={0C(A*$yY?ufPR@?&Q&?SA_F9yfIAwGT|h|aCH|v)eUHT!MR7xjHnZBF zV7xX^?PPv(DSxHX zaJr_gPt0rW_=Zg7N7sJ}KFnoG^?nf!8VGMAKAqMy7~MSFOlC|Lil5-*TrTPx!*{L@$zhMxm=DS_2J$gc&Jm4kKc)% z2XYVq#}_7|mmqZek>ux<>ayh3Krzz_TS~*E-68J4aYC& z>Yo4)cH;kw&F_DHJN^IdJNkL;9M`^bTDo?vLKM2x4Ib9DUXItZjkGO6^mLCgCB8nA zlGs=f)@y$@sz%Wiy~C;Fm(dm(s>@*R-)ehn`lQcwedfI9XeKk5t*Pno@X)>vBXs#E zaes0XQ7R#qL3y`T5}@pfi%(HLyDHft^$`#g^lDs&L?`v_duy&<#bSJ>%jBD=lmSZ zIr$pE!MvH8z6MB2W&frbZ$_={>xO~#71dRvjsA~d36CT zUNk|ga+novf)-* z=X)^_-DG89!MnMa@OXW?Y8x;XfYkr`Ce+rHrv7N)g>icXvd$?dtZ*|gPG1Z-ywKBl zODh6rnS#l3%i|U0Q@3po*R~LA&7#_qU!jPtm6dWpT#yL7HF>z4iVxu-mwNj*y zI%}KMi=(y4pzPDbZT48{5zxBxVS4z`xMjk{ln%xt>boEfb!&9h z)g@`GGcqz>u^Sp1Mn*;!L2IN{P`q=y zS}7`=!h^i-RW;0+y?j4ViTN}5XrV@SuX)qFXTF&d17&Sxth{Y zS-2L9(f%6;2ZzEOXML}A*5J2IH0z@Vb>4ypBZWRe1YqUJ{kDe(+hXP1L#aK=>ls21 z+dmpoOB+4&2Q$1{4-9D>t999FQnOm0Cx%Ca5#M}|vAycd4Lw7fM`tU*o+wn=LhZ(@ zZ7E>}+VA?7Il(*r8@QK#EqNk6rgW#bgd?O`*2dvgzOv7?#td{SKT`7_qDeJ;&YDgU zT+o3bm0ah82=PG_Sn-{WeGT8&+U*;9y1J5pYhzqd6Vz@>4ah($F`V2YbDv*xF;<-q zj?R&OgyUWBcg1h%Z|(B6XKd`aeqYI|Zd)W)V}!139TW4}0CFLw8SYNycaWnV|^x&OEVy1SawCw4TY*igvG z%zWxJ^cW;fxcL6q{c`t&aow^_g?#qD#l_Ks1g-Vo>huwgO1gA2SQh14>M@Isul8{a z?LvT4OcuzS!ukj)sp>2+-b+t|pFaR4N0<~L6_xtYtB1zM=H_ufKkR6S9-pAwp1iQ#kx&fdgOn)7x>91MjZ+H32MgBjx7p7z5&LR*zKye6CF1ET#{s&OuQN_F6 z=&*akb`l4aG=1RC{wYr_9^boq>-QIc6kh$ae}GGS^KYPo^fUdU>&n^Y_*Rfmrx!kopaAIYLiti{VMrt;%&>OiX?H*H8D_ zZDp6jDl55+sijp1i*tkL797sUR&{d6UnuGuC(cW0omGmOBbX|JgVO^FCC%&%m6bj8 zeLD{N-$9_Gmh-20?=PK6FAs|0wW~Yn^P07FUr4$sdc`&-n(Plwe8dYUtDKv(@SKd7 zkhGc%gwfn%t7raEODvsVgxo+16^DDYJ=Wlc%NC@nuUv8QM%w0CjY@i6Y{$b46<>yl z{f)DdXSCsFxW7NcLM~rce!Tj9z^TiiC<`NNs<6*Wpm@aDcMZyiJv|@e&8%n^Ug@g% zBFCdtN}Y!`{YB!lgJtC|PJhCJjZd6cSGp>*%$M5eP2-bf%oV8|=NuP;fhB6$&B`qy zL8s39I2d76^vS^y)(NZXkAqFb%!@*eAxvoh&(@F-s~28f11&Ye6FLKBw<>-YMU4$P z!`QDSupC}mVhf*lZJ*8)a=VMzrBd61(}ZeQ9gU-n<>a$^RwEL`93cC}f^QZc7&N-M zmXA~%dkQvGTWQF7CkB@owpZ#850~P_Wj=ctePXg8B&fw-GFkA&e?F(EoeoAxN$zU5 zEs+G>G@F8150qu&b45|y6L8EeFEE;ThoJ}y?3@=%={@(Xur}(`aZ4R!cK~QC3u`fc zsPsKffw8uZKyA!~3$P=Rqn?cOC?z7bcJtTLjwgFbXA`QHCt7m0l53sQ7o@F|P;&~O z!B@=^R{uEoaZTRU%OL^0o$B+pGtSgO2*4ougg3r81S^PCM&T zOgdsz77)AgHyekC_lDPx@;|SoL5`xL_RfRYRr)JR_%eHP_DWN$x?t-A!|Hnu54~bP zt37&bj(q~>mXA${G0@exGc^$5Bh3gp1EHQXmCLA3b9x8CFCV+T+2UG6971F|SA54B z*`C^NdB9i9U%Z8``j^}dmbS8Zv-E7N=i9s4Ud*kEKkfRRVoR4R?oU#0m)uwtFGTX& zpD3k&fU@$~{fZYeN`Jk+H6(%KEF;~mRcE)&z27clvA>9;Wtr42!>M^vj;BbCII^B|V=Z14?oPp!oMh~WLl3S7C@Y~fqUm9_z>A|U1 zWc&NWZ(oPkLQ0u(Bd@@8Jx4m5-)%YV%TidG|9(_vk%+ac9C2NG0$#1fl!wLB-e29` z^n=y@6(eYAW5CpYUa^k&N!!HfkPGg87}->ehZ6cdtcJFD&7-m-;B`!lW+`D@krcdo ztkTx$sCzC{GZS?Q6ofEkTRQHCuFlMiMEMAKt-W?_{?W%Z!nT zNi4$L-nxkg*A^4eu$g{CLSpwzu7m)7T(+0HFUW8oG^f~Tsfy444yHgud2O-?$=R>-E+&);H3Oy8Q4V05&m!=yKeZ(%ol>Y;2wxnPF%a@ z>EZQiT#J;LB>NFPFz}ZDoh@Ij7_?qq;o7MVoom5$NpDWphNXykq&;awmT!K4t2I@U z>_!OB@Z{Rx=HQ2Wy~_4)9^|FO_!d6O4URQR&JO0*D4!k6JLBEc66hef*- zfKx@awYpjX6kVO2m3HHM?I2?6iQMPtM-&|LgG8;h`y+m~rM}O6Pk6Gh{5|eKv|`vj zQO{xKVi8^^GhpU})gM(c6WXqVyLcJ;G6 zsm`bNLSC-S`C-NRK}mW>jwJh!PE5~zPZn6>Mg9AhV`44)aI*=>HWfMf_!!vpGg7IB zTgJ=0fj|xA@5gP{fy-y48;u9%!L8TZ@MuX$mPEv$BU9B1y}eRWQUH0KXlpwFUd5KR zLDhI9&TCWE8uR=HzSY*Ju1n&32!Cv~Rxyt=0e}Qn%s}McJAAa>FeI`Pd@Fdt1o>S$;C!n7im6;?Wq-yB(&MXlWBekB=ghadD0Yv>YxVw6>6U~ z4Zh!#+#Q@AwrgIQSxxYGp`hQ>!(3U$X-|Vd3|Z$+nlFVg| zs^i*8c#>DCBdm=JrxjOUn@6T3^ig+Dv0%S9{fsld8Y$s_kebMo-Di%pGfjDpb645I zWblMFNdDuk;^~jZ<)j}*Fwgyk(KLb%F7pO2s5T0p6+ItFLel<(N4kO~DhYAfg+^TJ1iI>9zGa(c{wOR!?*xjJV+n@0ehVmIXh zQ);`l5#({5KUTDAX>;aQf;f9a>yOTlBN`7#^lK>eH1q|{Cq9`aYgyZxRvzaLxJ-(B z`=r=;di0Pf^i=#x2zH@t$A|meq6d9$#hF(>MS>qkthbZy*ay=0i(v;p7f5r%CD8P; zqUnc=`h4mQeAoUsYjRTi%|qAff3X0$dqb1`Po|`WFC)U^Y`Ijn=q}*P=(QVe8k$Nj z;%wn=F*dPg5SAqc+6F_}ec$aNcJC6ZJt9&>3vF#4JZxrvuZyUF*HmM>ZL<;GWY@#b zt+*c7Ws6Lgcs*H3M@P5Z_obt!2lnHV&b&Tw-Y2hfLO`MQO1&C-L|r-X@APiWNP@PI z+vWuC_x!idf6XsfIisC)`0Usie%hM=-jF;fJ?#xG$qA`?AR>&~UJx@YrVKpu2;Kem zD^0sSi&G{WvG2+`>J)FZ;B{h{IapzYO zo8SuV2ypQ5Y|P~6Jh@LM=!pVz0;ZGz+Hl<*1LbP<89L^(Z*JQ_2je%}e<<0L@^vDp zw|!8`x;8cn3WpzGaVy>v-ktN>R`I&Hf@R>be~T?BIwe*Lw)86l9#@mabNXMj4S*4I zl7OkzQA!;E{{S$-v&`KUE1$?kC&E*nmuGdT>ZEv2R3UkCYz)|PWfa$wkdW}ul5AU; z@B;J3hT|nlnTB$Td3t#!7KYc5l&>`ump#vUK4pS7g&aH)r&OPY%{dTEo9yi@-X$UxIrF@$6y{k>p5e1yWzX zi`cRzdt1KxxK;WkO{#xEH_F7q5nsJAagtmcU)Dh9{o}S>);EM86@A+gm;HP&6 zJdl+i5^E#TvjIc&iu(A~-xjJ(1;@wPMUL*NyA>7|HXI-7bk=Q@Dzd*Ce9ZC2q32~S z;ux5-g~p1(O`0}a)(Nscbg2hepu#45 z&rjLE2p^$yQU_IU75)HIfPiglV`H>;HfYLkue-eKv0Z!dHoA-(tACFcg8ZSeBX#=* z8xxz`x3XEs>hAfhuwv$EP}BH64VEk7;O6Dx<9ZDi17z0VwS~z~g-aJbrU#AytW%M& z4C+n3mwfL4WFiWyKH(c>l_lueXhmlNMqA&VUidig^oP*( z$vXHJG2z``t1}&K3CaDV{Z>kl>^#Sx)%#!->cQ0XZr)fO&LQ}7QW91Sk$h&(*Y52_}d@d0)Wm_M#Mlza5hY3bwkhzSTQyLTe)`mTJu^(bX}$iub!zSOa|t8 zdN6uGiMNmUUBp&vad#aZqCkgSTU*0h?n@T%lxq+br!OA)@?VTp$T?|9oT{19#T_XI z1z@}5|J%EAU1>z(k+2)(fa!weUV~53$yCI5o;Hmkx(m#=XRf}TJsU5R^bAnroi1E3 zp^nhhRU^7X-CL=prA0!tARG3PLCx#*SgFrO*ued0-P6;v2BG8$`d@mXFqpcBq(@;d zR=~s_wm-Z01q+|Ha7bQgsI`-3KyL0cFL%$CrG@=SirE{x?#Zjb$?P(ajFoyAd)pNF z; zT6c{vZA}GCzLf-b%%)46qE_>DtmaP+^!x+j8g~3MGoxFdlmaHZ*cmFiCV6?;+PWD) zmYL&HA1MybLi1N>8_eX`RRHgw^3tK+5S~7qHh#E0K2)N*3^Z~Q5|XN{k%miO#_<~o z|LyAX#i$%?CD{*mHX5d<51IGq!VLqG>!yW^8xoSDTSWe;&^{l;V}JhceZ%31PLN{C z*}{GwG6s0 z@r;*?Fo7V{ce&Em=ky?RJi9Nk!m00W6A^UE=5H}VFf>$uyAiv zk14ur>zy7IYaKMF=)uYT=&$~vXUurd$GjwL2Ut&EyeW@b0Yu|4W78ckW5aia@o^79>^4pIoUAkc!tNlDUJPiJDJ*T$NdcmDu+Wn-8w5V6VzwBPIg>@LcCq??|@!hwvz8(U-sLzxoz6xHw=n(_=xaL+(IC65#83;u1oVk64lu>T;2GJl_b2;}o$?C^Bo=L{wyBJpcXk9otuV)gSif3xx_&cN$n7IV&CtfNU^6kVm5%4@y2 zHY^cHpab`m@3W3?a0jCqY2M`^dM@nF8`vUeMnkuza-V9Z9&2TJBQ(1 zcdvbXFkyUq4##AJNs;cRC!MEf_r!9%^=JZt7^oEwS$_1ypj}C$Bb&etX_WZc+`I?V z&iNN!#yGIvBQh=2(F#axvGrRN3|(mHa0xU%m7RS z=uV1XL@bxS0C$Bjcs)Asu`(GbHoqIJ#)$}d?7iP#1};CI;PzcOm}P>1ExaY-xl* z#P>IKe^Oc+741$JkB5d^)q+VLQ4YSStr)+)%P-Wj+JOd=IUj}2PSdN9P<94};}rua z)mRl0j0{xp&nPLYPoM0zU*5m0j_7R)zgIy0^u~={l(PCUn-Mc5sN5};~SFA@#~!F5`nEVwGIb+#M+%By=?BYPi_ZQJE9$}97DWqDb^^iJjDW9 zYo%pny~$z^Bc-nfSkMF@o9mb#;IT_^>aTnUOJ)eQ|A#gzVmP&41V=B~8suIUznl5PX?A_;!5j zqgB5g+%h{>ZsVHr+q%jfXM{l)QNCNZPA{<4v%s32r3E9k&w_4y&5bYDde`FKx_ypn zA%Kd%@dE~SqDF?6tFtSgW20nBHghFI%gZaQ^YXnKmI6)>!>IXP$G%2FsZLrZI;|!? zCK%EyC_`d#r1`6`L*qUZ5i9e+1ee*hdE78(Klhh;d3i}$^$yXMwz!8|Fjp_HAc9I? zZ5`F<_<|j?;?4HtX!SF~HmS58B-X!N)0j;B#FBP(1IxUHj`!bzv@*~q+ap`l8 z#819fR2=*`h;PpptADc^wX9j3EtZ8ShPXKE7i2r38Aj z?hkbi0enLCO=mmNh8uZ+Jt8(yqmFF6&(81GS#C+ZxEQOU(H>5n36WCn8PsOM3M*_DW%4pEzFU)G^}o2@n5JtqpLO&I z7k8kcR!->?3dhoe*S&{Z?hQxT>aE9;u5V+yOf^`{y?wG;21eucklga}ZUt6L_)8@( zyO$>uK3tcQ2uR>g+C7XKd8ewH;B#s(ek5x;W3KUXR8_P6o^bCyK_*+1OndIjo=l@e z@B_ApcG(XRUY7HL{%78N=cQlo=jLThJXY0GnqA(8J{GAxvR06lwY9ge;;32?c_|?= zKc1thJP;Ep>MM8Uc)BMkf+hyD_RVZ_g03qr`Ifl6%XFOnq?W3Bh0$tM;0aDhsDaPX zroUt;qZT!0J_0Lpn+@FUdRZ)|r5>G9Zx2)T#Vm*U6^5@xz$oKN zUXyR73u@A(`(G>|>1}$`k5L10mtV(T_6-tFDv^f=RWm|fC(f;Cr?O?Smo6Lh=G|%j zW%cz51-<1#u@gTWh{VW%x^IeOfoIDJxqQWg9y3br! zFUztz{Vh4)=LFueoCHQD0*aqsMB2{I@Qm$x3!usqdL&)b$pfS2Y7-M1qRr6n7UI?xRna$LovJbA)n>gb>WWhsX!Zvo!Aj@ ze8%d(Td23SwUNXQB9#Lp?!k(R>erL(#|0TDHPzIPk530Vg66(jTYU5|5sB-9-4nB0 zkd|o}OjxO64kYKc`;xs;l}d+4!eYR(DMiOhXFAsS*5<_2q$AZ=TU)m3*V>jVBU8vA z`@>Bdcud~ZUh+LbD+Ic1nc=+j1t~Sj($bOww#UsD#4qvt{hbquIF&wo9Y6b(vJAfC zeuV_6kO@ZJz<+(M)Gi?mKjF;#F>|Pc^NQsinVjMV+B$h{^%(RSR8IO^`SmF(Zf;8j z%>FlL#*mR-g~3D)X10__y=SJ`a%S1$v~{?`^vfD3+v=RI& z*}^8UFs>|fI}XKknUkeUn;2Vry+(>B>f_+<&i1TsJTICd{X@){TJ&rIo87y^mK1B4o+ZSv6tb} z#<(V%o){s?kC}wCMQ6w2a3@P+k0t-!*1sEyTyaHGbe^ zdRm;BFWa`?D=fvRiEQ+f=|m~p@bmI_b^e^HHpaDGgs1w}datUWVwl?_s9V-EXQ}Ul z>z1}xplft@|DYGHX<&(SNISP=_PD{%!NCkM3{OVy7<6Y~cem4U5fcx>3*0W6%PVD6 zIUm;bds$OgF0n-E?bybgM+E4kflEcLK;##C6YoU?ObacbJ`Ond_E2KEnTjdvK^8X7Gy0#LQ%+bB0OoPNE0L zTHArzy_s^u03&0`SAq}MdruOGyIc-XIM=Y>-=?fN2xXsvE++$fA=PBp{p`_IM?dJ3 zM!(s#14_PYIJmasXC!)5?sKpMQ2rC?0`USHFlas=PPK89a&3KFWa>&`9F zLp5a6v<(Jqz_uGM(CswSqN4u(&&+yLxGncSV>_j)ecXlA0XDYGTu)DxP*V;6236NH z!z%7~?>W_DxS^_+l+vF;xE;K6A z3k~@w;)`PNaory0cK`X$%|_L1dEo1Nxtm&FLEX9jzKNEWry(b5K zn2@TE0WAoxph>r1xjX8_j~A8a^mfmb#;X-{Nx9ynMKoN1{Eq$bhx`c<2}$kFURQb4 z=PVPPH1-{Q4hHt%joT&z$iCUHQyzjJie&fP2d~upurg(2U5JfytD}~XFq?_~C>@4g zP&Gr#EbW>87>wH8H}!mTCQ!D^n_Zs*mXKf2P|CEpY$Dyd0k~!p6_f^^!D9Pny%T`! z>SQKo&~S(@r12?#vsGI=H3ctv!9b*!aV8mBkF1;g*}npI@z*r zg>?XZ-2;z=hldBXcdJqA#}9kk@xh4+8Owy5Nkr+y1~!w)XJ=>Tnxf|@?))mBlH{(% z*qGSbi!I?8VqzZ4c2WbSLod5&Jb;0H4uYO8lAAD2D|gnb$*+)@f9sspZ!}E+hyTuHxGRBn&^mDt*Ln3eqg9aQ<8*quX~+;J(jvWCHpA z0Pdh~miV#KgqYQ%G7H+W3qKUyOR%!G>)3r>A5#;NU=pKB-&WH+x1#WV`vE*HD^(P0 z>yP9K)=C}_6UT7Q{|NIk@z1FueKGQu(6o9er`h!a6>zq+fE&_T>v?jLY>$FbIqknu z{1CeRhJa7q@4)%sE+Jt&G%&B2=wSYrk+6t~Z0=;+Fs~?UVP2k0Npeo}{>}~yGb?PR zXbcn>#+J6kUh`(l@=9T&t%ygQw1&-DFPSw8Da5VRGMncOLA9(7jAUV^RIwSZY;n=C zQJ$krLJYxx{yB4DnAXsGy|G_>$_Y}>%rr(pE@Lpsyj>_0jtLwalSU$Sv?ub_SJ$}% zapXJ$%aH}RH!oScPqyCTycU#aWi=-?@6@C6rvgO| z&Z)0TzFb*>*WcTw6Q2eCb`8W{_W)8+jy*ebFD&6_+En z+uFO`(AU+&%gdc#RD`ja%)xJL_?Ih;5e2(czGe~Xa94TsQ@y3&){;D8c+LQzV3*(o z68c{zGF*Bl;J>dBXTFp~+9>zW^#uf|g>fa4;i-x-Gxo`U#B# zxgRF2_KXM%n*UYc-xp~i5*MFRFC;uxZ{UzU=3mawTa*3sT~K%I-Uj+UWHWqUjTZ4a zSI%Ra_^4uK#l*yRo>{)Y{uen3H@32ChxnD=&9&UYegIxfLt1?;j^^RzfTVq%n4FxP znD7(?FyGBTU=f4?_y6*|exJ;L02l~_;Xmx_|HBLQ-+IFu4zno%dh?vEY(D;~Gt~K! zQAzw2Kg_=qN_gz`XQd%|Rs`ID=e2QuFiLH(S`XyrR0pK-;_vnL!!0V!_~ooM zHicI7aCiSn`ToJ3m=Aioy0Wse84n)H%E)+fzf{oE^O^C-1|k!B%Rq{es)(59$ppOxN8`y8L(UTO%uK$@`+S0rKlMi;R__~hn|kA0^*Bg&M*EL3Ag#Z7_`iV;WPTXPA13`Q^7U)bL$Znb9RD+1Md;jO zawIl3mXdQ6_x)}CLsTb-n_|#I-M;7r2HFNd240Gd?Groa3A;_sh3K8}or|IaCMhJb zBqAxD09OmVMWd{vlb@G&vY@Isu#!XL``l?_V!ZBRw_SudW~EE>$4AO4@8P_-(ZbNs zFxboJ$BIS8#iK)`eI1=bE@$t4Eoq3t%{md5mmG7L!7Z1tBQf;YNN;QudZolzv&eI) zXH>rq0A*L)SN?WYL>ML}Ch|uLvaf);)i3SJ4F?BDLnZc=Aqt(I0H7l{y zhRx7T#bSLYTCUOxSVhpwjZ(z-#Q=f=Tiem1SINmSZG1Q1H7}>=$Hd0UV9)f`pY4H~ z&tCUJrPagI2DmQ_Oyw;3)Sb(gXw_PbtK1IacKd-(p`omd-W@eC?@pmQ+Uweou^2?W zj2=tmP>&dw%EK2I+YFAF@rno;;}PhS(5bObuC2Y$ zAH3uE#OGb2e@^5A1o9qg)>gsHT?;1ZW0jLq>;s#B_Ki?8l(ng`rzd2w`4^66LOULVCoDfQ< zQQ8Ki$J`~XtV?#q3d8;F{vL_&4vCQht<)Q|=Cm4aZjyyKI7?HOxRZ4nPBF1sW_k;joZVk6Af0(iqtN;J1sx-8So!(s*$zn}2_#bv zw!50DbdnzJv20&a!-KxdB>cxdu)V&~+$ktrHvIWug(x8be~tH3`dCCq?ZrezY%IZr zg~7O6w7q$JlZVrR2KwPVdO>XqWRBSbfmmlZXbGx+^tfnyjGY`6Q)QDV_oL$&F6QQh z*J1yX@kkyk$cCACsS2G&Ak+31X53a1kf?h|bVBWuGYm6tKcYY039aymNIDm@%U=eq zIE0`G#|ZTI_2orQjmF0$J`WY=tEpKGtEowM;-YG%L@<3J?&@v_jeT2W`HZ8rt$N+Q zX-3E-KR-b7Jj(5iC%fL}Je*;FeN|{aQVkpmbfGmz%W)QTFu*b|$no`!_C9jCJO@hr zrzDOvToj*ftYZGDVXm*&j!%co_YV$cJP**DA9jZFKbT-mmgfS)!%>OzSn=0aAAiV- zk37Q-z$E5kazNUvs&W#rmlsC!dR;bpoPkW5>kCyKXkV?$sB^lM2{VCzeSa&aF6;Yu z>*L+00=8+unR&oiGrK~X^kakPRtaHB6CER?oAE(hczIO*+gJ3Ufo5Yep60uCOW4EX z78Ol*hTd&Ul5*+@yYe?P~dFnzfdr2};T9*6Bq2|8OQ#^c643DxgTZDYgZqfhoC zkHHb`{hywwm|K0lLoDm8#k5TZwv$lW!tE` zrt)>NHNbGZ&OHmku@NQZ;_SM<>iC@llss_@WOD2;PwT;Sy|9^@PUud`{ZQzCphoHe3}@S02Y46f%FYhJg=pG%8VUnnwn%`zlrgQp~1m7 zBBK56S$%@fp1tc8YR=h&06PK>E{ULPxGz9xObY1e^K5O4dp`Mr4#+W)k~Wg6h3ZW_ zZ)>n@f_ozmRX-$jA^Gm-7-i z!Kvd3DBHyPN@5&nh2Gb?T_;iWV$LZW{qyz{1B7n4C{@fg`hEY7U9PICiV_ol$|iC+ zl77Dj5yP5o18{dtLawhe^%T6UQZo%g)KB_$$94yRFMiXyZT1zwNK>-7Z4o9kygTRX zw2qe_&d;%0gV8aFe;|HjFSNR`PP8eccEk9^)}nkh)YX49{m7D}dvuTbAtd0+TZq=& zoM80m7Ztg%aHwbU)$89aK@wW{`!(YzhuM@LKg!G#Yt4~{RXexM_x6|SJnNQmad~*k zr^9|ONJ>G(Ft5sV_vW7q`)LQ%f%fZYnXy12H91)i0>R3YtaBDMLQRJN583(Io+zbc zWq5;F2%!{dvm?Z=5{3XK`$1!a!WssQGGMW1&jMU9` zx@JI64}|MB9h#3MMl&1q^`~!As9Sa(ntv1)1}`49aU#e7B>>Gd7g6v$!JA_3Y>kQW z=(G2db>L0G1HwnSL`4fmT84BO_>_wt!OPX{wa3`lEOtlnpy<#=pS{~TYeaR<`coh5 zyIQ(4;v9LGNq6^-04U&o7Q{*I(oY@I3W+Bm!9D6+XXmMMeZ10B#F|Dyxp(RgB_)Nc z(#)?fgCJoQMNR1@hcX9I(U$#YWo4x}^|RrZc)vP22INP+9;b;seE4wVr^EAGcabL` zS9|74A3B*Pr>ED5##nE$GyC;hcqa-%*ctV=Ho<(D1HLKb=62@u`SbX=x}(ywN281P%uWsS=Y!{VO&HSu=k;#c1tl<=y-#0bkNrQc`?9&)Oqc zke(-#iY+PZhjFGq0q0P=!^O_tJTc-VZ$d3H3{*tC9n(;I)%hwOq$#Ca4;K1)sriWa z>@2(ai2B>N{)2Bev|ZP>j^RwjWCwqNx#01C0dp5vzdJjjk z(6V#)2qXqVQd3K?>J1%F=91-#3EN{;5AH_ix&p?OH2_46N^t9sOY$pUJu(h>t zw!Pft;H7g9Wz~r*WBF?J5*=lAd|cJ*KYCLs{{xw&29a?p*#kBz0_pv^Hd zdU5uGBJyJxMA=Y8&9s#&J{y3DY3XV3sm!|}H?{-*!H!}UtwEm}PI!G~5l)y4D7PA# z3JPvKP0$5LGx+&Wp_PCB>wk|f<39m^{yTp|YeG4n^2;Uk=O0mh{SuIiA2G#(Nw(IP z$D7WxUz!3EY0dTeLymj0BQimcWj8R1EUDa{h&oARV zJoV=YtXn9@_y0^xmXwsuPrd+CYC!Wm=+?h?K6G$6Bw6l}?$JfA0*y@Ra^e6Z{!^aO z$xb4Wj3e;3Ey#N&=r!PretF|EbcNi^+)Uuip4P5j4Q$s%4h-~5`Ui|fzDiC8eFyMK zhZ^@ND=Wvu#mU8RfDl0yVTi#{?9fV2V)bJz;gXo>SPTsG^pKR%7aAU^@Pfc7m905O$`84 zM~WJ|&-?xR8(rLd=V<^1@&iEjVY8Jc#P^r z&U+d=mLSS7^M+$L0L4W_-dK%Sy!Z9PAiZpvfMOD{efDp9h=L&ad#yp~4)-(_($dnB zlj~_2?jGvO^E=hh(1`Z>(3DK7G;#sd$>3T>(!K4y2qe;Qenp(p$nvqGpS z$a{YfBlzej;YdmW^7L*zuLln=?Z!zTxf=c&e@6r$vI87q3LtKo8EUmw9|)Lk;vpw{^V*Uj1oNyM~6gd=44=gyhA= zU%Q|GFqFD=^A@;;3`fg3h8@*$G>;yjV&dDjo^wKJg4Z|J62>_~A6 zcY*r4x8*~Uu{nl&BETsDgrDHH zPj_&06a6V~JbitAY8CH(ff6P{#_nPOfed70QPoY?)dHEgpDruVt12X^s2d9bPXLqvf?irVJ;bB4_vN zOId?tgk$5eqFkB&E!)ng&Rz#im|+>x3exp&o;{LwM)d7C?qGmZ>S8>rPI9fhD#xtf zAk3|@t)aKkpd~mWq^{0p;jRW8!Q>?&@#~<6uViHfHr6LbC3l2*PZuQ#3$Rd0gVtyu z(+=Tue1U5Mu;jx7MxfNFj(bJ%4S3?Ql$}$ZF6%GJ$x|=tFF5v!S}``K zCgwN{0KS&P>gMXg&cOy4is-mF!7}Ujewk75V2&Y&>}5?&B`{%fX=zP`1Ic~Zi#r%k zAGsa8sHS6Q(1w>L7rkF^3d*{qK&$H+J>vn1dl z1~H3iy8#fNVLLUo_Jjzy9d9PH+fREXi(~Uy&qfz5(B*xnp{2oIDJm)>=G1%L;C-X3 ziyrRi2++@fO2?oZCr*cJxF&xOrxJJ>GaJjg3kg3zKiC?r+>?jT_fEXW&bD$A(38Fi zd@h@93heBCpj*Ys%y+pwwbZf7RK9cj_U4Jv%DiF6oAveWL~T&9UnhS?rM@*_f$e?G zxO>Nfc&T(L^oQu9&ETjKa6kt3e^XRe9x`P$s;a00A*=(6IXN^)B$Dmf?7`IJBv2Ak z%*=zQuGZs%T9vidKu_=VVtWd3mHn)}apxxs)t=MDJc!}vAy2v2b9d@AYV6kv=b8jh zH@$LUZoxD?4;z}$|C|9FfKZt36jclFn5zZi6{V6bJveZVn3``80o~gEe(@@;YPKj1 zjLm(q?d3oQSgm!p06JN5G-f>G<ExtlQj;Kqr!Xocq|)aOujyiRU$|BX zkw9m#sOX!`E6WgZnLNL;AthB1HDcJSYswxCj5)BGnB4*C5LpsISvOvz?`+PH>`2c< zF2}$%Vk$gipojHvJMMQhRU!-Byj;7B1%WU!LUSGxJ|!SVUN~`hMnyz6w>Gn&VDkUL zHzwA9I?T8qD)$_?gRDR$6^G;9BoK?AyKThE#6tJ74WfT!}JV?f~;Ln{Sm)-X3zU_@S&Lhvm*~xkZ#obT zFyu$z{^I7txYb+mcNib+AD7fCD@#B9*=GPyuapAqv8xH_#)!bx&&6Lraxvkw&Bh#| zB4%!Ge!Md^F(oM)gm3qpOdlxi_A&Ds8+t!)@z9d|1mp!NcsoA29j9kzDr|-KX9Lx8X4G+U1nt%) z@#xqCv zBz!o9608oDRt!ro7lL>wYpAM1qyW4KVkMp4+Kpyo0n;M0p?VIgQ{@_JQM8IkKzVuj z$k4E&QLumDQ+A`x2&pL7tfyzl(|~4&{XWh13NOjnns4MHyuT~P+w}=Fg&y)^YJ3Vr--X#>&**V|e5&&yIU!-qY?ns;1!NET2?Ykia z{Nuf&;=)=VMGp~&Z^+8xm^q(3nXlIPb#fmo9iRRC0)1;{(~lQ2F|XH3zn5A}m6(I~ zwbuOD%1lmH+I??qW&Pb_#Q)K1@qB+3n?g&IFQ&7#Z7F(oyroeFHonxEjTKkCHJwq? zqmolV5pb)@Bq`DMbV!}2wv2*b9!BZ(H(Fg8QIT&TH+tV+z$~X)x_6h1VH0Cx&`AUj z`PS`RHcIX<9!5`R^;($O%MDP3)V>U|o!op3ZecH!eQ-n~7SP{Oz%U0k77C+cA^}t~ zgGr=&iLVa2e~nj{#Ke-OCT0Nprg}jkPVppTC+p_-%v>=;4M+Q*Z3>K5&W7u$A=70a zHyH5o2{qKzfTeb5N^N1uwh}`&_;oVVbB0SG(q&;|)6CfI*7Npy<>uzLqtnsR941uSS1DKq^CEZ8)!|xkx@7hm40Yf;2+?>+JpgdPhda(WgvE0RgRDX(;M&1 zms(^cNZTB8bT6tcPSFRwi4w(3xiRLgU28!131uf=fAYoDmDHUIQWw|5^zXG*EL9H?9jW!U_Dn_J=*cbMH`4?EepM z%Kx;X4cE8)-?X7s%_|3>7#$uSmlqahWoQ2qmqQc8i3H>yijd&Xfx&)5@W5v0?7Pt9 zfO{%bKh#fWYH6uQ5(t#|qlKnnb#LxU6K(HNZN11=wXb z#+60uKMM|4KLLIY*+S^+>kDQRz_6|}nF3jchTzqC(1)X;2u+p{B zguDV-zxYn9k!#g}9wrvhUo;5e`+;)Kdi_{|AL2ZBBV`SZjr9mxAjRR4PKgA-f0ZXa zaw(`FU%RNdre-tWT;q7&6_c?b9Ssk_t*o`X|3XN3s{j@aMpKfTh40@t?q|Zl!#KUy zjJJ8N783*Nj_c^p&kCu_jOuBl{k`&Dn+4<=f|t>iq&x*M&5&Tv?HJ>y-dBQuzKhfD z-XYq<2ij~<5<{(=I*@vHb$XTzw4u!|S>Gz5J&C}nP`;NQ<*H4H^ZMn5imI}VtHr*b z#13;U6O(;mWlSX>N`0Npb$BG^0f0mJt~y!q*VZ;yQ^TlqxV-9CP gs}vj!)s=3* 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 Date: Tue, 21 Apr 2026 23:28:56 +0900 Subject: [PATCH 09/35] Tutorial: communities as Group actors Add the *Communities as Group actors* chapter. It covers four example-repo commits' worth of content in one coherent narrative: - Declaring the `communities` table and lifting the identifier uniqueness check into a shared `lib/identifiers.ts` so signup and community creation both go through `isIdentifierTaken`. - The community creation UI and the server action that inserts a new row under the logged-in user. - Teaching the existing profile page to fall through from `users` to `communities`, with screenshots of the filled form and the rendered community page. - Extending the actor dispatcher (and key pairs dispatcher) so the same `/users/{identifier}` URL returns a `Group` for community slugs and a `Person` for usernames, plus a screenshot of ActivityPub.Academy resolving `@@` via WebFinger. Matches commits 8200730 ("communities table"), 46c628b ("Community creation form"), 32e75bf ("Community page"), and 6de227a ("Group actor dispatcher") in fedify-dev/threadiverse. Assisted-By: Claude Code:claude-opus-4-7 --- docs/tutorial/threadiverse.md | 454 ++++++++++++++++++ .../threadiverse/academy-search-community.png | Bin 0 -> 88888 bytes docs/tutorial/threadiverse/community-page.png | Bin 0 -> 28103 bytes .../threadiverse/new-community-form.png | Bin 0 -> 38367 bytes 4 files changed, 454 insertions(+) create mode 100644 docs/tutorial/threadiverse/academy-search-community.png create mode 100644 docs/tutorial/threadiverse/community-page.png create mode 100644 docs/tutorial/threadiverse/new-community-form.png diff --git a/docs/tutorial/threadiverse.md b/docs/tutorial/threadiverse.md index a5993d3e5..7755fa850 100644 --- a/docs/tutorial/threadiverse.md +++ b/docs/tutorial/threadiverse.md @@ -1770,3 +1770,457 @@ can actually subscribe. [ngrok]: https://ngrok.com/ [*x-forwarded-fetch*]: https://github.com/dahlia/x-forwarded-fetch [ActivityPub.Academy]: https://activitypub.academy/ + + +Communities as group actors +--------------------------- + +In the threadiverse, the unit of organisation is the *community*: a topic +bucket that local and remote users can subscribe to, post threads into, and +reply inside. Every community is itself an actor, just like a user, but +represented as a [`Group`] in ActivityPub. This chapter adds communities +to the local database, gives them a UI, and teaches Fedify to serve them +as `Group` actors alongside the `Person` actors we already have. + +[`Group`]: ../manual/pragmatics.md#group + +### The `communities` table + +Add a new table to *db/schema.ts*: + +~~~~ typescript [db/schema.ts] +export const communities = sqliteTable("communities", { + id: integer("id").primaryKey({ autoIncrement: true }), + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + description: text("description").notNull().default(""), + creatorId: integer("creator_id") + .notNull() + .references(() => users.id), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type Community = typeof communities.$inferSelect; +export type NewCommunity = typeof communities.$inferInsert; +~~~~ + +`slug` is the machine-readable identifier, the part that appears in URLs +and in the federated handle `!slug@host`. `name` is the human-readable +title, displayed to users. `creator_id` is a foreign key to the local +user who opened the community. + +Apply it: + +~~~~ sh +npm run db:push +~~~~ + +### Shared identifier namespace + +Fedify's `setActorDispatcher` registers exactly one URL template per +`Federation` instance. The scaffold uses `/users/{identifier}` for +`Person` actors, and we'll reuse the same template for `Group` actors +too, because Fedify routes every actor through the same dispatcher. + +That means a username like `alice` and a community slug like `alice` +can't both exist: when someone fetches `/users/alice`, the dispatcher +has to pick *one* interpretation. Put the uniqueness check in a +helper so signup and community creation can share it. Create +*lib/identifiers.ts*: + +~~~~ typescript [lib/identifiers.ts] +import "server-only"; + +import { eq } from "drizzle-orm"; +import { communities, db, users } from "@/db"; + +export const IDENTIFIER_PATTERN = /^[a-zA-Z0-9_]{2,32}$/; + +export function isValidIdentifier(identifier: string): boolean { + return IDENTIFIER_PATTERN.test(identifier); +} + +export function isIdentifierTaken(identifier: string): boolean { + const user = db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, identifier)) + .get(); + if (user) return true; + const community = db + .select({ id: communities.id }) + .from(communities) + .where(eq(communities.slug, identifier)) + .get(); + return community != null; +} +~~~~ + +Then rewrite the signup action to consult it (replace the hand-rolled +regex and the direct user lookup): + +~~~~ typescript{5,10,13} [app/signup/actions.ts] +"use server"; + +import { redirect } from "next/navigation"; +import { db, users } from "@/db"; +import { hashPassword } from "@/lib/auth"; +import { isIdentifierTaken, isValidIdentifier } from "@/lib/identifiers"; + +export async function signup(formData: FormData): Promise { + const username = String(formData.get("username") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + if (!isValidIdentifier(username)) { + redirect("/signup?error=Invalid+username"); + } + if (password.length < 8) { + redirect("/signup?error=Password+must+be+at+least+8+characters"); + } + if (isIdentifierTaken(username)) { + redirect("/signup?error=Username+already+taken"); + } + + const passwordHash = await hashPassword(password); + db.insert(users).values({ username, passwordHash }).run(); + + redirect("/login?message=Account+created,+please+log+in"); +} +~~~~ + +### Community creation form + +Create *app/communities/new/page.tsx*. It's an async server component +that redirects anonymous visitors to `/login` and otherwise renders a +slug + name + description form: + +~~~~ tsx [app/communities/new/page.tsx] +import { redirect } from "next/navigation"; +import { getCurrentUser } from "@/lib/session"; +import { createCommunity } from "./actions"; + +type NewCommunityPageProps = { + searchParams: Promise<{ error?: string }>; +}; + +export default async function NewCommunityPage({ + searchParams, +}: NewCommunityPageProps) { + const user = await getCurrentUser(); + if (!user) redirect("/login?message=Log+in+to+create+a+community"); + const { error } = await searchParams; + return ( + <> +

Create a community

+

+ You are opening this community as @{user.username}. +

+ {error &&

{error}

} +
+ + +