# Meith > Open-source community software you run on your own server. Meith is open-source community software for people who want to own where they gather. One Postgres database and nothing else, permissions resolved per member per community, typed theme and plugin APIs, and no third-party script between your members and their board. Source: https://github.com/meith-dev/meith Documentation: https://www.meith.dev/docs Licence: LGPL-3.0-or-later — GNU Lesser General Public License v3, in LICENSE.md in the repository above. It incorporates the GNU GPL v3, which is in COPYING. This file is the published documentation, concatenated. Each document below is the exact contents of a file under `docs/` in the repository above, which is the single editable copy — the website renders these same files. ## Contents ### Running a board - [Quickstart](https://www.meith.dev/docs/quickstart) — From nothing to a board people can reach, on your own server with Coolify. About half an hour, most of it waiting for a build. - [Running a board](https://www.meith.dev/docs/operating) — The operator handbook. Configuration, permissions, themes, plugins, spam, migrations, backup and restore, connection pooling, and the failures that actually happen. - [Upgrading a board](https://www.meith.dev/docs/upgrading) — How to take a board from one version to the next, how far you can jump, and what to do when a migration fails halfway. - [Performance](https://www.meith.dev/docs/performance) — The p95 budgets for the hot pages, and what the last recorded run measured against a full-scale board. - [Demo mode](https://www.meith.dev/docs/demo-mode) — A public board with its password printed on it, seeded with content, that deletes everything and rebuilds itself on a timer. What runs at demo.meith.dev. ### Advanced deployment - [Deploying by hand](https://www.meith.dev/docs/self-hosting) — The same board without a panel: Docker Compose, a `.env` you write, and a reverse proxy you run. Advanced — the Quickstart is the route most boards should take. ### Themes - [The theme API, v1](https://www.meith.dev/docs/theme-api) — What the freeze covers, what a theme may do, and how to write one. - [Theme slots and view models](https://www.meith.dev/docs/theme-slots) — Every slot and every view model, generated from the theme registry. ### Plugins - [The plugin API, v1](https://www.meith.dev/docs/plugin-api) — What a plugin is, what it may and may not do, and how a failure is contained. - [Plugin hooks](https://www.meith.dev/docs/plugin-hooks) — Every hook and payload, generated from the hook registry. ### The API - [REST API v1](https://www.meith.dev/docs/rest-api) — Every endpoint, scope and rate limit, generated from the route registry. ### Migrating from MyBB - [MyBB parity decisions](https://www.meith.dev/docs/mybb-parity) — Every place this board behaves differently from MyBB, with the reason. Read it before promising anyone a like-for-like move. ### Development - [Development](https://www.meith.dev/docs/development) — Running the board on your own machine, the workspace layout, the commands, and what to do before opening a pull request. - [Architecture](https://www.meith.dev/docs/architecture) — How it fits together: the processes, the layers, the path a request takes, and the seams everything else hangs off. - [Next.js conventions](https://www.meith.dev/docs/nextjs-conventions) — Server components, caching, forms and errors — the decisions that would otherwise be re-litigated in every pull request. --- # Quickstart From nothing to a board people can reach, on a domain, over HTTPS. About half an hour, most of it waiting for a build. This is the guided route: [Coolify](https://coolify.io) on your own server. It is the shortest path to a real board, and a real board is the only kind worth setting up — a development server on `localhost:3000` is not something anybody else can post on. **You need:** | | | |---|---| | **A server** | Your own, anywhere. 4 GB RAM, 2 vCPU, 40 GB disk is comfortable. Ubuntu 24.04 LTS below; any distro Docker runs on is fine. | | **A domain** | With an `A` record already pointing at the server's IP. The certificate step needs it resolving. | | **SSH** | Root, once, to install the panel. Everything after that is a browser. | Prefer no panel? [Deploying by hand](./self-hosting.md) is the same board from the same image, with a `.env` you write and a proxy you run. It is the advanced route, and it is a fair bit more work. Only want to read the code or write a theme? [Development](./development.md) runs it on your laptop in two commands. ## 1. Install Coolify SSH into the server as root: ```sh curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash ``` It installs Docker if it is missing and serves its own UI on port **8000**. Open `http://your-server-ip:8000` and **create the first account straight away** — that registration page is open until somebody uses it. Then close the machine down to what is actually used: ```sh ufw default deny incoming ufw allow OpenSSH ufw allow 80,443/tcp ufw allow 8000/tcp # the panel; drop this once it is behind a domain ufw enable ``` Coolify can serve its own UI over HTTPS on a subdomain of yours, using the same proxy that will serve your board. Worth doing before you close 8000. > [!NOTE] > **Coolify v4.0.0-beta.411 or newer.** Magic environment variables in a compose > file from a Git source arrived in that release, and they are what make this > deploy ask you for nothing. The install script gives you the current version; > an older existing install needs updating first. ## 2. Point it at the board In the panel: **New Resource → Docker Compose → Public Repository**. | Field | Value | |---|---| | Repository | `https://github.com/meith-dev/meith` | | Branch | `main` | | Compose file | `/docker-compose.coolify.yml` | > [!IMPORTANT] > The compose file path is the one field worth checking twice. Coolify defaults > to `/docker-compose.yml`, which is the *other* shape — it publishes a port and > expects a `.env` you have not written, so the deploy stops at once with > `required variable AUTH_SECRET is missing a value`. A clear failure, but the > fix is the file path, not the variable it names. ## 3. Set your domain and deploy Coolify offers a generated domain and accepts your own. Put yours in — the one whose `A` record you pointed at this server — and press deploy. The first build takes five to ten minutes. Watch the log. Four containers come up, in order: | Container | What it does | |---|---| | `postgres` | The board. A named volume, so recreating the container keeps the data. | | `migrate` | Applies the schema and **exits 0**. The next two wait for it, so the code never talks to a schema behind it. | | `web` | The board itself. | | `worker` | The background tick, on its own one-minute loop. Its first tick runs every scheduled task once to catch up — a long `ran` list in that log is it working, not failing. | Four things happen without your involvement, and they are the reason this route exists: - **The secrets are generated.** `AUTH_SECRET` and `TICK_SECRET` come from Coolify's `SERVICE_BASE64_64_*`, filled in on the first deploy and kept for the life of the resource. The database password comes from `SERVICE_PASSWORD_POSTGRES`. All three are visible in the panel; none is typed. - **The board is told its own URL**, which is what every link in an e-mail is absolute against. - **The certificate is issued and renewed** by Coolify's proxy. - **Nothing is published on the host**, so the proxy is the only way in. ## 4. Run the installer Open `https://your-domain/install`. It checks your environment **before** it offers you a form, and separates two kinds of problem: | It says | It means | |---|---| | **Blocker** | Installing cannot succeed. A missing variable, or the database is unreachable. | | **Warning** | Installing will succeed and something will be wrong *later*. | Anything that passed is folded into a "*N* checks passed" line you can open if you want the roll call. What is on the page is what needs a decision — so read the warnings. Nearly every way a new board disappoints somebody a month in is visible on that screen on day one. If there is a blocker, there is no form: fix it, redeploy if it was an environment variable, and reload. Otherwise the form is three numbered sections — **Your board**, **Your account** and **Sending mail**. The first two are four boxes between them: what the board is called, and the name, address and password of the first account. The third is a **list of providers**, not a page of server details. Pick the one you already have and it fills in the host, the port and the TLS mode, leaving you a sender address and a single credential to paste. Fill it in here: [step 5](#5-mail) is the answer sheet for that list, and mail is the one thing on this form that is harder to add later than now. > [!NOTE] > **Your username is the name you post under**, not a role. `admin` and > `administrator` are both reserved — along with `root`, `moderator`, `mod`, > `staff`, `system`, `guest`, `anonymous`, `me` and `you` — so that no account > can impersonate the board. The form lists them under the box. Being an > administrator is a group, and this account is put in it either way. It does not ask for the board's address. Coolify supplies that (`APP_URL`), so the installer shows it as already decided rather than asking you to retype your own domain. Pressing Install runs five steps. They are listed beside the button under **What installing does**, and if anything goes wrong the same list reopens as **How far it got**, marking each one *done*, *failed* or *not run*: 1. **Apply migrations** — every table, index and seeded usergroup. 2. **Record the board's name, address and mail settings** — the only settings it writes. 3. **Create the administrator** — your account. 4. **Create a first forum** — so the index is not empty. 5. **Disable the installer**. If you filled in section 3, it sends a **test message to the address from section 2 before step 1**, and installs nothing at all if that fails. A mistyped API key costs you a retry on this form rather than a finished board that cannot e-mail anybody. > [!CAUTION] > Step 5 is irreversible. `/install` answers 404 from then on, on purpose. You > are running this against the production database, which is the right place — > just do not do it twice against two different ones. That is a board. It sends you to the sign-in page and says so; sign in with the account you just made. If the header still says *Meith* rather than your board's name, wait a minute and reload — settings are cached briefly, and the name you typed outlives the cache. Nothing needs doing. Then go to **`/admin`**, which asks for your password a second time. That is not a bug and not a failed sign-in: the control panel keeps a session of its own, separate from your board session, so an unattended browser that is still signed in to the board is not also signed in to the panel. It lapses after 30 minutes idle, and again after 8 hours whatever you are doing. ## 5. Mail **This is the answer sheet for section 3 of the installer**, so read it before you fill that section in. If the board is already installed, the same settings are at **`/admin/settings?group=mail`** and take effect on the next message — no redeploy either way. > [!IMPORTANT] > A board with no mail configured **sends nothing at all**. Each message is > written to the container log and stops there. Password reset fails silently, > and if registration asks for a confirmation link, nobody can finish signing up. > Nobody notices until the first member cannot get back in. ### Pick the one you already have **How mail is sent** is a list of providers that opens on *Skip for now — this board sends no mail*, which is a real answer and the wrong one for most boards. Every other row is the ordinary SMTP or API transport with the fiddly half typed in for you: prefills rather than integrations, so anything you type yourself wins over the choice and a provider that moves a hostname cannot make the board un-installable. | Choose | It already knows | You give it | |---|---|---| | **A mailbox I already have (SMTP)** | Port 465, implicit TLS | Sender address, your provider's SMTP host, your mailbox address as the username, and an app password — never the password you sign in with | | **Resend (API)** | The endpoint | Sender address and the API key | | **Resend (SMTP)** | `smtp.resend.com`, 465, implicit TLS, username `resend` | Sender address, and the API key as the password | | **Brevo (SMTP)** | `smtp-relay.brevo.com`, 587, STARTTLS | Sender address, and Brevo's SMTP login and key | | **Postmark (SMTP)** | `smtp.postmarkapp.com`, 587, STARTTLS | Sender address, and the server API token as **both** username and password | | **Amazon SES (SMTP)** | Port 587, STARTTLS | Sender address, `email-smtp..amazonaws.com`, and SMTP credentials — *not* your AWS access keys | | **Any other SMTP server** | Port 587, STARTTLS | Sender address, the host, and credentials if the server wants them | | **Any other JSON API** | Nothing | Sender address, endpoint and key — and only works if the provider takes Resend's exact field names | Three boxes are on the page whichever you pick: **Sender address**, **Username** and **Password or API key** — one credential box, because the transport already knows whether it is asking for an app password or an API key. Everything else lives behind **Server details — only if yours differ from the choice above**, which is where the two rows that still need a hostname go: a mailbox provider's, and the SES host for your region. A box left blank uses the choice's own value. *You receive mail on this domain already* — Fastmail, Migadu, Google Workspace, your host's mailbox. Take the first row. It is the shortest path by a distance, because SPF and DKIM are published for the domain already and there are **no DNS records to add**. *Everything else on the list needs the sending domain verified with the provider first*, and the board cannot do that step for you. Until it finishes, a new account can usually only mail the address you signed up with, whatever the board is configured to do — and SES starts in a sandbox that needs a support request to leave. The installer says which caveat belongs to which provider, under *What each of these needs before it will send*; free tiers and deliverability are compared in [Running a board § Mail](./operating.md#mail). ### The installer proves it before it writes anything Press Install and a real message goes to the address you gave for the administrator **before the first migration**, with nothing installed if it fails. A provider that refuses says why, and that sentence is put on the form word for word — "the domain example.com is not verified" is the whole answer, and it is the one that saves the afternoon. That is what makes this a minute now rather than a visit later: a wrong key found here costs a retry, and the same key found afterwards costs a sealed board that cannot e-mail anybody, fixable only from a panel you have not seen yet. ### If you skipped it Do it at **`/admin/settings?group=mail`**. Same settings, minus the provider list — that screen is generated from the setting registry and has never heard of Brevo, so **How mail is sent** there is the transport rather than the provider (*SMTP server*, or *Provider API (Resend-compatible JSON)*) and you type the host, the port and the security mode from the table above. Then **save**, and press **Send a test message to me**. It goes to the address on your own account and sends through what is *stored*, so save first or you are testing the old configuration. The provider's refusal is shown here verbatim too. Last, check **Activation method** under `/admin/settings?group=registration`. It decides whether new members need a confirmation link at all, and it is the one setting that turns a mail problem into a board nobody can join. > None of this is an environment variable, and on this route none of it needs to > be. `MAIL_DRIVER` and its companions still exist and still win outright when > set — the installer then hides its mail section and says so, and the settings > screen warns that what it stores is not used until the variable is unset. That > is for a deployment configured wholly from files, at the cost of a redeploy to > rotate a key, and it is [Running a board § Mail](./operating.md#mail). ## 6. Set up backups Not optional, and not the panel's job alone. Two separate things live on that machine: - **The database.** Coolify schedules `pg_dump` per resource, with S3 as a destination. Turn it on now. - **The uploads volume.** Avatars and attachments. Coolify's scheduled backup does **not** include it, and finding that out during a restore is the worst possible time. [Backup and restore](./operating.md#backup-and-restore) has the commands for both, and the order they have to go back in. A backup nobody has restored is a file, not a backup. ## If the install fails halfway The run stops at the first failed step, and the step list beside the button reopens as **How far it got** — each step marked *done*, *failed* or *not run*. That list is the answer to "is it safe to press this again", so read it before you do. Most of what stops it is an **answer**, not a fault. "Create the administrator" runs the board's ordinary registration, so a reserved name, an address already in use or a password below the board's own minimum all stop the run there. When that is what happened, the message is repeated beside the box that caused it and the summary links straight to it — change that one answer, retype the passwords, and press Install again. Sealing is deliberately last, so a failure before it leaves a board you can fix and retry. What to do depends on how far it got: - **It failed before the administrator was created.** Fix the cause and run it again. Migrations and the board-name setting are both safe to apply twice. - **It failed after the administrator was created.** The installer will refuse to run again — its preflight blocks on *any* account existing, so a retry cannot add a second administrator to a board that already has members. If the board is genuinely yours to reset, recover at the database: restore the empty database, or drop and recreate it, and start again. If the only thing missing is administrator access on a board that otherwise works, do not reinstall — use the [operator CLI](./operating.md#the-operator-cli): `community user:promote`. ## When something else goes wrong | What you see | What it is | |---|---| | The deploy fails before any container starts, naming `AUTH_SECRET` or `TICK_SECRET` | Almost always the wrong compose file. It has to be `/docker-compose.coolify.yml` — the other one expects a `.env` that does not exist here, and Compose refuses to start without it. | | `migrate` exits non-zero | Read its log. A failed migration stops the stack on purpose rather than serving against a half-applied schema. | | The worker logs `worker started` every few seconds | It is crash-looping; the throw is in the log above each restart. | | 413 on an upload | The proxy's body limit, not the board's. Raise it on the resource. | | Password reset "sent" and never arrives | Mail is not configured, so the message is sitting in the web container's log. Check `/admin/settings?group=mail` and press the test button. | | Nothing happens on a schedule | The `worker` container is not running. Every catch-up operation is on that loop, and when it stops **nothing errors** — see `/admin` → System health. | [Running a board § Troubleshooting](./operating.md#troubleshooting) covers the failures that are about the board rather than about the deploy. ## Next | You want to | Read | |---|---| | Run this board day to day | [Running a board](./operating.md) | | Take it from one version to the next | [Upgrading a board](./upgrading.md) | | Deploy it without a panel | [Deploying by hand](./self-hosting.md) | | Change how it looks | [The theme API](./theme-api.md) | | Add behaviour | [The plugin API](./plugin-api.md) | | Move a MyBB forum here | [MyBB parity](./mybb-parity.md) | | Work on Meith itself | [Development](./development.md) | --- # Running a board The operator handbook: everything from the day after you install to the day something goes wrong. Written for somebody who has not read the source and is not going to. Installing for the first time? Start with the [Quickstart](./quickstart.md). ## Configuration Settings live in three places, and which place a setting lives in tells you what changing it costs. | Where | What lives there | Changing it costs | |---|---|---| | Environment variables | Secrets, and anything needed before the board can read its own database. | A redeploy | | `community.config.ts` | What is *installed*: themes and plugins. | An edit and a redeploy | | `/admin/settings` | Everything else: board name, registration mode, posting limits, search, mail. | Nothing — it takes effect immediately | **Why the split.** Anything in `community.config.ts` has to be visible to the bundler, because a production build contains only what it could see statically. So "install a plugin" cannot be a database row. Anything in `/admin/settings` is a value the running board reads, so it can change without a deploy. **Two things live in the overlap, on purpose.** Mail and the board's own address are ordinary settings *and* environment variables, and when both are present the environment wins outright — the screen says so rather than accepting an edit it would ignore. They are there because each has two legitimate owners. A board installed by one person on one server wants to configure mail on the day they need it, from a screen, without a redeploy; a deployment built from files in a repository wants its credentials in the environment where the rest of them are, and wants the panel unable to change them. Neither is the wrong answer, so both work, and the precedence rule is one sentence rather than a per-field table. The trade is explicit: a credential stored on the board sits in the `settings` table, readable by anything with database access, and one in the environment takes a redeploy to rotate. The registry marks the stored ones as secrets, so they are never rendered back into a page or written to the audit log — which is not the same as encrypted, and is worth knowing before choosing. ### Environment variables | Variable | Required | Notes | |---|---|---| | `DATABASE_URL` | For a real board | On a *managed* database, use the transaction-mode pooler string. See [connection pooling](#connection-pooling). | | `AUTH_SECRET` | Yes | Signs the unsubscribe links in outgoing mail. Sessions do not depend on it — they are random tokens stored hashed — so rotating it signs nobody out. No default, deliberately. | | `TICK_SECRET` | Yes, in production | Guards `/api/system/tick`: with it set, a caller without it gets a 404. It is **not** what drives the tick on the Docker Compose stack: the `worker` container runs the loop in-process and never calls the route, so scheduled work happens there either way — but the board still refuses to boot in production without the secret, so the route is never left open. Only an external caller — a cron, a platform scheduler, the `curl-tick` sidecar — actually presents it. | | `APP_URL` | No | The board's public origin, absolute and with no trailing slash. Optional since the installer began asking for it: unset, it comes from **Board address** in the settings, and set here it wins — the settings screen still accepts edits but warns they are stored, not read, until the variable is unset. Something has to supply it — a digest sent from the worker has no request to be relative to. | | `MAIL_DRIVER` | No | `log`, `http` or `smtp`. Optional for the same reason as `APP_URL`: `http` or `smtp` here wins outright, and the mail settings screen warns that what it stores is not used while the variable is set. `log` or unset leaves mail to the board. See [Mail](#mail) for the companions each transport needs. | | `DATA_SOURCE` | No | `postgres` or `fixture`. Defaults to `fixture` when `DATABASE_URL` is unset. | | `ADMIN_IP_ALLOWLIST` | No | Comma-separated address prefixes. Empty allows everything. | | `FILESTORE_DRIVER` | No | `local` or `s3`. Defaults to `local`, which is right for a board with a disk. See below. | | `MIGRATIONS_DIR` | No | The folder holding the generated SQL and its `meta/_journal.json`. Normally unset — the migrator looks beside `@meith/db` in a checkout and in `/app/migrations` in the image, which is where the Dockerfile puts it. Set it only if yours is somewhere else. | ### Where uploads go Avatars, attachments and the board logo all share one store, chosen by `FILESTORE_DRIVER`. | Deployment | Setting | Why | |---|---|---| | **Local development** | nothing to set | `local`, writing to `.uploads` beside the app. | | **A VPS (Docker Compose)** | nothing to set | The image creates `/app/.uploads`, declares it a volume and points `UPLOADS_DIR` at it; compose mounts the same named volume into the web and worker services so both see the same files. | | **A board big enough to want a CDN** | `FILESTORE_DRIVER=s3` | Optional at any size, and the point at which uploads stop being your disk's problem. | **Wherever you run this, the store must survive a restart.** On a host whose filesystem is per-instance and ephemeral, `local` does not fail — it *loses*. The write succeeds, the file is served back from the same warm instance, and it is a 404 for every other visitor and for you tomorrow. An administrator uploading a logo sees it work. That is one of the reasons a board on your own server — the route this project documents — has a real disk, mounted as a volume, and nothing to think about. `s3` needs `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`, and boot fails naming any that are missing. Add `S3_ENDPOINT` for anything S3-compatible — Cloudflare R2, MinIO, DigitalOcean Spaces — which switches the client to path-style addressing. On local disk, the uploads directory is the second thing to back up; on an object store the bucket has its own backup story. See [Backup and restore](#backup-and-restore). ### Settings from the command line `/admin/settings` has everything, but the CLI works when the panel does not — which is usually the moment you need it. ```sh community settings:list # the whole registry, with defaults community settings:get board.name community settings:set board.name "The Townland" ``` ## The operator CLI Everything you should not need a browser for: migrations, users, forums, settings, scheduled tasks, search reindexing. It ships **inside the image**, so a deployed board needs no checkout, no toolchain and no Node on the host. How you reach it depends on how the board was deployed: | | | |---|---| | **Coolify** | Open the `web` container's terminal in the panel, then `node apps/cli/cli.cjs `. | | **Docker Compose** | `docker compose run --rm --no-deps web node apps/cli/cli.cjs ` | | **A checkout** | `pnpm community ` | `--rm` matters on Compose: without it every invocation leaves a stopped container behind. `--no-deps` matters too — without it, each command re-runs the whole migration container first, which is harmless and slow enough to be confusing. The rest of this page writes **`community `**, which is worth making true on a Compose board: ```sh alias forum='docker compose -f ~/meith/docker-compose.yml run --rm --no-deps web node apps/cli/cli.cjs' ``` ```sh community --help # everything it can do community env:check # is the environment valid? (it does not open a connection) community user:create --group admin # a second administrator, or the first if /install is sealed — password on stdin community user:promote # administrator access on a board that already works community task:run # run the tick once, by hand community search:reindex # after a large import, to hurry the tick along ``` Search indexes itself. `search.reindex` runs on the tick every ten minutes and covers every case that leaves a post unindexed — an import, a restored dump, an upgrade that changed what the index holds. `community search:reindex` does the same work now instead of over the next few ticks, and runs to completion rather than one batch at a time; the Admin CP's button is the same thing, one batch per click. None of the three is a prerequisite for search working. The commands that exist are the ones `--help` lists. This project does not document a command it has not written, so one you expected and cannot find is missing rather than hidden. ## Permissions 46 permission fields — 27 resolved per member per forum, 19 board-wide. Every read path — pages, search, feeds, the REST API — asks the same resolver, so there is no route that quietly reads around the rules. ### The three layers Permissions resolve in this order. Understanding the order is most of understanding the model. 1. **Group permissions** — the floor. A member's groups are combined, and a boolean is granted if *any* of their groups grants it. 2. **The forum matrix** — per forum, per group. Each cell has three states: inherit, grant, deny. 3. **Moderator rights** — per forum, per member or group, granted separately. > [!IMPORTANT] > In the forum matrix, **empty means inherit** — it is not the same as "no". > That is why each cell is a three-state control rather than a checkbox: a > checkbox writes an explicit value into every cell the first time you save, > pinning that forum so later changes at its parent do nothing. Silently pinning > a forum is the commonest way a board's permissions end up wrong. ### Numbers behave differently from switches Numeric permissions — attachments per post, signature length, edit window — combine as the **most generous** value across a member's groups. > [!NOTE] > **`0` means unlimited, not none.** A cell showing `0` is not a restriction. ### Reading the matrix `/admin/forums` holds it. Each cell shows what it resolves to *and which forum it inherited from* — "inherit" on its own tells nobody anything. **Copy to subforums** means *identical*, not *merged*. It clears rows the source forum does not have, because a descendant that denied something the source inherits would leave you with two forums you had just been told now match. The change is previewed cell by cell before it applies. ### The one door no bypass opens `admincp.access`. Super-moderator and administrator bypasses apply everywhere else, and every use of one is logged. ### How a group looks `/admin/groups/[id]` carries a group's appearance as well as its rights. Three things, and all three are optional — a board that sets none of them looks exactly as it did before they existed. - **A name colour**, set separately for **light and dark**. Both are worth filling in: a colour that reads well on white is usually unreadable on a dark page, and the board will not guess a second one for you. Set one and the other is simply not applied in that scheme. - **A badge**, as two uploads, light and dark, on the same terms as the board logo — the bytes decide the format rather than the file name, and SVG is accepted. Upload one and it is used in both schemes. It appears beside the group's title in the postbit. - **The title**, which is what shows under a member's name on every post. A member's group is their **display group** where they have chosen one, and their primary group otherwise — so a moderator who prefers to post as an ordinary member is shown as one. The colour reaches **every** username: the postbit, who started a thread, who posted last, the profile heading, who is online. It is delivered as a stylesheet rule rather than a colour on each name, which is why it works for a reader whose dark mode comes from their operating system rather than from the board's own control — that reader's page carries no dark-mode class for a theme to match on. > **Check the contrast.** Nothing stops you setting a pale yellow no reader can > make out on a white page. Beneath each picker is a sample of the name on the > surface it will really be on — light beside dark, both painted from the > board's own palette rather than inherited from the screen you are looking at, > so the light sample is light even if your machine is set to dark mode. It is > there to be looked at. ## Themes A theme is a package named in `apps/community/community.config.ts`. Installing one is three steps, in your checkout of the board: ```sh pnpm --filter @meith/web add @meith/theme-midnight ``` ```ts // apps/community/community.config.ts import midnight from "@meith/theme-midnight" export default { theme: midnight } ``` Then commit, push, and redeploy — the image is rebuilt from your repository, so an installed theme is a commit rather than a state the server drifts into. Writing your own starts from [`examples/iris-theme`](https://github.com/meith-dev/meith/tree/main/examples/iris-theme), the worked minimal theme — reference code in the repository, not installed on any board until you register it. > [!NOTE] > This is why there is no upload-a-zip path and will not be one. A theme has to > be visible to the bundler at build time; a production build contains only what > the bundler could see, so a theme discovered at runtime works in development > and is absent in production. > [!IMPORTANT] > **A member picks a whole theme, components included.** `midnight` renders its > forum listings as tables, and a member who picks it gets tables. The choice is > a cookie the server reads, so the page arrives already correct — no flash, no > second paint — and the control works with JavaScript turned off. > > `defaultTheme` in `community.config.ts` is now the *fallback*: what the board > renders when its `themes` table says nothing, and what a palette-only theme > borrows its markup from. Changing it is still a deploy; changing what members > see is not. ### The board's name, and its logo The name in the header, in every ``, and in outgoing mail is `board.name` under Settings → Board. There is nowhere else it is written down. `/admin/themes` takes a **logo** to show in place of that name, as two uploads: - **Light** — used on a light page, and everywhere if there is no dark one. - **Dark** — used when the reader is in dark mode. Two images because one that reads on a white page usually disappears on a black one. Which one a reader gets is decided on the server from their colour-scheme cookie, so a member who has forced dark on a light machine still gets the dark logo — a board doing this in CSS gets that case wrong, and gets it wrong for the commonest reader of all, the one on "system". PNG, JPEG, WebP or SVG, up to 512 KiB. **The contents decide the format, not the file name**: markup uploaded as `logo.png` is refused. SVG is accepted and is usually what you want for a wordmark; one containing a `<script>`, an event handler or a `javascript:` URL is refused, and the served response is sandboxed besides. The alt text — what a screen reader announces instead of the image — is **Logo alt text** under Settings → Board. Leave it empty and it becomes the board's name, which is usually what the logo says anyway. It is worth setting only when the logo says something the name does not. With no logo the header shows the board's name in text, which is where every board starts and where most stay. ### What you can change without a deploy `/admin/themes` holds the parts that are data rather than code: - **On or off.** A theme that is on appears in the appearance control at the foot of every page, and any member — signed in or not — can pick it. The theme the board is built with can never be turned off; neither can the default, which has to be moved first. With one theme enabled the menu is not rendered at all — a board with one look does not carry a control for choosing between it and itself — and the light/dark buttons still work on their own. - **The default** — what a member who has chosen nothing sees. It need not be the theme the board is built with: setting `midnight` as the default gives every visitor midnight, without a deploy. - **Token values** — colours, corner radius, spacing step, and the three font stacks: **body**, **heading** and **monospace**. Grouped and described on the screen, with the platform colour picker beside each colour, and **separate light and dark values**: a page background set to white no longer follows you into dark mode. The sample repaints as you drag, in both schemes at once. The board reads in one face by default — the heading stack is `var(--font-sans-stack)`, so changing the body font moves headings with it. Set **Heading font** on its own if you want headings in a different voice; any CSS font stack works, and one built from faces the reader already has (`Georgia, ui-serif, serif`) downloads nothing. - **Custom CSS.** For any theme other than the board's default this is nested under that theme's own selector, so it stops applying when a member picks another one — and a rule aimed at `:root` will not match inside the nesting. Target `body` or a class and it works in both positions. - **Export and import** — an exact JSON round-trip, so a look can be moved between boards. Documents written before per-scheme overrides existed (`"version": 1`) still import; their values apply to both schemes. A member's choice lives in a cookie (`meith_theme`, `meith_scheme`), not on the account: it works for readers who are not signed in, and it does not follow anyone between browsers. It is read on the server, which is what lets the theme decide the markup rather than only the colours — and why the switcher needs no JavaScript at all. A theme that is turned off stops rendering immediately for the members who had chosen it. The cookie is validated against the enabled list on every request, so nobody has to clear anything. **Reset** clears that theme's colours and custom CSS. It deletes the row when nothing else is left in it — a row that is enabled and not the default — because "no overrides" and "no row" look identical to every reader and only the delete leaves the board as a fresh install. It keeps the row when the board has turned the theme off, because putting colours back must not put a theme back in everybody's switcher. Writing a theme: [The theme API](./theme-api.md). Every slot and view model: [Theme slots](./theme-slots.md). ## Cookies The board sets five cookies of its own and no third-party ones: | Cookie | What it is for | |---|---| | session, remember-me | signing in. The session row also carries the CSRF secret that protects that session's forms — a column, not a cookie of its own | | admin session | the control panel's separate sign-in | | `meith_theme`, `meith_scheme` | the appearance controls, written only when a member presses one | Every one of them is either strictly necessary or set in direct response to something the reader explicitly asked for, and none exists before somebody asks for it. There is no cookie banner, because there is nothing on the board that needs one. > What a particular board must disclose or record depends on what it does with > its data, which is the operator's to decide — a board that adds its own > tracking is adding its own obligations with it. ## Plugins Same shape as a theme: add the package, a line in `community.config.ts`, a redeploy. The worked example to copy is [`examples/hello-plugin`](https://github.com/meith-dev/meith/tree/main/examples/hello-plugin) — reference code, not installed by default. > [!NOTE] > There is no upload-a-zip path, and there will not be one. A plugin discovered > at runtime is a plugin the bundler never saw — it would work in development > and be absent from the production build. ### What a plugin cannot do It cannot decide authorization, reach the visibility filter, open its own database connection, or patch core. Everything it *can* do is in a typed registry. Failures are contained: a plugin that throws leaves the page intact, and the error is counted, logged, and — after repeated failures — the plugin is switched off for the rest of the process. ### Administering one `/admin/plugins` lists what is installed, what each plugin attaches to, its settings, and the thing you cannot find out anywhere else: whether its migrations have actually been applied to *this* database. Three things are worth knowing before you need them. **"Enabled" has three answers, and the screen says which one you have.** | It says | It means | Fix | |---|---|---| | Disabled in `community.config.ts` | The entry in the installed list (`community.plugins.ts`) sets `enabled: false`. A plugin missing from that list entirely is not shown at all. | Edit the list, redeploy | | Switched off | Somebody pressed the button on this screen. | Press it again | | Failing | The server stopped calling it after repeated errors. | The error is on the plugin's own page | **The disable button is durable.** It takes effect on every instance, not just the server that handled the click, and it survives a redeploy. Reach for it when a plugin is misbehaving — you do not need to deploy to stop one. **The panel never runs migrations.** It tells you which are outstanding; `community upgrade` applies them. > [!WARNING] > A plugin with unapplied migrations is running against a schema that does not > have what it expects. Treat that line as urgent, not informational. ### Removing one `npm uninstall`, a line out of `community.config.ts`, a redeploy — the three install steps in reverse. There is no uninstall button. Its stored settings stay behind on purpose: reinstalling should not lose your configuration. Writing a plugin: [The plugin API](./plugin-api.md). Every hook: [Plugin hooks](./plugin-hooks.md). ## Content and announcements `/admin/content` holds the board-wide vocabularies — the word filter, thread prefixes, smilies and custom directives — with attachments and announcements on screens beside it. ### One difference that matters operationally | Change | When it applies | Cost | |---|---|---| | Word filter | Next page load, everywhere | None. It is applied when a post is *shown*. | | Smilies, custom directives | Gradually | Marks every stored render on the board out of date. | Smilies and directives decide what a post *renders to*, so changing one invalidates every cached render. Nothing breaks — those posts render correctly on demand and are rewritten in the background by the ordinary tick — but on a large board expect a period of extra rendering, and expect `/admin/system` to report a backlog until it clears. ### Custom directives Markdown's extension point, and the board's own additions to it. A directive chooses a name and whether it is inline or block; members write a block one as `:::spoiler` … `:::` and an inline one as `:spoiler[the ending]`, and it renders as a `div` or `span` carrying a class your theme can style. There is deliberately no replacement-pattern field: if you need bespoke markup, that is a plugin, where the code is reviewed rather than typed into a form. ### Posts are Markdown Since 0.2 the board's markup language is Markdown, and there is no BBCode renderer left in it. A board upgrading from an earlier release — or importing one from MyBB — has every post, private message, signature, announcement and draft **converted once**, in the background, by `posts.render_backfill`. Two things follow for an operator: - **Nothing looks broken while it runs.** A row the sweep has not reached is converted in memory when somebody reads it. `/admin/system` reports the backlog; on a large board expect it to take a while and to clear on its own. - **`[u]`, `[color]` and `[size]` lose their styling.** Markdown has no spelling for underline, colour or size, so those tags become their own text: the words survive, the presentation does not. It is the one permanent loss in the conversion, and it is recorded in [mybb-parity.md](./mybb-parity.md#the-markup-language-is-markdown-not-bbcode). ### Attachments **Deleting an attachment does not touch the post it was on.** Attachments are listed beside a post rather than written into it, so removing one takes an entry off a list and nothing else. The bytes go to the hourly sweep rather than being deleted immediately. ### Announcements **An announcement is not a pinned thread.** Nobody can reply to one, it expires on its own date, and removing it removes nothing anybody wrote — which is why it is safe to delete and a sticky thread is not. Dates are entered in UTC. ## Reputation `/admin/settings` under **Reputation**. Four settings, and the first two decide what the feature *is* on your board. **Allow negative ratings** — off by default. Off, reputation is a thanks button: every post carries **Thanks**, one press gives the author a point, and pressing it again takes it back. The **Rate** link is not shown, because with negatives off the rating form has nothing on it the button has not. Turn it on and the Rate link comes back beside the Thanks button, leading to a form that can also rate somebody down and say why. Both controls are then offered, because they are then two different things. **Require a comment** — off by default, and turning it on removes the Thanks button. One press cannot carry a reason, so a board that requires one is a board where every rating goes through the form. That is the right trade for a board that allows negatives — a criticism with no reason attached is the part of reputation people argue about — and the wrong one for a board that only allows thanks, which is why the default is off. > If you are upgrading, this default **changed**: it used to be on. See > [Upgrading](./upgrading.md#settings-whose-defaults-have-changed). **Posts required before rating** — 5 by default. A spam defence: registering takes seconds, posting five times on a moderated board does not. 0 turns it off. **Ratings per day** is per *group*, on the group's own screen, not here — it is a number that should differ between a new member and a moderator, and every numeric permission on this board lives with the group (0 means unlimited). A member's total is **derived**, not counted up: it is recomputed from the live ratings every time one is written, changed or withdrawn. So a withdrawn rating really leaves, and a total that has somehow drifted repairs itself the next time anybody rates that member. Editing `users.reputation` by hand therefore does nothing lasting — use **Recount & rebuild** on `/admin/system` if you need it corrected. ## Mail Mail is the one subsystem a new board gets wrong silently. Nothing errors: the password-reset form says "check your inbox", the registration confirmation is written to a log file, and the member waits. So it is asked for on the installer and provable from the control panel, rather than being an environment variable somebody sets after going live. ### Two places it can be configured, and which one wins | Where | How | When to use it | |---|---|---| | **The board** — `/admin/settings?group=mail` | Stored in the `settings` table. Takes effect on the next message, no redeploy. Has a **Send a test message** button. | The default, and what the installer writes. | | **The environment** — `MAIL_DRIVER` and friends | Read at boot. Overrides the board entirely. | When the credential must not live in the database, or the deployment is configured wholly from files. | **The rule is one line: `MAIL_DRIVER=http` or `MAIL_DRIVER=smtp` in the environment wins outright.** Anything else — `log`, or unset — hands the decision to the board's own settings. Every board that already configures mail through the environment therefore keeps working exactly as it did; what changed is only the board that never set it, which previously could not send at all and can now be fixed from the panel. When the environment wins, the settings screen says so and does not pretend its fields are live. Storing a credential in the environment is the more careful choice, at the cost of a redeploy to rotate it; storing it on the board means the API key sits in the `settings` table, readable by anything with database access. Neither is wrong, and the panel marks the stored ones as secrets so they are never rendered back into the page or written to the audit log. ### What sends mail | What | When | How it goes out | |---|---|---| | Notification e-mail | A member's notification, when they asked for it by mail | Queued — leaves on the **tick** | | Mass mail | An administrator sends one from `/admin/users/mail` | Queued — leaves on the **tick** | | E-mail change confirmation | A member changes their address in the UserCP | Sent during the request | | Registration confirmation | A registration, when the activation method asks for one | Sent during the request | | Password reset | Somebody uses the "forgot your password" form | Sent during the request | The split is not arbitrary. The first two go to members the board already knows, in volume, and can wait a minute. The last three each go to somebody sitting in front of a screen who will retry within seconds if nothing arrives, and two of the three go to an address the board has not proven yet — a queued job cannot be a notification to an account that may not be reachable. ### Choosing a transport | Transport | What it does | |---|---| | Not sending (`log`) | Writes `mail (not actually sent)` to the log with the recipient and subject. Delivers nothing. The default. | | **SMTP** | Speaks SMTP to any server. Reaches every provider, and every mailbox host. | | **Provider API** (`http`) | Posts Resend's JSON body with a Bearer token. Works for Resend and anything that copies it. | ### The shortest path, if you already receive mail on your domain Use SMTP against the mailbox you already have — Fastmail, Migadu, Google Workspace, your VPS host's mail service, whatever it is. It is the only option with **no DNS work at all**, because SPF and DKIM are already published for that domain; every provider below needs new records before it will carry a message to anybody. On `/admin/settings?group=mail`, using the screen's own labels: ``` How mail is sent: SMTP server Sender address: an address on that domain SMTP host: your provider's SMTP host SMTP port: 465 (or 587) SMTP security: Implicit TLS (or STARTTLS, for 587) SMTP username: your mailbox address SMTP password: an app password — never the password you sign in with ``` Mailbox providers rate-limit sending (Workspace is around 2,000 messages a day), which is ample for a forum and not for a newsletter. ### Resend, copy-pasteable Free for 3,000 messages a month, and the provider whose API the `http` transport was written against. On the installer, pick **Resend (API)** and give it two things — the sender address and the API key. The endpoint comes with the preset. On `/admin/settings?group=mail` after the fact, the same three fields by hand: ``` How mail is sent: Provider API Sender address: noreply@yourdomain.com API endpoint: https://api.resend.com/emails API key: re_… ``` Or the same account over SMTP — host `smtp.resend.com`, port 465, implicit TLS, username the literal word `resend`, password the API key. **Resend (SMTP)** is a preset too and fills those four in for you; on the settings screen you type them, because the screen is generated from the setting registry and has no provider list. Only if the credential must not live in the database, the environment says the same thing and overrides both — at the cost of a redeploy to rotate it: ```sh MAIL_DRIVER=http MAIL_HTTP_ENDPOINT=https://api.resend.com/emails MAIL_HTTP_TOKEN=re_… MAIL_FROM=noreply@yourdomain.com ``` Two things will bite you before the first message arrives: 1. **Verify the sending domain with the provider first.** Every provider requires it, the board cannot do it for you, and until it is done a new Resend account can only mail the address you signed up with. 2. **The sender must be an address on that verified domain.** If it is not, every message is rejected with a 4xx — which the driver reports as a *configuration error* and does not retry, because it would fail identically on every attempt. ### SMTP, in the environment ```sh MAIL_DRIVER=smtp MAIL_SMTP_HOST=smtp.provider.example MAIL_SMTP_PORT=465 MAIL_SMTP_SECURITY=tls # tls | starttls | none MAIL_SMTP_USERNAME=… MAIL_SMTP_PASSWORD=… MAIL_FROM=noreply@yourdomain.com ``` `MAIL_SMTP_HOST` and `MAIL_FROM` are required; the username and password must be set together or not at all, since a relay on the same machine legitimately needs neither. Boot fails naming whatever is missing. **Security is three values, not a checkbox, and this is the setting people get wrong.** `tls` is implicit TLS — the socket is encrypted before the first byte, which is port 465. `starttls` connects in the clear and upgrades, which is port 587, and the board *refuses to continue if the upgrade fails* rather than sending your password in plaintext. `none` is genuinely unencrypted and is for a relay on this machine and nothing else. A mode that disagrees with the port produces a connection that hangs instead of failing, which is the single most confusing way for this to go wrong. ### Other providers Brevo (~300/day free), Postmark (the best deliverability, 100/month free), Mailgun and Amazon SES all speak SMTP, so all four work as-is. The **installer** carries prefilled presets for Brevo, Postmark and SES; Mailgun has none, and neither does any provider added after this was written — pick *Any other SMTP server* and type the host. A preset is a convenience, not an integration, and nothing behaves differently without one. The **provider API** transport is not a Resend client but it is Resend-shaped. It posts: ```json { "from": "…", "to": "…", "subject": "…", "text": "…", "html": "…", "reply_to": "…" } ``` Resend's `POST /emails` takes exactly that. **Postmark and Mailgun do not** — Postmark uses `From`/`To`/`TextBody` and an `X-Postmark-Server-Token` header, Mailgun takes form-encoded fields on a per-domain URL. Use their SMTP hosts instead; that is what the SMTP transport is for, and it needs no code change. ### Prove it, rather than assuming it `/admin/settings?group=mail` has a **Send a test message to me** button. It sends through the configuration the board has *saved* — so save first — to the address on your own account, and shows the provider's own refusal verbatim when there is one. "The domain example.com is not verified" is the whole answer; a tidier message would not be. The installer does the same thing and goes further: it sends the test **before the first migration**, and refuses to install if it fails. A wrong API key therefore costs a retry rather than a sealed board that cannot mail anybody. ### The settings behind the screen The screen is generated from the setting registry, so every field on it is a key `community settings:set` can write. That matters exactly once, and it is the once that counts: **when mail is broken and the panel is not reachable**, which is the same situation as being locked out, because password reset is the thing mail was going to fix. | Key | Field | Notes | |---|---|---| | `mail.transport` | How mail is sent | `log`, `smtp` or `http` | | `mail.from` | Sender address | Must be on the verified domain | | `mail.from_name` | Sender name | Empty sends the bare address | | `mail.smtp_host` | SMTP host | | | `mail.smtp_port` | SMTP port | 465 for `tls`, 587 for `starttls` | | `mail.smtp_security` | SMTP security | `tls`, `starttls` or `none` | | `mail.smtp_username` | SMTP username | Both credentials, or neither | | `mail.smtp_password` | SMTP password | Stored as a secret — never echoed back | | `mail.http_endpoint` | Provider API endpoint | Only for the `http` transport | | `mail.http_token` | Provider API key | Stored as a secret | ```sh community settings:set mail.transport smtp community settings:set mail.smtp_host smtp.provider.example community settings:set mail.from noreply@yourdomain.com community task:run # run the tick once, so queued mail leaves now ``` The two secrets are write-only from the operator's side: the panel renders them as empty password boxes and a blank one means *unchanged* rather than *clear it*, and `community env:check` and the audit log both refuse to print them. To clear one deliberately, set it to the empty string. ### Queued mail needs the tick Notification and mass mail are delivered by a job that runs on the tick. A stopped tick means no mail and **no error anywhere** — the messages sit in the queue looking fine. `/admin/system` says loudly when the tick is stale; see [Nothing happens on a schedule](#nothing-happens-on-a-schedule). The three that are sent during the request — password reset, e-mail change, and registration confirmation — do not wait for it. So "the reset arrived but the digest did not" points at the tick, and "nothing arrives at all" points at mail. ### The sender name and the sender address are different settings The address is `mail.from` (or `MAIL_FROM`); **Sender name** is the display name beside it. Together they become `"The Townland" <noreply@yourdomain.com>`; leave the name empty — the default — and messages go out as the bare address. The split predates mail being a board setting, and it still earns its keep: the address has to be on a domain your provider has verified, so getting it wrong means nothing is delivered, while the name is only what people see in their inbox. The name is read **per message**, not once at startup, so renaming your board changes the next message rather than the next restart — a worker process can outlive several settings changes. ### Activation and mail are one decision `registration.method` in `/admin/settings` chooses what a new account has to do before it can sign in: | Method | What happens | |---|---| | `none` | The account works immediately. | | `email` | A confirmation link is sent. Until it is followed, the account cannot sign in. | | `admin` | The account waits for an administrator. No mail involved. | | `both` | The link first, then an administrator. | The default is `none`, and it is `none` because a board that has not configured mail sends nothing: asking for confirmation out of the box would mint links it cannot deliver. Choosing anything else is a decision to make *after* mail works — which is now one button away rather than a redeploy away. > [!IMPORTANT] > **`email` or `both` on a board with no working mail is a board nobody can > join.** The links are minted, printed to the log, and never delivered. This > cannot be a boot check — mail and the method are both rows you can change on a > running board — so instead the registration settings screen and `/admin/system` > both say so, loudly, while it is true. > [!NOTE] > **Upgrading an existing board?** This setting had no effect until recently — > whatever the dropdown showed, accounts were created as though it said `none`. > A board that stored `admin` or `both` gets the vetting it asked for as soon as > it upgrades. See > [Settings that gained a reader](./upgrading.md#settings-that-gained-a-reader). An account already stuck at "awaiting activation" can be activated by hand from its member screen in `/admin/users`. Somebody who never received their link can ask for another at `/verify/resend`, which is linked from the sign-in page. ### What happens when a provider fails - **A rejection that will not change** — a bad address, an unverified domain, a bad token, or any SMTP 5xx — is treated as configuration and **not retried**, because it would fail identically every time. - **A transient failure** — 5xx or 429 over HTTP, a 4xx SMTP reply, a refused connection — is retried by the queue's backoff for queued mail. A greylisting relay answering "try later" is the case this exists for. A direct send has no retry: the member asks again. - Drivers hold no retry logic of their own. The queue is the retry mechanism, deliberately, so one place decides how often to try again. - Every send is **bounded by a timeout**, including each stage of an SMTP conversation. Without it a hung provider would hold a job's lease open for its full duration and consume the tick's whole budget — and a host that accepts the connection and never greets, the classic symptom of a port that disagrees with the security mode, would do it on every attempt. - A failed send never fails the thing that caused it. A registration whose confirmation could not be sent still created the account — reporting "registration failed" would be a lie about a state you now have to live with — and the screen it lands on offers to send the link again. ### The board has to know its own address Every message that carries a link — confirm your address, reset your password, a notification pointing at a post — builds it from the board's own origin, because nothing in a queued job or a mail template knows the request that caused it. So do feeds, sitemaps and every canonical URL. This used to be `APP_URL` and nothing else, which made it the single most likely misconfiguration on a new board. It is asked for on the installer now — prefilled from the address you loaded `/install` at — and lives at **Board address** on `/admin/settings?group=board`, changeable without a redeploy. `APP_URL` still wins when set, on the same rule as mail, and the settings screen says so rather than accepting an edit it will ignore. With neither set, the board does **not** emit a relative link, which would be a dead string in a mail client. It degrades to written instructions instead: the mail arrives, it is polite, and it is useless. Feeds and canonical URLs fall back to a localhost origin, which is obviously wrong rather than subtly wrong. The address is an **origin** — scheme, host, optional port, nothing else. `https://forum.example/board` is rejected by the settings screen on the way in, because every link the board built from it would carry `/board` in the middle. `APP_URL` is checked more loosely — only that it is a URL — so a path pasted into the environment is the one place this mistake can still get through. ## Spam Registration questions are at `/admin/antispam`; the numbers are in `/admin/settings` under **Anti-spam**. Everything except the hidden-field trap and a three-second minimum fill time ships switched off. A fresh board has no spam on it, and a feature that arrives switched on introduces itself by breaking your registration form — those two are on by default because no human notices either. ### What each control is actually worth | Control | Stops | Costs a real visitor | |---|---|---| | Hidden-field trap | Bots that fill every field | Nothing. Leave it on. | | Minimum fill time | Instant submissions | Occasionally somebody with a password manager. Keep it to a few seconds. | | A question | Scripted registration | A moment, every time. Switch it on when you have a problem. | | Hold first posts | Nearly all forum spam | One wait per genuine new member. | | Hourly limits | A night's work by one script | Nothing, set sensibly. | > [!TIP] > **Holding a new member's first posts is the effective one.** Spam accounts post > once or twice and never come back, so a threshold of two or three catches most > of it. Held posts go to the moderation queue like anything else. ### Limits and the flood interval are different controls | | What it bounds | What it stops | |---|---|---| | Flood interval (`posting.flood_seconds`) | The minimum gap between two actions | A double-click | | Hourly limit | How many actions in an hour | A script posting steadily all night | A script satisfies any interval you would be willing to set — every 31 seconds, all night, is thousands of posts and never breaks the rule. Use both. Members with **bypass flood check** are exempt from both. Limits are counted in the database, so every instance of your board shares one allowance rather than getting one each. The counters are pruned hourly by the tick; if the tick is stopped they accumulate, but `/admin/system` will tell you the tick is stale long before this becomes your problem. ### If registration stops working Check `/admin/antispam` first. - A question challenge switched on with **no question configured** does nothing rather than refusing everybody. That is deliberate, and the screen says so. - A **minimum fill time** set to a minute quietly turns away most real applicants. This is the usual culprit. If registrations are *created* but nobody can sign in afterwards, it is not anti-spam — it is the activation method waiting for mail the board cannot send. See [Activation and mail are one decision](#activation-and-mail-are-one-decision). ### No hosted captcha Not because it is hard. A hosted captcha means every visitor's browser contacting a third party before they can join your board, which is a decision about your members rather than a setting. The provider seam is there if you want one — see `packages/antispam`. It is a small module, not a fork. ## Migrations Migrations are **forward-only**. There is no down migration and there will not be one: a migration that drops a column is a data-loss button on a live board, and some migrations cannot be reversed at all, so a "roll back" that worked for half of them and silently did nothing for the rest would be worse than its absence. ```sh community migrate # core only community upgrade # core, then each installed plugin's, then record the version ``` The admin panel shows a notice when the deployed code is ahead of the database. Full procedure, including how far you can jump between versions: [Upgrading a board](./upgrading.md). ## Backup and restore > [!IMPORTANT] > **The backup is the rollback plan.** Migrations are forward-only, so restoring > is the only way back. This is not a precaution, it is the recovery procedure — > which is why it is worth testing before you need it. ### What to back up Two things, and only one of them is the database. 1. **The database.** Accounts, posts, settings, permissions, theme overrides — everything the board knows. 2. **Uploaded files**, if your file driver is local disk. On S3 or a compatible store the files are already elsewhere and the bucket has its own backup story. The code is in git. `.env` values — or the secrets your panel generated — are worth a copy somewhere you can reach when the machine is the thing that is broken. > [!WARNING] > **A scheduled database backup is not a backup of the board.** Coolify's > per-resource schedule dumps Postgres and does not touch the uploads volume, so > a restore from it gives you every post and a broken image in each of them. > Whatever takes the database, something has to take the volume too. ### Taking one ```sh pg_dump --format=custom --no-owner --no-privileges "$DATABASE_URL" > board.dump ``` From a container deployment, where `pg_dump` is in the database container rather than on the host: ```sh docker compose exec -T postgres pg_dump -U forum forum | gzip > board-$(date +%F).sql.gz docker run --rm -v meith_uploads:/u -v "$PWD":/out alpine \ tar czf /out/uploads-$(date +%F).tar.gz -C /u . ``` Check the volume's real name with `docker volume ls` first — Compose prefixes it with the project directory, and Coolify with the resource's UUID. Then put both in a cron and **copy them off the machine**: a backup on the server is a backup of the thing most likely to fail. `--format=custom` restores selectively and compresses. `--no-owner` and `--no-privileges` because the role names on a managed platform are not the ones you will restore into. > [!WARNING] > **Use the direct connection string for a dump, not the pooler.** A transaction > pooler does not support the session-level operations `pg_dump` needs, and the > failure is confusing: a dump that starts and then stops. ### Restoring ```sh createdb forum_restored pg_restore --no-owner --no-privileges --dbname="$RESTORE_URL" board.dump ``` Restore into a **new database** first and point a staging deployment at it. A restore over a live database is how a bad backup becomes two lost boards. Then check three things, in this order: 1. `select count(*) from posts;` — is the content there? 2. Sign in as an administrator — did the credentials survive? 3. `community migrate` — it applies anything missing and reports what it did, so on a good restore it says there was nothing to do. ### Rehearse it A backup nobody has restored is a file, not a backup. Restore one into a scratch database before you need to, and note how long it took: that number is your recovery time, and an incident is the wrong moment to find it out. ## Connection pooling **Running the documented deployment? Skip this section.** A board on its own Postgres, with a fixed number of server processes in front of it, opens a bounded number of connections and needs no pooler. Use the ordinary connection string. This is for a board pointed at a *managed* database — Neon, Supabase and their kind — and it is worth reading before you point one at it. > [!CAUTION] > **It does not break during testing.** Those providers hand out two connection strings, and the difference only shows under load: on the direct one, every process that scales up opens its own connection, Postgres runs out at around a hundred, and the board that worked perfectly while you were the only visitor starts refusing connections the first day it is busy — with an error that names the database rather than the cause. **Use the transaction-mode pooler string.** On Supabase that is port `6543`, not `5432`. The installer used to warn about this and no longer does: it could only guess from the shape of the URL, and on a board running against its own Postgres — which is the deployment this handbook documents — the direct string is correct and the warning was noise. Two consequences: - **Prepared statements are off.** A transaction pooler hands a different backend to each transaction, so a prepared statement from one is not there for the next. The database layer is configured for this; a plugin issuing raw SQL should be too. - **`pg_dump` and DDL want the direct URL.** Both need session-level state. Set `DIRECT_DATABASE_URL` for migrations when your provider offers both strings — a migration's advisory lock is invisible through a transaction pooler, which is what lets two deploys interleave schema changes. ## Troubleshooting ### Nothing happens on a schedule *Bans do not expire, digests do not send, counters drift, uploads are not swept — and nothing errors, because nothing ran.* 1. Check `/admin/system`. The tick's status is there, and a stale one is called out loudly. 2. Check something is actually running the tick. **On the documented deployment that is the `worker` container, which runs the loop in-process** — it does not call `/api/system/tick` and does not need `TICK_SECRET` to do its job. `docker compose ps` should show it up, and `docker compose logs worker` should show `worker started` **once** rather than every few seconds, which is a crash loop with the reason logged above each restart. 3. If instead you drive the tick from outside — a cron, a platform scheduler, the `curl-tick` sidecar — then it is the route that runs it, and `TICK_SECRET` has to be set *and* presented. A caller with the wrong secret gets a 404, deliberately, so an unauthorised caller cannot confirm the endpoint exists; from the caller's side that looks identical to a wrong URL. (An *unset* secret would leave the route open, which is why production refuses to boot without one.) Notification and mass mail are delivered on this tick, so a stopped one is also a board that has stopped sending them — see [Mail](#mail). Verification and password-reset links do not wait for it; if *those* are missing, mail itself is what to check, and the **Send a test message** button on `/admin/settings?group=mail` settles it in one click. ### The installer's "migrate" step says it cannot find `meta/_journal.json` The migrator was looking in the wrong place. The generated SQL is *data*, so Next never traces it into the standalone output — the Dockerfile copies it to `/app/migrations` instead, and a build where that copy did not happen leaves the web server with no migrations to apply. Check the folder is in the image (`docker compose run --rm web ls /app/migrations`) and rebuild if it is not. If your deployment keeps the SQL somewhere else, name it with `MIGRATIONS_DIR` and redeploy. Nothing has been written when this fails: migrations are the installer's first step, and it stops at the first failure precisely so a retry is safe. ### "Too many connections" See [connection pooling](#connection-pooling). It is almost always the direct connection string. ### The admin panel 404s Three possibilities, in order of likelihood: 1. `ADMIN_IP_ALLOWLIST` is set and your address is not in it. The panel answers 404 rather than 403 from outside the allowlist — its value is being invisible. 2. Your account is not in a group with `admincp.access`. 3. Your admin session expired. It has a 30-minute idle timeout and an 8-hour ceiling, both separate from your board session. ### A member cannot see a forum they should Open `/admin/forums` for that forum and read **the row for their group** rather than reasoning about the combination. Each cell says what it resolves to and where it inherited from. The usual cause is an explicit deny somewhere up the tree, which inheritance carries down. ### Counters look wrong `/admin/system` → **Recount & Rebuild**. It is resumable and safe to run on a live board. If they drift *again* afterwards, the outbox is not being drained — see the tick, above. ### An imported board's old links 404 `board.legacy_redirects` is off by default. Turn it on at `/admin/settings`. It needs an import to have run, because the redirect is a lookup in the legacy id map. ### Everything is broken and the panel will not load The CLI does not need the web app: ```sh community env:check # is the environment valid? (no connection is opened) community settings:list # what the board thinks its settings are community task:list # what is scheduled, and how often each runs community migrate # apply anything the schema is missing ``` `community --help` lists everything. The commands that exist are the ones listed there — this project does not document a command it has not written, so if one you expected is missing, it is missing rather than hidden. ### Getting help Every error page carries a **request id**. Quote it. The board's logs are correlated by it, and it turns "a page broke" into one grep. --- <!-- docs/upgrading.md · Running a board --> # Upgrading a board Taking a board from one version to the next: what to do, in what order, and how far you can jump. ## The short version Deploy the new code, then run the upgrade: ```sh community upgrade --dry-run # read what it will do community upgrade ``` On the documented deployments the *core* migrations are already applied by then — the `migrate` container runs to completion before anything serves — so `upgrade` is what carries plugin migrations and records the version. The admin panel shows a notice until you run it. `forum` is the operator CLI, and how you invoke it depends on how the board was deployed; [Running a board § The operator CLI](./operating.md#the-operator-cli) has the three spellings. ## Take a backup first > [!CAUTION] > Migrations are forward-only. Restoring a backup is the *only* way back, which > makes the backup your rollback plan rather than a precaution. There is no down migration and there will not be one. A down migration that drops a column is a data-loss button on a live board, and some migrations — a destructive backfill, a column collapsed into another — cannot be reversed at all. A "roll back" that worked for some and silently did nothing for others would be worse than its absence. Take a backup before every upgrade, and make sure it is one you have actually restored at least once. See [backup and restore](./operating.md#backup-and-restore). ## What `community upgrade` does Four things, in this order: 1. **Core migrations.** Everything else assumes the schema they create. 2. **Plugin migrations**, per plugin, in dependency order. 3. **Plugin versions recorded**, one per plugin. 4. **The core version recorded**, last. **Why the version is written last.** A version written before the work means a failed upgrade leaves a board claiming to be something it is not — and the next run finds nothing to do. Same reasoning as the installer's seal. ### Dependency order is declared, not guessed A plugin says what it needs: ```ts export const badges = definePlugin({ key: "badges", name: "Badges", version: "1.2.0", dependsOn: ["points"], // … }) ``` Declared rather than inferred, because the dependency that matters is a *schema* one, and nothing in an import graph reveals it. The planner sorts topologically and **breaks ties on the plugin key**, so the sequence is identical on your staging board and on production. That is the only thing that makes rehearsing an upgrade worth anything. | Problem | What happens | |---|---| | A dependency cycle | Refused, with the tangled keys named | | A plugin depends on something not installed | Refused by name, rather than quietly running against a table that does not exist | ### An interrupted upgrade is safe to re-run Each plugin migration is applied *and recorded* in one transaction. That is the only arrangement that survives a crash between the two: - Applied but not recorded → the next run applies it again. - Recorded but not applied → a column that never exists, and a plugin that fails on every request. Because the two are atomic, "try the upgrade again" is a safe instruction: an interrupted run re-applies nothing it already did. ## How far you can jump **Two majors.** A board at 1.x can upgrade directly to 3.x. 1.x to 4.x is refused. The limit is honesty rather than caution. Supporting an arbitrary jump means every migration must remain correct against every schema that ever existed — a promise nobody can test, and therefore one that should not be made. Two majors is what the migration set is exercised against, so two majors is what is claimed. A board further behind is not stuck. Upgrade in stages — check out each major in turn, deploy it, and run the upgrade before moving on: ```sh git checkout v2 && docker compose up -d --build && community upgrade git checkout v3 && docker compose up -d --build && community upgrade git checkout main && docker compose up -d --build && community upgrade ``` Each stage is an ordinary upgrade with an ordinary backup in front of it. ## Downgrades Refused. Migrations are forward-only, so "downgrading" means running old code against a schema that has already been migrated past it — which usually appears to work and corrupts something a week later. | Situation | Do this | |---|---| | You deployed a version you did not mean to | Deploy the newer one again | | The newer one is broken | Restore the backup | ## On your own server Under [Coolify](./quickstart.md), the upgrade is the **Redeploy** button — or nothing at all, if you have enabled the webhook and a push to `main` deploys itself. Under Compose it is two commands: ```sh git pull docker compose up -d --build ``` Either way the ordering is handled for you: `migrate` runs to completion first and `web` and `worker` wait for it, so the new code never serves against the old schema. Take a backup before you start — migrations are forward-only, and recovery is by restore. Coolify's scheduled backup covers Postgres; the uploads volume is a second thing, and yours. That applies **core migrations only**. Plugin migrations run through `community upgrade`, which carries your board's plugin list with it — see [the operator CLI](./operating.md#the-operator-cli) for how to run it on your deployment. ## When the deploy and the migration are separate events Deploy some other way, and the two come apart: the board runs the new code as soon as the deployment is live, and the schema does not change until you run the command. Between them, new logic is talking to an old schema. That window is why the admin notice exists. It names both versions and the number of migrations waiting — so the failure mode (surfacing as "column does not exist" in whichever request touches it first) becomes a sentence somebody read before it happened. For a board with real traffic: | Migration kind | When to run it | |---|---| | Adds things only | Before or after the deploy; either is safe | | Removes or renames | Two-step: ship code that tolerates both shapes, migrate, then ship code that assumes the new one | Releases say which kind they are. ## Settings whose defaults have changed A board setting is stored only once somebody changes it, so a **default** that moves applies to every board that never touched that switch. There is no migration to run and nothing to undo; the point of listing them is that behaviour changed without anybody on your board doing anything. | Setting | Was | Is | What changes on a board that never set it | |---|---|---|---| | `reputation.comment_required` | on | off | Posts gain a one-press **Thanks** button. A rating no longer has to carry a reason — a click is the whole interaction, which is what makes thanking an answer worth doing. | Set it back from **Admin → Settings → Reputation** if your board wants every rating to say why. That is the right choice for a board that allows negative ratings, and it is why the two switches are worth reading together: a criticism with no reason attached is the part of reputation people argue about, and a thanks is not. ## Configuration that moved out of the environment Two things that were environment variables and nothing else are board settings now. **Nothing changes for a board that had them set** — the environment still wins, outright, and the screen says so rather than accepting an edit it would ignore. What changes is the board that never set them, which previously could not fix either without a redeploy. | | Was | Is | |---|---|---| | **Mail** | `MAIL_DRIVER` and friends, read at boot | `MAIL_DRIVER=http` or `=smtp` still wins. `log`, or unset, now hands the decision to `/admin/settings?group=mail` — which has a **Send a test message** button that reports the provider's own refusal verbatim. | | **The board's address** | `APP_URL`, read at boot | `APP_URL` still wins. Unset, it comes from **Board address** on `/admin/settings?group=board`, and the installer asks for it on a fresh board. | The upgrade needs no action either way. Worth doing once, though: open `/admin/settings?group=mail` and press the test button. Mail is the subsystem where a misconfiguration is silent by construction — the reset form still says "check your inbox" — so "we believe mail works" and "a message arrived" are worth reconciling on a board you have just moved. ### `MAIL_DRIVER=smtp` boots now It used to refuse to start, on purpose: there was no SMTP driver, and quietly downgrading to the log driver would have meant an operator watching password resets vanish with no error. There is one now. `MAIL_SMTP_HOST` and `MAIL_FROM` are required with it, and the username and password must be set together or not at all — see [Mail](./operating.md#mail). If you have been running a separate relay to bridge this gap, it can go. ### `TICK_DEADLINE_MS` and `TICK_MAX_JOBS` are gone They were read by nothing. Both were declared, documented in three places, and consulted by no code — a task's wall-clock budget comes from its own definition, and the worker bounds a tick with a constant of its own. Tuning them changed nothing, and there was no way to discover that. **Leaving them in your `.env` is harmless** — unknown variables are ignored, not rejected, so nothing fails on the next boot. Delete them when convenient; the only cost of keeping them is the next person believing they do something. ## Settings that gained a reader A setting can also change behaviour by starting to be *read*. That is not a default moving, and there is nothing to run — but it is worth knowing which switches on your board were, until now, decorative. ### `registration.method` now decides what a new account has to do `registration.method` had been a setting with no reader: the dropdown moved, the value was stored, and every account was created as though it said `none`. It is now honoured everywhere the board creates an account. **Its default moved to `none` in the same release**, which is what keeps this from changing anything under you. Read the two together: | Your board stored | Before | Now | |---|---|---| | Nothing (never opened the screen, *or* chose `email` while it did nothing) | Accounts active immediately | Accounts active immediately — unchanged | | `none` | Accounts active immediately | Unchanged | | `admin` | Accounts active immediately, **contrary to the setting** | Accounts wait for an administrator | | `both` | Accounts active immediately, **contrary to the setting** | A confirmation link, then an administrator | The first row is the one that needs explaining: **a value equal to its default is not stored**, so an operator who selected `email` back when it did nothing has no row, and is indistinguishable from somebody who never opened the screen. Defaulting to `email` would have switched confirmation on for both of them — on boards that very often had no mail configured at all, which would have left them unable to register anybody. The default follows the behaviour every board actually had. **If you did want confirmed addresses, you now have to say so** — and this time saying so works. Configure mail first at `/admin/settings?group=mail`, prove it with the **Send a test message** button, then set the method in **Admin → Settings → Registration** ([Mail](./operating.md#mail)). The last two rows of the table are the boards that get a real behaviour change: they asked for vetting, and now they get it. > [!IMPORTANT] > `email` or `both` on a board with no working mail is a board nobody can join: > the links are minted, written to the log, and never sent. The registration > settings screen and `/admin/system` both say so for as long as it is true, so > this is not a thing you find out from your members. > > "No working mail" is the state to check, not a particular variable. Mail is > configured on the board now and only *optionally* from the environment, so > `MAIL_DRIVER` being unset no longer tells you anything on its own — the mail > settings screen states what the board resolved and where it came from. Accounts stuck at *awaiting activation* can be activated by hand from their member screen under **Admin → Members**, and anybody who never received a link can ask for another at `/verify/resend`. The CLI and the installer are deliberately unaffected: `community user:create` and the founding administrator are still created active, because an operator at a terminal cannot follow a link in somebody else's mailbox, and an unactivatable first administrator is a board with no way in. ### The password and username rules now come from the settings screen `registration.min_password_length`, `registration.username_min` and `registration.username_max` were registered settings with no reader either — every one of them served from a constant, so the fields moved and the registration form went on enforcing 8, 3 and 30. They are read now, by the board **and by `community user:create`**, which matters more than it sounds: a CLI that enforced different rules is a way to create accounts the board itself would have rejected. The registry defaults are 10, 3 and 30. A board that never touched them gets a **minimum password length of 10 rather than 8** — the one change here that can surprise somebody, and it applies to new passwords only. Existing passwords are untouched and no one is locked out; they rehash on next login regardless. > [!NOTE] > A minimum username length above the maximum is impossible to satisfy, so it is > ignored rather than enforced: both fall back to the built-in 3 and 30, and the > board keeps registering people. Fix the pair on the settings screen. ## What the CLI applies `community upgrade` applies **core migrations, then each installed plugin's, then records the version** — the three steps it prints. It reads the plugin list from your board's `community.plugins.ts`, which is compiled into the command when the image is built, so there is no separate entry point to remember and nothing to point it at. > [!NOTE] > This was not true before, and three places said it was. The command passed no > plugins at all, so a board could be told by the panel to run it and be no > further on afterwards. If you have been running a plugin whose migrations the > panel reported as pending, run `community upgrade` once more — it is safe to > repeat, since applying a migration and recording it happen in one transaction > and a re-run of an applied one is a no-op. A plugin listed with `enabled: false` is skipped: creating tables for code that will not run leaves your schema ahead of your board, which is the state the panel's refusal to offer a migrate button exists to prevent. This is a real limitation rather than an oversight, and it is written down here because discovering it during an upgrade is the wrong moment. --- <!-- docs/performance.md · Running a board --> # Performance <!-- GENERATED FILE — do not edit. Budgets come from packages/testkit/src/load/budgets.ts, which the load runner enforces. Measurements come from docs/perf-results.json, written by `pnpm perf measure --record`. Regenerate with `pnpm perf:docs`; `pnpm verify` fails when this is stale. --> The p95 budgets for the pages a board’s traffic actually goes to, and what the last recorded run measured against a full-scale board. ## The board these numbers came from | | | |---|---| | Posts | 2,343,847 | | Threads | 100,030 | | Longest thread | 14,741 posts | | Visibility | 23,438 deleted, 23,438 unapproved, 2,296,971 visible | | Iterations | 60 per scenario, 8 discarded | | Machine | 4× Intel(R) Xeon(R) Processor @ 2.80GHz, 16 GB | | Runtime | Node v22.22.2 on linux-x64 | | Measured | 2026-08-04 | The absolute numbers belong to that machine. What travels between machines is the **shape**: which scenarios sit near their budget, and whether a deep page costs more than a first page. Compare ratios, not milliseconds. ## Budgets and measurements | Page | Budget | | Measured p95 | p50 | p99 | Used | |---|---:|---|---:|---:|---:|---:| | Thread, page 1 | 50 ms | target | 3.3 ms | 1.8 ms | 10.3 ms | 7% | | Thread, deep page | 60 ms | target | 4.7 ms | 3.6 ms | 7.1 ms | 8% | | Forum, page 1 | 50 ms | target | 6.2 ms | 4.7 ms | 8.4 ms | 12% | | Forum, deep page | 60 ms | target | 5.0 ms | 3.6 ms | 6.5 ms | 8% | | Board index | 80 ms | target | 1.6 ms | 1.2 ms | 3.1 ms | 2% | | Permission filter | 40 ms | target | 5.9 ms | 3.6 ms | 6.6 ms | 15% | | Latest threads | 150 ms | target | 44.3 ms | 31.7 ms | 47.6 ms | 30% | | Search, near-universal term | 300 ms | target | 95.2 ms | 85.3 ms | 110.9 ms | 32% | | Search, rare term | 200 ms | target | 35.3 ms | 15.3 ms | 37.3 ms | 18% | | Member profile | 60 ms | target | 1.8 ms | 1.3 ms | 3.4 ms | 3% | ## Partial visible indexes `EXPLAIN` evidence that the partial `visibility` indexes are actually used. This is that evidence, and it is also a **check**: `pnpm perf explain` fails when the planner stops choosing one. That failure is the one worth guarding. A partial index only matches a query whose predicate the planner can prove implies it, so a read path that starts passing a variable visibility scope where it passed a literal falls silently onto a sequential scan of the largest table on the board. Nothing errors. | Page | Index | Used | Warm | |---|---|---|---:| | Forum listing, as a member | `threads_forum_listing_idx` | yes | 2.7 ms | | Forum listing, as a moderator | `threads_forum_listing_all_idx` | yes | 2.9 ms | | Thread page, as a member | `posts_thread_visible_idx` | yes | 0.0 ms | | Thread page, as a moderator | `posts_thread_all_idx` | yes | 0.0 ms | | Moderation queue | `posts_forum_visibility_idx` | yes | 1.2 ms | Each partial index has an unfiltered twin, and the twins are checked too. A moderator seeing unapproved and deleted content *cannot* use the partial index — their predicate does not imply it — so without the twin their forum view is a sequential scan. That failure is invisible to every test written from a member’s point of view, which is most of them. ## What each scenario is and why it is measured ### Thread, page 1 `thread-page-first` — listThread(limit 20) on a long thread. The single most requested page on any forum. Everything else is rounding. ### Thread, deep page `thread-page-deep` — listThread(afterId) far into a long thread. The keyset claim. Under OFFSET this degrades with depth; it must not. ### Forum, page 1 `forum-page-first` — listForum(limit 20) on the busiest forum. Sticky-first ordering over the largest thread set on the board. ### Forum, deep page `forum-page-deep` — listForum(after cursor) deep into the busiest forum. Same keyset claim on the other axis, and the one an archive crawler hits. ### Board index `board-index` — listListing() — every forum with its counters and last post. One query for the whole tree, and the page every visitor lands on. ### Permission filter `visible-forums` — forumIdsWhere(actor, thread.view). Every list page pays this before it reads anything, so its cost multiplies. ### Latest threads `discovery-latest` — Discovery page 1, scoped to visible forums. Ordered across the whole board rather than within one forum — the widest scan, and the most run-to-run variance of anything here. It was budgeted at 80ms against a typical p95 near 50, which is 1.6× and breaks the 2–3× rule stated at the top of this file; it duly went red on a noisy run at 110ms with a 621ms outlier. Raised to 150ms — not to make it pass, but because the original number was set tighter than the methodology the rest of the table follows. ### Search, near-universal term `search-common` — Relevance search for a term matching 96% of the board. The worst query a member can trigger, and the one budget the first load run failed. Relevance ordering is not indexable: `ts_rank_cd` has to score every matching row before it can name the top twenty, so a term matching 2.26M of 2.34M posts cost a p95 of 5.5 seconds with the GIN index present and used. The fix was to bound the ranked set to the most recent 20,000 matches, which measured 98ms — and changes nothing for any term selective enough that the window holds the whole match set, which is every real query. Recorded in mybb-parity.md. ### Search, rare term `search-rare` — Full-text search for a term with ~1,000 matches. Separated because a fast rare-term search hides a slow common-term one, and here it did: before the window bound these two differed by a factor of 130, and only the pair made it visible that the cost was the match count rather than the code. They still differ, by about 5×, which is the residual and expected shape. ### Member profile `member-profile` — Profile with counters for a prolific member. A post count computed live is an aggregate over the member's whole history. --- <!-- docs/demo-mode.md · Running a board --> # Demo mode A public board with its password printed on it, seeded with content, that deletes everything and rebuilds itself on a timer. It is what runs at [demo.meith.dev](https://demo.meith.dev). This is not a lighter board or a read-only preview. It is the whole board — posting, moderation, the admin panel, search, the background tick — with the outbound surfaces disarmed, because on a demo everybody who visits is an administrator. > [!WARNING] > Never set `DEMO_MODE` on a board with real members. The reset drops every > table in the database. On a demo that is the feature; anywhere else it is the > end of the board. ## Turning it on ```sh DEMO_MODE=1 DEMO_RESET_MINUTES=60 # 5–1440, default 60 ``` `DEMO_MODE` requires `DATA_SOURCE=postgres`, and the board refuses to boot without it. A demo whose visitors cannot post is a screenshot, and [fixture mode](./development.md#fixture-mode-and-why-it-exists) has no write side to offer them. ## What a visitor gets Three logins, printed in a strip at the top of every page: | Username | Password | What it demonstrates | |---|---|---| | `admin` | `admin` | The whole admin panel: settings, permissions, themes, plugins, tasks. | | `moderator` | `moderator` | The moderation queue, reports, warnings — without the admin panel. | | `member` | `member` | An ordinary account, and everything an ordinary account cannot do. | `admin` is five characters and the board's own policy wants eight. The seed writes the hash directly rather than going through registration, so the policy stays honest for every account created *after* the seed — including the one a visitor makes themselves. The board they land on has eight forums in three categories, twenty-two threads carrying eighty-nine posts, and sixteen members with join dates spread over two years. There is a poll with votes in it, a sticky, a locked thread, private messages in the administrator's inbox, a post held in the moderation queue and an open report against another that got through. An empty ModCP demonstrates nothing, so it is not empty. Every timestamp is an **offset from the reset**, not a date. The newest post is always minutes old and the board is always six hundred days into its life, whenever you happen to visit. ## What demo mode changes Each of these closes a hole that only exists because the administrator password is published. | Guard | Why | |---|---| | **Mail is pinned to nowhere.** Resolved before the environment and before the settings table. | `MAIL_DRIVER=log` falls through to the settings table, which on a demo is written by whoever visited last. Without this, a visitor fills in the SMTP screen and the host is an open relay. | | **Webhook delivery is never registered.** The task does not exist, rather than existing and refusing. | A visitor can point a webhook at any address that resolves from the host, and the board would make the request for them, on a schedule. | | **Login lockout is relaxed** to 50 attempts and a one-minute lockout. | The counter is per account. One visitor mistyping `admin` five times would lock the published login for everybody else for a quarter of an hour. Relaxed, not removed — it is still a login form on the open internet. | | **The published logins cannot change password, email or username.** Everything else about them is fair game. | All three lock the next visitor out. Renaming `admin` leaves the banner naming a login that no longer exists. | | **`robots.txt` disallows everything.** | Half of what a crawler stored is a 404 within the hour, and the other half is whatever an anonymous visitor typed on a board carrying the project's domain. | Nothing else is held back. A visitor can delete every forum, rewrite the permission matrix, switch themes, ban the moderator and turn the board off. All of it is undone by the next reset, and watching someone do it is a better demonstration than a disabled button. ## The reset `demo.reset` is a scheduled task, registered only when `DEMO_MODE` is set, and only in the web server's task list. It drops the schema, replays the migrations, writes the board back, clears the uploads directory and invalidates the cache. Ten seconds or so, during which the board is genuinely unavailable — the tables are not there. **The schema is dropped rather than truncated**, and that is the whole design. Truncating leaves behind everything the migrations seeded — the usergroup ladder, the warning types, the permission defaults — and those are exactly the rows a visiting administrator can wreck. A reset that cannot restore the guest group's permissions cannot fix the most likely thing to need fixing. By hand, or from a cron of your own: ```sh community demo:reset --yes # drop, migrate, seed community demo:seed # seed an already-migrated empty database ``` Both refuse to run unless `DEMO_MODE` is set. ### Why the demo runs no worker The reset has to clear the web server's cache, and the cache is a map in the web server's own process. A reset run by the worker would leave the web server serving the forum tree of a board that no longer exists, for up to the tree's 60-second TTL, on the one page every visitor lands on. So the demo drives the tick against the web server instead — a `ticker` service calling `/api/system/tick` every minute — and every task runs in the process that can see the consequences. That is why `docker-compose.demo.coolify.yml` has no `worker` service, and why it is not the ordinary compose file with a flag added. ## Deploying one [`docker-compose.demo.coolify.yml`](../docker-compose.demo.coolify.yml) is a third Coolify resource beside the board and the site, from the same repository. Point Coolify at it, give it a domain, and it generates the secrets and the database password itself. It differs from `docker-compose.coolify.yml` in three ways, all of them the flag's doing: no worker (above), no volumes (a redeploy should be as clean as a reset), and a `seed` one-shot in place of `migrate` — running the same `demo:reset` the hourly task runs, so the board a visitor finds one minute after a deploy is the board they would find one minute after any reset. ## What it costs you A public board where anyone can post, on a subdomain of yours. The reset bounds how long anything stays up, `robots.txt` keeps it out of search, and no mail or webhook can leave the host — but for the length of one reset interval, the content on that domain is whatever the internet typed. Pick `DEMO_RESET_MINUTES` with that in mind rather than with the seed's size in mind: the reset is cheap. One thing works in your favour, and it is the shipped default rather than anything demo mode does: the `guests` group can read, search and download, and cannot post. The demo leaves that alone. Posting needs one of the published logins, and spam bots do not log in. --- <!-- docs/self-hosting.md · Advanced deployment --> # Deploying by hand **Advanced.** The [Quickstart](./quickstart.md) deploys this board with [Coolify](https://coolify.io) and is the route most people should take: same image, same four containers, same environment contract, and it issues the certificate and generates the secrets for you. This page is the same board without the panel — the compose file, a `.env` you write, and a reverse proxy you already run. Take it if: - **you already run a proxy** (nginx, Traefik, Caddy) and would rather add one vhost than a second thing that wants ports 80 and 443; - **you want no extra moving parts.** Coolify is a daemon, a database and a proxy of its own, which is a fair price for what it does and not free; - **the machine is too small for it.** Coolify wants ~2 GB to itself; - **you are deploying into something else** — an existing Swarm, a Nomad job, a CI pipeline that already builds images. What you give up: certificates, secret generation, the redeploy button, and the scheduled database backup. All four are things you now do yourself, and the first is the one people underestimate. ## What you need | | | |---|---| | **A server** | Your own, anywhere. 2 GB RAM, 2 vCPU, 20 GB disk. Ubuntu 24.04 LTS below; any distro Docker runs on is fine. | | **A domain** | With an `A` record pointing at the server, before you start — the certificate step needs it resolving. | | **Half an hour** | And a terminal. | 1 GB works for a quiet board and is tight during the build. If that is what you have, [build the image elsewhere](#building-somewhere-else) and pull it. ## 1. Prepare the server SSH in as root, make a user, and give it Docker: ```sh adduser meith usermod -aG sudo meith ``` Install Docker from Docker's own repository rather than the distro's — the packaged version is usually old enough to be missing `docker compose`: ```sh curl -fsSL https://get.docker.com | sh usermod -aG docker meith ``` Then close the machine off. Everything a visitor needs is 80 and 443; the board itself is never exposed directly. ```sh ufw default deny incoming ufw allow OpenSSH ufw allow 80,443/tcp ufw enable ``` Log out and back in as `meith` — group membership only takes effect on a new session, and `docker ps` failing with a permissions error at this point is almost always that. ## 2. Get the board ```sh git clone https://github.com/meith-dev/meith.git cd meith ``` A clone rather than a release tarball, because upgrading is `git pull` and a rebuild, and because [`docker-compose.yml`](../docker-compose.yml) is a file you are meant to read and edit. ## 3. Write the environment The compose file reads `.env` from beside it. Nothing in it belongs in git — `.gitignore` already covers it. ```sh cat > .env <<EOF POSTGRES_PASSWORD=$(openssl rand -hex 32) AUTH_SECRET=$(openssl rand -base64 32) TICK_SECRET=$(openssl rand -base64 32) PORT=127.0.0.1:3000 EOF chmod 600 .env ``` **Nothing in that needs editing.** Three `openssl rand` outputs and a fixed literal — you can paste it as it stands, which is the point: the board's own address used to be a fifth line here and is asked for by the installer now, prefilled from the address you load it at. > [!WARNING] > If you add `APP_URL` back, it has to be your real origin. A value set here > **wins over the installer**, so a placeholder left in — `https://board.example` > — is not a value the installer will correct. It is the origin every password > reset and confirmation link is built from, and those links go out pointing at a > domain you do not own. Leaving it out is safer than filling it in with > something approximate. > [!NOTE] > `hex` for the database password and `base64` for the two secrets, and the > difference is not stylistic. The password is substituted into a > `postgres://forum:…@postgres:5432/forum` URL, and base64's alphabet includes > `/` and `+` — so about one password in three produces `TypeError: Invalid > URL` from the migration and a stack trace that says nothing about passwords. > Hex has no such characters. The two secrets are never part of a URL. Rerunning that heredoc rewrites all four values. If the board is already installed, changing `POSTGRES_PASSWORD` locks it out of its own database — Postgres keeps the password from when the volume was created. Four lines, and each one matters: | | | |---|---| | `POSTGRES_PASSWORD` | The database's own password. Generated, never typed, and hex — see the note above. | | `AUTH_SECRET` | Signs unsubscribe links in outgoing mail. Sessions are not derived from it — they are random tokens stored hashed in the database. There is deliberately no default — a shipped one is a link every reader of the source can forge. | | `TICK_SECRET` | Guards `/api/system/tick`, which is publicly routable. | | `PORT` | **`127.0.0.1:3000`, not `3000`.** Binding to all interfaces publishes the board on port 3000 alongside your HTTPS one — plaintext, no certificate, and Docker writes its own iptables rules, so `ufw` does not stop it. | Rotating `AUTH_SECRET` later signs nobody out — sessions do not depend on it. What it does break is the unsubscribe link in every message already sent, which then answers with a polite failure rather than unsubscribing anybody. That is the whole consequence; it is a safe thing to do if you think it leaked. [`.env.example`](../.env.example) at the repository root documents every other variable, including `APP_URL` and the `MAIL_*` set — both optional, both overriding the board's own settings when present, and both worth setting here if you would rather this deployment were configured entirely from files than from a screen. ## 4. Start it ```sh docker compose up -d --build ``` The first build takes five to ten minutes. Four containers come up in order: | Container | What it does | |---|---| | `postgres` | The board. A named volume, so recreating the container keeps the data. | | `migrate` | Applies the schema and **exits 0**. `web` and `worker` wait for it, so the code never talks to a schema behind it. | | `web` | Next.js, on `127.0.0.1:3000`. | | `worker` | The tick, in-process, on its own one-minute loop. | Check all four: ```sh docker compose ps docker compose logs -f worker ``` `migrate` showing `Exited (0)` is correct and is what the other two waited for. The worker should log `worker started` **once**, and then nothing much — if that line repeats every few seconds, the container is crash-looping and the log above it says why. ## 5. Put a proxy in front Nothing in the compose file terminates TLS. Caddy, because it gets a certificate and renews it without being asked. On the host, not in the compose file: ```sh sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key \ | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \ | sudo tee /etc/apt/sources.list.d/caddy-stable.list sudo apt update && sudo apt install -y caddy ``` `/etc/caddy/Caddyfile`, in its entirety: ```caddyfile board.example { reverse_proxy 127.0.0.1:3000 request_body { max_size 25MB } } ``` ```sh sudo systemctl reload caddy ``` `max_size` has to be at least your largest allowed attachment, or the upload fails at the proxy with a 413 the board never sees and cannot explain. Prefer nginx? The equivalent is a `proxy_pass` to `127.0.0.1:3000` with `client_max_body_size 25m`, `proxy_set_header X-Forwarded-Proto https`, and certbot for the certificate. Nothing about the board cares which you pick. ## 6. Install it Open `https://board.example/install` — your domain, over the proxy you just set up, not `127.0.0.1:3000`. The address you load it at is the one it will offer to keep, so loading it the way your members will is the point. The form is three numbered sections. **Your board** asks for a name and — unlike the Coolify route — **the board's address**; **your account** asks for a username, an e-mail and a password; **sending mail** is optional. Your username is the name you post under, not a role, and the obvious ones are reserved so that no account can impersonate the board — see [Quickstart § Run the installer](./quickstart.md#4-run-the-installer). The form lists them under the box. **The address is prefilled from the URL you loaded**, because that is almost always right and because nothing else on the form is as easy to get subtly wrong. Check it before you submit: it is the origin every password-reset and confirmation link is built from, and it is stored as you confirm it. This is the one difference from the Coolify route, where the panel supplies `APP_URL` and the installer does not ask. > [!TIP] > If you set `APP_URL` in `.env` after all, the field is not shown and the > environment's value is used. That is the right choice when you would rather > this deployment were configured from files — and the wrong one if the value is > a placeholder, since nothing will correct it. Fill in mail here too. It is a list of providers rather than a page of server details — pick the one you have and the host, the port and the TLS mode come with it — and it sends a **test message to your address before the first migration**, installing nothing if that fails. A wrong key costs a retry rather than a sealed board that cannot e-mail anybody. Everything else about the installer — the preflight report, the five steps, the sealing that cannot be undone — is the same on both routes and written once: - **[Quickstart § Run the installer](./quickstart.md#4-run-the-installer)** - **[Quickstart § Mail](./quickstart.md#5-mail)** — the answer sheet for that list, provider by provider. `/admin/settings?group=mail` changes it afterwards with no redeploy; the `MAIL_*` variables in the `.env` beside this stack override both, for a deployment you would rather configure from files than from a screen. A board that uses none of the three sends no mail at all. - **[Running a board](./operating.md)** — the operator handbook, and everything from the day after you install: backups, the operator CLI, upgrades, permissions, spam, and the failures that actually happen. ## Upgrading ```sh cd ~/meith git pull docker compose up -d --build ``` `migrate` runs first and the others wait for it, so the schema is never behind the code. **Take a backup first** — see [Backup and restore](./operating.md#backup-and-restore). Migrations are forward-only, recovery is by restore, and there is no down migration to undo a destructive one. [Upgrading a board](./upgrading.md) covers how far you can jump in one go and what to do when a migration fails halfway. ## Building somewhere else On a 1 GB server the Next build can run out of memory. Build the image on your laptop or in CI, push it to a registry, and replace `build: .` with `image: your-registry/meith:latest` in the compose file. Everything else is unchanged. The image takes `COMMUNITY_ROLE` — `web`, `worker` or `migrate` — so one image is all three services. That is what makes the roles impossible to drift apart, and it is why there is no second Dockerfile. ## Running the tick without a second set of credentials The `worker` service holds database credentials, which some operators would rather only the web server did. The compose file ships an alternative behind a profile: a small container that calls `/api/system/tick` over HTTP once a minute, presenting `TICK_SECRET`. ```sh docker compose --profile curl-tick up -d ``` Enable that **or** `worker`, never both. Running both is harmless — a task claims its work in the database, so concurrent ticks are safe — but it is two things doing one job. ## When it goes wrong | What you see | What it is | |---|---| | `AUTH_SECRET must be set`, before any container starts | Compose itself refusing to interpolate: `.env` is not beside the compose file, or you ran `docker compose` from another directory. | | `TypeError: Invalid URL` from `migrate` | A `/` or `+` in `POSTGRES_PASSWORD`. Generate it with `openssl rand -hex 32`. | | `migrate` exits non-zero | Read its log. A failed migration stops the stack on purpose rather than serving against a half-applied schema. | | Worker logs `worker started` every few seconds | It is crash-looping. `docker compose logs worker` shows the throw above each restart. | | 502 from the proxy | The web container is not up, or `PORT` is not `127.0.0.1:3000`. `curl -I http://127.0.0.1:3000/api/health` on the host settles which. | | 413 on an upload | The proxy's body limit, not the board's. See `max_size` above. | | Uploads vanish after a redeploy | The `uploads` volume is not mounted. `docker volume ls` and `docker compose config` will show it. | | The board is reachable on `:3000` as well as `:443` | `PORT` is `3000` rather than `127.0.0.1:3000`. Docker writes its own iptables rules, so `ufw` will not have stopped it. | | Password reset says "check your inbox" and nothing arrives | Mail is not configured, or the provider is refusing it. `/admin/settings?group=mail` → **Send a test message to me** answers which in one click, and prints the provider's own refusal. | | Mail arrives, but its links point at the wrong host | The board's address is wrong. If `APP_URL` is in `.env` it wins — fix it there and redeploy; otherwise fix **Board address** under `/admin/settings?group=board`. | [Running a board § Troubleshooting](./operating.md#troubleshooting) covers the failures that are about the board rather than about the deployment. ## What you are taking on Worth being plain about, because this is the route with no panel behind it: - **Backups are yours.** Nobody else is taking one. Both the database and the uploads volume — see [Backup and restore](./operating.md#backup-and-restore). - **Certificates are yours.** Caddy makes this a solved problem, but it is a problem you now own. - **Security updates are yours.** `unattended-upgrades` for the host, and a `git pull` and rebuild for the board. - **Uptime is yours.** `restart: unless-stopped` covers a crash and a reboot; it does not cover a disk filling up. In exchange: no platform limits, no per-seat pricing, no vendor reading your members' posts, and a board you can move to another machine with a `pg_dump` and a `tar`. ## Why not serverless The question comes up, so: a board needs a process that outlives a request, and that is the one thing a function cannot be given at any price. Everything else has a workaround with a bill attached. A per-minute schedule is a plan feature. A disk that survives a restart is an object store and a second vendor. But the tick is bounded by the function timeout, a large import cannot hold a function open, and migrations stop being part of the deploy — so between the code going live and you running the command, new code is talking to an old schema. You can run this board on a function, and this project does not test it, ship a configuration for it, or answer for it. Your own server does all three things by existing, which is why it is the only route documented here. --- <!-- docs/theme-api.md · Themes --> # The theme API `@meith/theme-kit` is the contract between the board and a theme, frozen since **0.1** and currently at **0.9**. This document is the policy — what the freeze covers, what it does not, and how something is removed from it. The reference (every slot, every field) is generated into [Theme slots](./theme-slots.md). ## Writing a theme A theme is a module that calls `defineTheme` with a key, a title, and a map from slot name to component. Nothing else. ```ts // themes/acme/src/theme.ts import { defineTheme } from "@meith/theme-kit" import { defaultTheme } from "@meith/theme-default" import { PostBit } from "./slots/post-bit" export const acmeTheme = defineTheme({ key: "acme", title: "Acme", extends: defaultTheme, slots: { PostBit }, }) ``` Register it in `community.config.ts` and set `defaultTheme` to its key. Two worked examples bracket the range a theme can occupy: - **[`examples/iris-theme`](https://github.com/meith-dev/meith/tree/main/examples/iris-theme) is the minimal one, and the one to copy first**: the default board recoloured by overriding one brand group of tokens, plus a single slot (`Footer`) where its markup genuinely disagrees. It ships as reference code rather than registered; [`examples/README.md`](https://github.com/meith-dev/meith/tree/main/examples) walks through installing it or your copy of it. - `themes/midnight` is the maximal one: twenty-two slots overridden, five inherited, tables where the default theme has lists, and no change to any package to make it possible. ### Four rules the tooling enforces Worth knowing before they fire. | Rule | Why | |---|---| | **Write the slot map inline, with bare identifiers.** | `scripts/slot-kinds.mjs` resolves each binding to its module to check the server/client boundary. A map assembled dynamically cannot be checked, so it fails rather than passing unchecked. | | **A server slot must not be a `"use client"` module.** | For `PostBit` that ships the whole post list to the browser. Checked statically, and again at `defineTheme` for anything the bundler marked. | | **Colours come from tokens.** | A board's operator restyles by overriding tokens; a hardcoded colour is a region they cannot reach. Guard `no-hardcoded-colour` rejects hex literals in a theme. | | **View models are plain JSON data.** | No `Date`, no functions, no class instances — the same models cross to client slots and out through the REST API. `Serialisable<T>` proves it at compile time. | ## What a theme can and cannot do **A theme may:** - Fill any slot in the registry with a component. - Inherit from another theme with `extends` and override only the slots it cares about. Resolution is shallowest-wins-per-slot, and overriding is total. - Ship its own token values, which a board's operator can then override without touching the theme. **A theme may not, and cannot:** | It cannot | Because | |---|---| | Read the database, the request, cookies or the session | `@meith/theme-kit` depends on `@meith/core` alone — no database, no request, no domain package — and dependency-cruiser makes a theme's import of `@meith/db`, a driver or a domain package an error rather than a review comment | | Decide anything about permissions | `ViewerModel.canAccessAdminCp` and its siblings are *rendering hints* the Authorizer has already resolved. CSS is not authorization — anything a viewer must not see is not in the model at all | | Build a URL | Every href arrives resolved, so the board can change its URL shape without breaking installed themes | | Render another slot | Slots are flat. The page composes them and passes rendered output in `regions` — rendering a slot needs the resolved theme, and there is no way to reach one from inside a slot | > [!IMPORTANT] > **A member can switch the whole theme, components included.** Every registered > theme is in the bundle and is resolved at module load — an `extends` chain > genuinely cannot change between requests — but *which* resolved map a request > renders is a per-request choice, made by `currentTheme()` from a cookie. > > This document used to say the opposite, on the argument that a switcher "would > cost every first paint a database read". That had quietly expired: 91 of the > board's 92 routes were already `ƒ (Dynamic)`, because the shell resolves the > viewer from a cookie. There was no static rendering left to protect. > > Consequences for a theme author: > > - **`assertThemeContract` now runs over every registered theme**, not only the > board's. An incomplete alternate used to be a latent 500 on whatever page > reached its missing slot; now that a member can pick it, it is a boot > failure naming the slots. > - **A theme that fills no slots is a palette**, and that is a supported shape > rather than a broken one: picking it repaints the board and leaves the > markup to the build's theme. It is how a board offers three looks without > maintaining three sets of components. > - **Pairing rules matter more.** `midnight`'s note about overriding > `ForumRow` and `CategoryBlock` together is now a rule a *member* can trip > over, not only an operator. ## What the freeze covers | Covered | Not covered | |---|---| | The name and `kind` of every **stable** slot | The two **provisional** slots (`QuickReply`, `EditorToolbar`) | | The fields of the model a stable slot is handed | Fields of a provisional slot's model | | `defineTheme`, `resolveTheme`, `requireSlot`, `hasSlot`, `assertComplete`, `assertThemeContract`, `checkThemeContract` | Anything not re-exported from `packages/theme-kit/src/index.ts` | | `SLOTS`, `SLOT_NAMES`, `SLOT_STABILITY`, `isSlotName`, `slotKind` | The markup, class names and token *values* of the shipped themes | > [!IMPORTANT] > The last row is worth reading twice. `themes/default` is a reference > implementation, not an API. A theme that extends it inherits its markup and > therefore its changes. Copying it is supported and inheriting is better, but > neither makes its DOM a promise. ### Provisional slots `QuickReply` and `EditorToolbar` are the editor islands. They are named in the registry so the slot list is not retrofitted onto finished pages later, and they are excluded from the freeze because no page has ever handed their models to a component — freezing a props contract that has never been rendered is guessing with a version number attached. A theme is not required to fill them. `assertThemeContract` does not ask for them, and `resolveTheme(...).missing` reporting both is the normal state of a complete theme today. ## Versioning `THEME_API_VERSION` is `major.minor`, and both halves are promises. | Bump | What may land | What it costs you | |---|---|---| | **minor** | Additive only: a new slot, a new optional model field, a new export | Nothing. Every existing theme keeps working; upgrading is a redeploy | | **major** | Removals and renames — but only for things scheduled through `DEPRECATIONS` at least one major earlier | Work you were warned about | There is no patch component. This is a type-level contract with no runtime behaviour of its own; a bug fixed in `resolveTheme` is a package version. > [!NOTE] > **The major is `0`, and the freeze is still real.** Meith has not been > released, so nothing here has ever been somebody else's dependency and there is > no installed board for a rename to break. Every rule in this document is > enforced by code in `packages/theme-kit/src/api.ts` and has been since the > freeze — but the major those rules count toward is `1.0`, which ships with the > product rather than ahead of it. Practically: write a theme against `0.9` and a > later `0.10` will not break it, because a minor is additive whatever the major > says. > [!NOTE] > Adding a **required** field to an existing model is a breaking change even > though nothing is removed — the app is the only producer of these models, and a > theme cannot fail to supply one. In practice new fields are added as optional > and themes ignore them until they want them. ## Deprecation No *slot* is deprecated. One field is: `PostBitModel.quoteSource`, deprecated in 0.5 and scheduled out at 1.0 in favour of `PostBitModel.post.id` — the first entry through the machinery below, which is machinery rather than prose. 1. **Mark and schedule.** The slot is marked `deprecated` in `SLOT_STABILITY`, and an entry is added to `DEPRECATIONS` naming when it was deprecated, which major removes it, what replaces it, and why. Both halves are required: `assertDeprecationPolicy` refuses a mark with no schedule and a schedule with no mark. 2. **It keeps working.** A deprecated slot is still *required* of a theme, because a page still renders it in this version. A theme that drops it early has a hole in it. 3. **It is reported.** `checkThemeContract` lists a deprecated *slot* a theme still fills in `deprecatedInUse`, so the admin theme screen and a theme's own CI test can both see it coming. A deprecated field is visible in the type and the generated reference instead — no runtime report can tell whether a theme reads a prop. 4. **It is removed at the scheduled major** — and if it is not, the build fails. `assertDeprecationPolicy` throws once the current version reaches `removeIn`. Step 4 is the reason to trust the schedule: a deadline that can pass quietly is how a deprecation becomes permanent. A field is scheduled the same way, as `Model.field` — `quoteSource` above is the live example. A whole model is never deprecated on its own — a model exists because a slot is handed it, so removing the slot *is* the deprecation. ## Tokens A theme ships `LIGHT_TOKENS` and `DARK_TOKENS` using the same **names** the default theme declares. `globals.css` maps each name to a Tailwind utility, so a renamed token is a utility pointing at nothing. The values are the theme's own. Only the default theme's values are compiled into the stylesheet. Any other theme's palette is emitted into `<head>` as the *difference* from that baseline — so a board on the default theme pays nothing for the mechanism, and a board on any other theme gets its colours without redeploying the CSS. The cascade, in order: ```text compiled defaults (globals.css) → the board default theme's values + its overrides + its custom CSS :root / .dark → each other enabled theme's difference from that [data-theme="<key>"] ``` A board with one enabled theme emits exactly the first two lines, byte for byte what it emitted before members could switch. The scoped blocks carry only what a theme *disagrees with the board default about* — not its difference from the stylesheet — because the unscoped block is still in force when `data-theme` names another theme. Diffing against the wrong side is the bug that leaks one theme's brand colour into another's palette with nothing failing anywhere. `themes.token_overrides` is keyed by colour scheme: ```json { "light": { "primary": "#1d4ed8" }, "dark": { "primary": "#93c5fd" } } ``` A flat `{ "primary": "…" }` map is still read, and means both schemes — that is what every row written before this shape existed holds, and what an exported version 1 document carries. `BROWSER_THEME_COLOR` is the one place a literal colour belongs in a theme: `<meta name="theme-color">` is ignored by Safari and older Chrome when given `oklch()`. Keep it equal to the two `background` tokens converted — there is a test for that, because a hand-written pair goes stale silently. ### The default theme's palette is neutral on purpose Every greyscale token the default theme ships is at **chroma zero** — the one colour in the palette is `primary`, the green the project's own site uses. A board brands itself by overriding one group — `primary`, `primary-hover`, `primary-foreground`, `ring`, or a single press of a brand preset on the theme screen — and nothing else fights the result, because nothing else in the palette carries a hue to clash with. Two consequences worth knowing before you write a theme or a plugin: - **`accent` is a hover surface, not a highlight.** It carries shadcn/ui's meaning here. Anything that needs to shout uses a semantic token, which has a meaning to justify the volume. - **Link text is weight and an underline; only the underline takes `primary`.** Colouring the text itself would put every operator's brand choice between their members and the words — a pale yellow `primary` should cost a pale yellow underline, not a page nobody can read. Neither is a rule the contract enforces. A theme is free to disagree; it should disagree deliberately. ## Components: `@meith/ui` `@meith/ui` is shadcn/ui's component vocabulary implemented on **Base UI** (`@base-ui/react`), and it is available to a theme — the shipped default theme is built out of it. The package is split by rendering cost rather than by category, and that split is the thing to understand before importing from it: | Import | What it is | |---|---| | `@meith/ui` | Everything that renders on the **server**: `Card`, `Badge`, `Alert`, `Avatar`, `Field`, `Input`, `NativeSelect`, `Separator`, `Empty`, `Disclosure`, plus the `buttonVariants` and `badgeVariants` class recipes | | `@meith/ui/button` | The Base UI `Button` — a `"use client"` island | | `@meith/ui/menu` | The Base UI `Menu` — the other `"use client"` island | Nothing reachable from the barrel declares `"use client"`, which is what makes it safe in a server slot. `PostBit` is rendered fifty times on a thread page, and a design system that pulled a client boundary in behind a `<Card>` would cost the board the property the slot registry exists to protect. That is also why `buttonVariants` is a separate module from `Button`. Almost every button on a forum is not a button: "New thread" is a link, "Mark read" is a native form submit. Both want the class recipe on a plain element — ```tsx <a href={newThreadHref} className={buttonVariants({ variant: 'primary' })}> New thread </a> ``` — and get the same appearance for no bytes. Reach for `@meith/ui/button` when the control genuinely lives in an island. A theme is not required to use any of this. `@meith/theme-kit` remains the only dependency a theme *needs*, and a theme that wants its own markup from scratch (as `themes/midnight` largely does) is a supported thing to be. ## Testing a theme `apps/community/src/theme/contract.test.ts` renders **every theme registered in `community.config.ts`** through every stable slot with the same fixture models, and asserts the properties that are true of any theme: - Required slots are filled. - Each one renders. - The values a reader is owed appear in the output. - Nothing renders `[object Object]`, `undefined`, or an empty `href`. - No server slot emits a script. Registering a theme enrols it. There is no list to add yourself to, and none to forget. **It deliberately does not assert appearance.** A theme is free to be a table, a card grid or a wall of text. A suite that required matching the default theme's markup would make the second theme's job "look like the first", which is the opposite of the point. ## The generated reference is a gate [Theme slots](./theme-slots.md) is written by `scripts/theme-api-docs.mjs` from the three source files that *are* the contract. `pnpm verify` and CI run `pnpm theme:docs:check`, which fails when the file and the code disagree. The consequence is deliberate: you cannot change the theme contract without the documentation change appearing in the same diff — which is exactly when a reviewer should be asked whether the change is allowed at all. If the check fails, run `pnpm theme:docs` and commit the result. --- <!-- docs/theme-slots.md · Themes --> # Theme slots and view models <!-- GENERATED FILE — do not edit. Written by scripts/theme-api-docs.mjs from packages/theme-kit/src/{slots,api, view-models}.ts. Run `pnpm theme:docs` after changing any of them; `pnpm verify` and CI run `pnpm theme:docs:check` and fail when this file and the code disagree. --> **theme-kit v0.9.** 29 slots: 27 stable, 2 provisional, 0 deprecated. What the marks mean, and how something is removed, is in [`theme-api.md`](./theme-api.md). In short: a **stable** slot and the fields of its model do not change before the next major; a **provisional** slot is named but not yet rendered by any page, so its model may change in a minor release; a **deprecated** slot still works and has a removal scheduled below. ## Every slot | Slot | Kind | Stability | Props | |---|---|---|---| | [`Shell`](#shell) | `server` | stable | `ShellModel` | | [`Header`](#header) | `server` | stable | `HeaderModel` | | [`UserPanel`](#userpanel) | `server` | stable | `UserPanelModel` | | [`Navigation`](#navigation) | `server` | stable | `NavigationModel` | | [`Footer`](#footer) | `server` | stable | `FooterModel` | | [`Notice`](#notice) | `server` | stable | `NoticeModel` | | [`Announcement`](#announcement) | `server` | stable | `AnnouncementModel` | | [`BoardIndex`](#boardindex) | `server` | stable | `BoardIndexModel` | | [`CategoryBlock`](#categoryblock) | `server` | stable | `CategoryBlockModel` | | [`ForumRow`](#forumrow) | `server` | stable | `ForumRowSlotModel` | | [`BoardStats`](#boardstats) | `server` | stable | `BoardStatsModel` | | [`WhoIsOnline`](#whoisonline) | `server` | stable | `WhoIsOnlineModel` | | [`LatestThreads`](#latestthreads) | `server` | stable | `LatestThreadsModel` | | [`LatestPosts`](#latestposts) | `server` | stable | `LatestPostsModel` | | [`ForumDisplay`](#forumdisplay) | `server` | stable | `ForumDisplayModel` | | [`ThreadRow`](#threadrow) | `server` | stable | `ThreadRowSlotModel` | | [`SubforumList`](#subforumlist) | `server` | stable | `SubforumListModel` | | [`Pagination`](#pagination) | `server` | stable | `PaginationModel` | | [`ThreadView`](#threadview) | `server` | stable | `ThreadViewModel` | | [`PostBit`](#postbit) | `server` | stable | `PostBitSlotModel` | | [`PostActions`](#postactions) | `server` | stable | `PostActionsSlotModel` | | [`QuickReply`](#quickreply) | `client` | provisional | `QuickReplyModel` | | [`PostForm`](#postform) | `server` | stable | `PostFormModel` | | [`EditorToolbar`](#editortoolbar) | `client` | provisional | `EditorToolbarModel` | | [`MemberProfile`](#memberprofile) | `server` | stable | `MemberProfileModel` | | [`SearchForm`](#searchform) | `server` | stable | `SearchFormModel` | | [`ForumJump`](#forumjump) | `server` | stable | `ForumJumpModel` | | [`RedirectNotice`](#redirectnotice) | `server` | stable | `RedirectNoticeModel` | | [`ErrorNotice`](#errornotice) | `server` | stable | `ErrorNoticeModel` | ## Slot reference ### Shell `server` · stable The outermost frame: skip link, header, main landmark, footer. Wraps every page including the error pages. Props: `ShellModel` | Field | Type | Notes | |---|---|---| | `boardTitle` | `string` | | | `viewer` | `ViewerModel` | | | `children` | `ReactNode` | optional | ### Header `server` · stable Board title or logo, and the region the user panel sits in. Props: `HeaderModel` | Field | Type | Notes | |---|---|---| | `boardTitle` | `string` | | | `homeHref` | `string` | | | `viewer` | `ViewerModel` | | | `navigation` | `readonly LinkModel[]` | | | `logo` | `LogoModel \| undefined` | optional — The board's logo, when it has one. A theme that ignores this renders the board's name and is still correct — which is what makes the field additive rather than breaking. A theme that uses it should keep the name as the link's accessible content when there is no logo, because the header is the only link home on most pages. | | `children` | `ReactNode` | optional | ### UserPanel `server` · stable Greeting and account links, or the sign-in prompt for a guest. Varies by actor, which is why no page wrapping it may be cached globally. Props: `UserPanelModel` | Field | Type | Notes | |---|---|---| | `viewer` | `ViewerModel` | | | `links` | `readonly LinkModel[]` | Sign-in / register, or account links. Resolved by the app. | | `unreadNotifications` | `number` | `0` when there is nothing to show. | | `unreadMessages` | `number` | | | `children` | `ReactNode` | optional — Account controls the app supplies — today, the log-out form. Log out cannot be a `LinkModel`: it is a POST to a Server Action, because a GET that ends a session is fired by every prefetcher and link scanner that touches the page. A Server Action reference is also not plain data and could never cross this contract, so the app renders the form and the theme decides where in the panel it sits. | ### Navigation `server` · stable The breadcrumb trail. Board → category → forum → thread. Props: `NavigationModel` | Field | Type | Notes | |---|---|---| | `items` | `readonly LinkModel[]` | | ### Footer `server` · stable Board footer: copyright, timezone note, links. Props: `FooterModel` | Field | Type | Notes | |---|---|---| | `boardTitle` | `string` | | | `links` | `readonly LinkModel[]` | | | `timezoneLabel` | `string` | Which zone `TimeModel.label`s were formatted in, for the footer note. | | `poweredBy` | `LinkModel` | optional — What the board runs on, and where to read about it (0.8). A `LinkModel` and not a hardcoded string in each theme, for the reason every other piece of footer text is one: the app owns the words and the URL, so they are written once and a theme that wants to place the attribution somewhere else in its layout can, without owning a copy of them. Optional, which is what makes it a minor rather than a major: a theme written against 0.7 compiles and runs unchanged, and simply does not render it. The two themes in this repository do. | ### Notice `server` · stable A board-wide announcement or a flash message. Server-rendered so a notice is present in the first response, not after hydration. Props: `NoticeModel` | Field | Type | Notes | |---|---|---| | `kind` | `'info' \| 'success' \| 'warning' \| 'error'` | | | `message` | `string` | | | `dismissHref` | `string \| null` | | ### Announcement `server` · stable One announcement: a dated, authored notice shown above the forums. Distinct from Notice, which is a flash message about what the viewer just did — these are for everybody and last until they expire. Props: `AnnouncementModel` | Field | Type | Notes | |---|---|---| | `title` | `string` | | | `bodyHtml` | `string` | Trusted HTML, from `@meith/markdown`'s own renderer — the same contract as a post body, and the reason a theme inserts it rather than escaping it. | | `postedBy` | `UserRefModel \| null` | | | `postedAt` | `TimeModel` | | | `forum` | `LinkModel \| null` | The forum it belongs to, or `null` when it is board-wide. | ### BoardIndex `server` · stable The index page body: the ordered list of category blocks. Props: `BoardIndexModel` | Field | Type | Notes | |---|---|---| | `markAllReadAction` | `string \| null` | The "mark all read" target — a form target, not a client handler. | | `regions` | `{ /** One `CategoryBlock` per top-level category, already rendered. */ readonly categories: ReactNode readonly stats: ReactNode readonly online: ReactNode /** * The self-refreshing pair: newest threads and newest posts, already * rendered, or absent on a board that cannot answer either question. * * **One region rather than two, and that is the contract rather than a * convenience.** The pair is refreshed by a single round trip while the page * is open, so it arrives as one node; two regions would be two polls of the * same board for the same reason, or one poll that could only update half of * what a theme had placed. A theme places it — the default puts it at the * top of a sidebar — but does not take it apart. * * Optional, so a theme written against an earlier minor compiles and simply * does not show it. Same rule as every other region field here. */ readonly latest?: ReactNode /** * The `index.footer` region: whatever plugins contributed, already * rendered and ordered by the host. * * Optional, which is what makes this a **minor** addition under the * versioning policy — a theme written against 0.1 keeps compiling and simply * does not render plugin output. Every region field below follows the same rule. */ readonly plugins?: ReactNode /** * Live announcements, already rendered — one `Announcement` per row, * or absent when there are none. * * Optional for the same reason the plugin region is, and under the same * policy: a theme written against an earlier minor compiles and simply does * not show them. */ readonly announcements?: ReactNode }` | | ### CategoryBlock `server` · stable One top-level category and the forum rows under it. Props: `CategoryBlockModel` | Field | Type | Notes | |---|---|---| | `category` | `ForumRowModel` | | | `children` | `ReactNode` | optional | ### ForumRow `server` · stable One forum in a listing: title, description, counters, last post, subforum links. Props: `ForumRowSlotModel` | Field | Type | Notes | |---|---|---| | `forum` | `ForumRowModel` | | ### BoardStats `server` · stable Board totals and the newest member. Props: `BoardStatsModel` | Field | Type | Notes | |---|---|---| | `threadCount` | `number` | | | `postCount` | `number` | | | `memberCount` | `number` | | | `newestMember` | `UserRefModel \| null` | | | `computedAt` | `TimeModel \| null` | When the totals were last rolled up, or null before the first run. Part of the contract rather than a detail the app hides, because a theme that shows the numbers should be able to say how old they are — and "computed ten minutes ago" is the difference between a number that is stale and one that is wrong. | ### WhoIsOnline `server` · stable The online list and its record. Props: `WhoIsOnlineModel` | Field | Type | Notes | |---|---|---| | `guestCount` | `number` | | | `members` | `readonly OnlineMemberModel[]` | | | `total` | `number` | Members plus guests, as this reader is permitted to count them. | | `recordCount` | `number` | | | `recordAt` | `TimeModel \| null` | | | `fullListHref` | `string` | The full list, for a theme that shows only a summary here. | ### LatestThreads `server` · stable The newest threads on the board, for the index sidebar. Server, not client, even though the panel refreshes itself: the app polls a Server Action that renders this slot again, so the live half is one island around the region rather than a client component per panel. Props: `LatestThreadsModel` | Field | Type | Notes | |---|---|---| | `threads` | `readonly LatestThreadModel[]` | | | `capturedAt` | `TimeModel` | | ### LatestPosts `server` · stable The newest posts on the board, with an excerpt of each. Same server rendering and same refresh path as LatestThreads. Props: `LatestPostsModel` | Field | Type | Notes | |---|---|---| | `posts` | `readonly LatestPostModel[]` | | | `capturedAt` | `TimeModel` | | ### ForumDisplay `server` · stable A forum page body: subforums, thread list, pagination. Props: `ForumDisplayModel` | Field | Type | Notes | |---|---|---| | `forum` | `ForumRowModel` | | | `newThreadHref` | `string \| null` | | | `markReadAction` | `string \| null` | | | `regions` | `{ /** * Controls scoped to this forum — the thread ordering, and the follow * form for a member who may subscribe. Rendered by the route because both * carry a Server Action or a URL contract the theme does not own. * * **A theme renders this under its heading, not above it.** That placement * is the reason the field exists: these were app-rendered strips stacked * *before* `ForumDisplay`, so the first thing on a forum page was a filter * with nothing yet to say what it filtered. A control belongs after the * thing it acts on has been named. * * Optional, which is what makes it a **minor** addition under the * versioning policy — a theme written against 0.3 keeps compiling. * * Only what acts on the listing *below* it belongs here. Following the * forum is in `afterContent`, for the reason given there. */ readonly tools?: ReactNode readonly subforums: ReactNode /** One `ThreadRow` per thread. Empty-state markup is the theme's. */ readonly threads: ReactNode readonly pagination: ReactNode /** * This forum's announcements *and* the board's — an announcement being * board-wide would mean little if it appeared only on the index, which is * the page fewest people arrive on. */ readonly announcements?: ReactNode /** * Controls for somebody who has finished with the page — today, the form * that follows this forum. * * A theme renders it after the listing. "Do you want to hear about this * forum?" is a question you can only answer once you have seen what is in * it, and asked above the threads it is a panel between a reader and the * thing they came for. The ordering tabs stay at the top in `tools`, * because those act on the list underneath them. */ readonly afterContent?: ReactNode }` | | ### ThreadRow `server` · stable One thread in a listing: prefix, title, author, counters, last post. Props: `ThreadRowSlotModel` | Field | Type | Notes | |---|---|---| | `thread` | `ThreadRowModel` | | | `select` | `SelectionModel \| null` | The inline-moderation checkbox, or `null`. | ### SubforumList `server` · stable The compact list of child forums shown above a thread list. Props: `SubforumListModel` | Field | Type | Notes | |---|---|---| | `forums` | `readonly ForumRowModel[]` | | ### Pagination `server` · stable Page links. Server-rendered and href-based: paging must work with JavaScript disabled, so this can never become an island. Props: `PaginationModel` | Field | Type | Notes | |---|---|---| | `page` | `number` | | | `pageCount` | `number` | | | `pages` | `readonly { readonly page: number readonly href: string readonly isCurrent: boolean }[]` | | | `previousHref` | `string \| null` | | | `nextHref` | `string \| null` | | ### ThreadView `server` · stable A thread page body: the post list, pagination, reply affordance. Props: `ThreadViewModel` | Field | Type | Notes | |---|---|---| | `thread` | `ThreadRowModel` | | | `forum` | `LinkModel` | | | `replyHref` | `string \| null` | | | `markReadAction` | `string \| null` | A native POST target for the last visible post on this page. | | `regions` | `{ /** * Controls scoped to this thread — following it, rating it, its poll, and * the moderator's thread tools. Rendered by the route, for the reason * every app-rendered region exists: each one carries a Server Action. * * **A theme renders this under its heading, not above it**, and the same * history is behind this field as behind `ForumDisplayModel`'s. Four of * these strips used to stack before `ThreadView`, so a thread opened on a * phone began with a follow control, a star rating and a poll, and the * title of the thing being followed, rated and voted on was a screen * further down. * * Only what belongs *before* the posts: the moderator's bar, and the * poll, which is content rather than a control. Rating and following are * in `afterContent`. * * Optional under the versioning policy: a theme written against 0.3 compiles * and simply does not offer them. */ readonly tools?: ReactNode /** One `PostBit` per post on this page. */ readonly posts: ReactNode readonly pagination: ReactNode /** * Controls for a reader who has reached the end — rating the thread, and * following it. * * A theme renders it after the posts and **before** the quick reply, which * is the order the two are wanted in: somebody who has just read fifty * posts is deciding what they think and whether to keep hearing about it, * and then whether to answer. Both used to be above the first post, where * they were asking for a verdict on something the reader had not read yet. */ readonly afterContent?: ReactNode /** * The quick-reply island, or `null` when the viewer may not reply — in which * case nothing is rendered and no island bytes are shipped. */ readonly quickReply: ReactNode }` | | ### PostBit `server` · stable One post: author block, body, footer. **The** load-bearing server slot — see this file’s header for what marking it `client` costs. Props: `PostBitSlotModel` | Field | Type | Notes | |---|---|---| | `post` | `PostBitModel` | | | `select` | `SelectionModel \| null` | The inline-moderation checkbox, or `null`. A theme that ignores it loses only bulk actions. | | `regions` | `{ /** The `PostActions` slot, rendered by the page. */ readonly actions: ReactNode /** The `postbit.badges` region, beside the author's name. */ readonly pluginBadges?: ReactNode /** The `postbit.footer` region, below the body. */ readonly pluginFooter?: ReactNode }` | | ### PostActions `server` · stable Per-post controls (quote, edit, report, moderate). Links and forms, not buttons with handlers, so they work without JavaScript. Props: `PostActionsSlotModel` | Field | Type | Notes | |---|---|---| | `actions` | `PostActionsModel` | | | `postId` | `number` | | | `children` | `ReactNode` | optional — App-rendered controls that belong beside the post's own actions — today, the multi-quote island. It is `children` for the reason logging out is: the button is a client island holding browser state, and neither a component nor a handler can cross this contract as data. Before this field the page had nowhere to put it but `PostBitModel.regions.pluginFooter`, so every post on the board carried a second bordered row containing one control — the plugin region used as a parking space, and a visible band of furniture per post as the price. Additive under the versioning policy, and `children` is already exempt from the plain-data rule. | ### QuickReply `client` · provisional The inline reply island at the foot of a thread. Enhances the full reply page; it never becomes the only way to reply. Props: `QuickReplyModel` | Field | Type | Notes | |---|---|---| | `action` | `string` | | | `threadId` | `number` | | | `placeholder` | `string` | | | `submitLabel` | `string` | | | `fullReplyHref` | `string` | Where the no-JS reply form lives, for when the island is not rendered. | ### PostForm `server` · stable The composer page: subject, message, prefix, options. A native form posting to a Server Action — the editor toolbar is the island, not this. Props: `PostFormModel` | Field | Type | Notes | |---|---|---| | `mode` | `'thread' \| 'reply' \| 'edit'` | | | `heading` | `string` | e.g. "Post a new thread in General". | | `cancelHref` | `string` | Where a cancel link returns to — the forum, or the thread being replied to. | | `cancelLabel` | `string` | | | `errorMessage` | `string \| null` | | | `regions` | `{ /** The app-rendered `<form>` carrying the Server Action and its controls. */ readonly form: ReactNode /** * The `EditorToolbar` island, or `null`. A `null` here must leave a working * plain-textarea form: the island enhances, it never enables. */ readonly toolbar: ReactNode }` | | ### EditorToolbar `client` · provisional Formatting toolbar, preview, attachment picker. Mounted beside the textarea; removing it must leave a working plain-textarea form. Props: `EditorToolbarModel` | Field | Type | Notes | |---|---|---| | `textareaId` | `string` | The textarea's `id`; the island attaches to it rather than owning it. | | `buttons` | `readonly { readonly tag: string readonly label: string readonly icon: string \| null }[]` | | | `previewAction` | `string \| null` | | ### MemberProfile `server` · stable A member’s profile page body: identity, stats, recent activity. Props: `MemberProfileModel` | Field | Type | Notes | |---|---|---| | `user` | `UserRefModel` | | | `avatarUrl` | `string \| null` | | | `title` | `string \| null` | | | `joinedAt` | `TimeModel` | | | `lastVisitAt` | `TimeModel \| null` | | | `postCount` | `number` | | | `signatureHtml` | `string \| null` | | | `fields` | `readonly { readonly label: string; readonly value: string }[]` | Custom profile fields, already filtered by visibility. | | `actions` | `readonly LinkModel[]` | | | `regions` | `{ /** The `profile.panel` region. */ readonly plugins?: ReactNode }` | optional | ### SearchForm `server` · stable The search form. A GET form with named inputs, so a search is a URL that can be linked and cached. Props: `SearchFormModel` | Field | Type | Notes | |---|---|---| | `action` | `string` | Where the form submits. A GET form: a search is a URL. | | `fields` | `{ readonly query: string readonly forum: string readonly sort: string }` | The names to give the controls, owned by the app. | | `query` | `string` | | | `maxQueryLength` | `number` | The server's limit, so the browser can refuse over-long input first. | | `forums` | `readonly OptionModel[]` | Forums this viewer may search. The first option is "everywhere". | | `sorts` | `readonly OptionModel[]` | | | `hint` | `string \| null` | Guidance for an empty form: quoting, exclusion. `null` once submitted. | | `errorMessage` | `string \| null` | | ### ForumJump `server` · stable The jump box at the foot of every page. A GET form with a submit control, never a select that navigates on change — choosing an option is not committing to it, and arrow-keying through one would teleport a keyboard user to the first forum in the list. Props: `ForumJumpModel` | Field | Type | Notes | |---|---|---| | `action` | `string` | Where the form submits. GET, because a jump is a navigation. | | `field` | `string` | The query-parameter name to give the select. The app owns it. | | `forums` | `readonly ForumJumpOption[]` | Visible forums, in tree order. | | `submitLabel` | `string` | The label for the submit control. Always rendered. | | `label` | `string` | Accessible name for the control, e.g. "Jump to forum". | ### RedirectNotice `server` · stable The MyBB-style interstitial: "your post was made, continuing in a moment", with a real link for anyone the meta refresh does not carry. Props: `RedirectNoticeModel` | Field | Type | Notes | |---|---|---| | `message` | `string` | | | `targetHref` | `string` | | | `delaySeconds` | `number` | | ### ErrorNotice `server` · stable The themed body of an error or not-found page. Must not depend on the database: it is what renders when the database is the thing that failed. Props: `ErrorNoticeModel` | Field | Type | Notes | |---|---|---| | `status` | `number` | | | `title` | `string` | | | `message` | `string` | | | `homeHref` | `string` | | | `requestId` | `string \| null` | The request id, so a user can quote it in a report. | ## Shared models Referenced by the models above. Same promise: a field of a shared model reached from a stable slot is stable. ### ForumJumpOption | Field | Type | Notes | |---|---|---| | `value` | `string` | | | `label` | `string` | | | `depth` | `number` | 0 for a top-level category. The theme chooses how to show nesting. | | `isCategory` | `boolean` | A category is a heading, not a destination — rendered disabled. | | `isSelected` | `boolean` | | ### ForumRowModel Submitted as the form value. Opaque to the theme. readonly value: string readonly label: string readonly isSelected: boolean } /* ------------------------------------------------------------------ * Listing models ------------------------------------------------------------------ | Field | Type | Notes | |---|---|---| | `id` | `number` | | | `title` | `string` | | | `description` | `string \| null` | | | `href` | `string` | | | `type` | `'category' \| 'forum' \| 'link'` | `link` rows navigate away and have no counters. | | `threadCount` | `number` | | | `postCount` | `number` | | | `lastPost` | `LastPostModel \| null` | | | `isUnread` | `boolean` | `false` for a guest, who has no read state. | | `subforums` | `readonly LinkModel[]` | | ### LastPostModel The last post in a forum or thread, as a listing shows it. | Field | Type | Notes | |---|---|---| | `threadTitle` | `string` | | | `href` | `string` | Deep link to the post itself, not the thread's first page. | | `author` | `UserRefModel` | | | `at` | `TimeModel` | | ### LatestPostModel One post in the index's "latest posts" panel. | Field | Type | Notes | |---|---|---| | `threadTitle` | `string` | The thread it is in. A post has no title of its own. | | `href` | `string` | `/thread/12-slug#post-34` — the post, not the top of its thread. | | `forum` | `LinkModel` | | | `author` | `UserRefModel` | | | `excerpt` | `string` | The post as text: flattened out of its Markdown source and cut on a word boundary, the same way a feed entry's summary is. Flattened rather than rendered, because the board's HTML carries quotes, directives and attachment markup whose meaning is lost in two lines — and because a theme dropping raw post HTML into a sidebar is one plugin away from being an injection point. | | `postedAt` | `TimeModel` | | ### LatestThreadModel One thread in the index's "latest threads" panel. Every row carries its forum, because these two panels are the only lists on the board that cross it: without the forum, two identically-titled threads in two forums are the same row printed twice. | Field | Type | Notes | |---|---|---| | `title` | `string` | | | `href` | `string` | | | `forum` | `LinkModel` | The forum it was started in, resolved — a theme never builds an href. | | `author` | `UserRefModel` | | | `replyCount` | `number` | | | `startedAt` | `TimeModel` | | ### LinkModel A resolved link. Themes never build hrefs; the app owns URL shape. | Field | Type | Notes | |---|---|---| | `label` | `string` | | | `href` | `string` | | ### LogoModel A board's logo, already resolved for this reader's colour scheme. Optional, and absent on most boards: a board with no logo renders its name in text, which is what every board did before this field existed. **The app resolves the scheme, not the theme.** A theme cannot do it, and the obvious attempt is wrong in the commonest case: `dark:hidden` matches the `.dark` class, and a reader who has chosen "system" has no class — their dark mode comes from a media query. They would get the light logo on a black page, which is the exact failure two images exist to prevent. The server knows the answer, so it gives one. | Field | Type | Notes | |---|---|---| | `src` | `string` | The image to render. Already the right one for a forced colour scheme. | | `darkSrc` | `string \| null` | A dark-scheme source, or `null`. Non-null means "wrap it in a `<picture>` and put this behind `(prefers-color-scheme: dark)`" — the reader is on "system" and has two images to choose between. Null covers three different situations a theme does not need to tell apart: one image, or a reader who has forced a scheme, in which case `src` is already the right one. | | `alt` | `string` | Never empty — the board's name when the operator has set nothing. | ### OnlineMemberModel One visitor in the online list. `location` is **already resolved against the reader**: a forum they may not see arrives as the bare label, never as a title with a link. The theme renders what it is given and cannot leak what it was not. | Field | Type | Notes | |---|---|---| | `userId` | `number \| null` | from `UserRefModel` — `null` when the account was deleted; `username` is still shown. | | `username` | `string` | from `UserRefModel` | | `profileHref` | `string \| null` | from `UserRefModel` | | `nameClass` | `string \| null \| undefined` | from `UserRefModel` — optional — A class carrying this member's group colour, or `null` for most members. **A theme should put this on whatever renders the name**, wherever a name appears. It is a class rather than a colour because the value has to differ between light and dark, and a `style` attribute cannot hold two answers — a reader on "system" has no `.dark` class at all, so the only place both can live is the stylesheet the app emits into `<head>`. A theme that ignores it renders the name in the ordinary text colour and is still correct, which is what makes the field additive. It will simply not show the board's own hierarchy, which most boards will notice. | | `location` | `{ readonly label: string; readonly href: string \| null }` | Where they are, as this reader may be told. Never null — see `label`. | | `isInvisible` | `boolean` | True only for staff, who see hidden members marked rather than absent. | | `lastSeen` | `TimeModel` | | ### OptionModel One choice in a `<select>` or a radio group, with the current one marked. `isSelected` rather than a separate `selected` field on the parent: a theme renders options in a loop, and "which of these is current" answered per option is one comparison the theme does not have to write — and cannot write wrongly by comparing a string to a number. | Field | Type | Notes | |---|---|---| | `value` | `string` | Submitted as the form value. Opaque to the theme. | | `label` | `string` | | | `isSelected` | `boolean` | | ### PostActionsModel | Field | Type | Notes | |---|---|---| | `quoteHref` | `string \| null` | | | `editHref` | `string \| null` | | | `restoreHref` | `string \| null` | Where a soft-deleted post is put back. A separate field rather than a second meaning for `editHref`, because the two are never both offered: a deleted post cannot be edited, and a visible one has nothing to restore. A theme that renders both gets exactly one. | | `reportHref` | `string \| null` | | | `warnHref` | `string \| null` | Warn this post's author, citing this post. Present for moderators only, and `null` for a post whose author is the viewer or a deleted account. Separate from `moderateHref` because a warning is aimed at the *person* and the post is only the evidence — which is also why the link carries the post id rather than living on the post's own moderation controls. | | `moderateHref` | `string \| null` | Reserved for per-post moderation controls that are not inline. Still `null` everywhere: per-post moderation is on checkboxes and a bar rather than a per-post link, so nothing fills this yet. It stays in the contract because the moderation control panel is where such a *page* would live, and removing a public field to add it back next feature is worse than a documented `null`. | | `rateHref` | `string \| null` | Rate this post's author, for this post. Null on your own post, on a board with reputation off, and for anybody without the permission. It carries the post so the rating is attached to *this* post rather than to the author generally — which is what makes one rating per post a meaningful rule. | ### PostAttachmentModel One file attached to a post. | Field | Type | Notes | |---|---|---| | `id` | `number` | | | `filename` | `string` | Sanitised, and always ending in the extension the *bytes* imply. | | `size` | `string` | Already formatted — "1.4 MB" — because a theme is not a unit converter. | | `isImage` | `boolean` | Whether the board is willing to show this inline rather than link it. | | `href` | `string` | The download. Permission is re-checked on every fetch. | | `thumbnailHref` | `string \| null` | | | `width` | `number \| null` | | | `height` | `number \| null` | | ### PostAuthorModel The author block beside a post. | Field | Type | Notes | |---|---|---| | `userId` | `number \| null` | from `UserRefModel` — `null` when the account was deleted; `username` is still shown. | | `username` | `string` | from `UserRefModel` | | `profileHref` | `string \| null` | from `UserRefModel` | | `nameClass` | `string \| null \| undefined` | from `UserRefModel` — optional — A class carrying this member's group colour, or `null` for most members. **A theme should put this on whatever renders the name**, wherever a name appears. It is a class rather than a colour because the value has to differ between light and dark, and a `style` attribute cannot hold two answers — a reader on "system" has no `.dark` class at all, so the only place both can live is the stylesheet the app emits into `<head>`. A theme that ignores it renders the name in the ordinary text colour and is still correct, which is what makes the field additive. It will simply not show the board's own hierarchy, which most boards will notice. | | `avatarUrl` | `string \| null` | | | `title` | `string \| null` | The display group's title, or a custom user title. Was `null` on every post the board has ever rendered — the field was in the contract from the start and nothing populated it, so every theme's postbit had a place for a member's standing and nothing to put in it. It comes from `users.display_group_id`, falling back to the primary group. | | `badge` | `LogoModel \| null \| undefined` | optional — The board's badge for this member's group, or `null`. Shaped exactly like `LogoModel` and for the same reason: the app has already chosen which of the two images this reader gets, so `darkSrc` is non-null only for a reader on "system", where the server cannot know. | | `reputation` | `number \| null \| undefined` | optional — This member's reputation, or `null` when the board has it switched off. A denormalised counter on `users`, so it costs the postbit nothing. | | `postCount` | `number` | | | `joinedAt` | `TimeModel \| null` | | | `signatureHtml` | `string \| null` | Pre-rendered Markdown. Trusted output of the board's own renderer. | | `isOnline` | `boolean` | | | `fields` | `readonly { readonly label: string; readonly value: string }[]` | Custom profile fields, for the ones an operator marked for the postbit and this viewer may see. The same `{label, value}` shape `MemberProfileModel.fields` uses, and **plain text** for the same reason: it is rendered as text by the theme, and a field that could carry markup is stored XSS on the board's heaviest page. Empty on a board with no custom fields, which is most of them. | ### PostBitModel | Field | Type | Notes | |---|---|---| | `id` | `number` | | | `number` | `number` | Position within the thread, 1-based. What "#12" in the corner means. | | `permalink` | `string` | | | `author` | `PostAuthorModel` | | | `bodyHtml` | `string` | Pre-rendered Markdown. | | `quoteSource` | `string` | @deprecated Since theme API 1.4, removed in 2.0. Use `post.id`. It existed so the client could assemble a quote out of the page. Quoting asks the server for a post **by id** now, which re-checks who may see it and cannot hand back what a deleted post used to say — so this is a copy of every post's source in the HTML of every thread page, for nobody. Still populated, because a theme could have read it; see `DEPRECATIONS`. | | `postedAt` | `TimeModel` | | | `editedNote` | `string \| null` | "Last edited by X on Y", already assembled, or `null`. | | `isFirstPost` | `boolean` | | | `visibility` | `'visible' \| 'unapproved' \| 'deleted'` | A moderator sees deleted and unapproved posts, marked as such. | | `ignored` | `{ readonly authorUsername: string /** Same page, this post revealed. A GET: revealing changes nothing. */ readonly revealHref: string } \| null` | Set when this viewer ignores the author and has not revealed this post; `null` otherwise, which is the case on almost every post. The body is **withheld server-side** when this is set — `bodyHtml` is empty, the signature and custom fields are gone — rather than hidden with CSS, because "ignored" that ships the text to the browser is a preference rather than a feature. The post keeps its place and its number: filtering it out would give every viewer a different page size and make "#12" mean different posts to different people. A theme renders the placeholder and the link. Both are required — a hidden post with no way to see it is a hole in a conversation. | | `attachments` | `readonly PostAttachmentModel[]` | The files attached to this post. Empty on almost every post, and empty rather than absent so a theme has one shape to render. **Every entry is already downloadable**: a `pending` upload — one whose re-encode has not finished — and a failed one are not in this list, because a link to a file that is not there yet is worse than the file appearing a minute later. `thumbnailHref` is `null` for anything that is not an image, and for an image small enough that a thumbnail would be the same picture again. A theme showing an image inline uses `thumbnailHref ?? href` and gets the right answer in both cases. | | `actions` | `PostActionsModel` | | ### PrefixModel A thread prefix; `token` supplies its styling. | Field | Type | Notes | |---|---|---| | `label` | `string` | | | `token` | `string \| null` | | ### SelectionModel One inline-moderation checkbox, or `null` when this viewer has no business selecting rows. Plain data, and it has to be: the *form* it belongs to carries a Server Action reference, and such references never cross the theme contract. So the app renders the form — below the listing, where a bar of buttons belongs — and the theme renders a checkbox that says which form it belongs to. `formId` is the whole trick, and it is why this works with scripting off. HTML's `form` attribute associates a control with a form **by id, anywhere in the document**, so the checkboxes can live inside table rows, list items or article elements without the listing having to be wrapped in a `<form>` — which it cannot be, because `ForumDisplay` already renders a mark-read form and nested forms are not a thing browsers will parse. | Field | Type | Notes | |---|---|---| | `name` | `string` | The field name every checkbox shares. | | `value` | `string` | This row's value, opaque to the theme. | | `formId` | `string` | The `id` of the app-rendered form these checkboxes submit with. | | `label` | `string` | For a visually-hidden label: "Select 'How do I …' for moderation". | ### ThreadRowModel | Field | Type | Notes | |---|---|---| | `id` | `number` | | | `title` | `string` | | | `href` | `string` | | | `prefix` | `PrefixModel \| null` | | | `author` | `UserRefModel` | | | `replyCount` | `number` | | | `viewCount` | `number` | | | `isSticky` | `boolean` | | | `isLocked` | `boolean` | | | `isUnread` | `boolean` | | | `isMoved` | `boolean` | Set when the thread is a move stub; the row renders as a redirect. | | `lastPost` | `LastPostModel \| null` | | ### TimeModel A timestamp, in both forms a template needs. See this file's header. | Field | Type | Notes | |---|---|---| | `iso` | `string` | ISO-8601 UTC. Goes in `<time datetime>`; never rendered raw. | | `label` | `string` | Preformatted in the viewer's timezone, e.g. "Today, 09:14" or "12 Mar 2026". | ### UserRefModel A user as they appear attached to content. | Field | Type | Notes | |---|---|---| | `userId` | `number \| null` | `null` when the account was deleted; `username` is still shown. | | `username` | `string` | | | `profileHref` | `string \| null` | | | `nameClass` | `string \| null \| undefined` | optional — A class carrying this member's group colour, or `null` for most members. **A theme should put this on whatever renders the name**, wherever a name appears. It is a class rather than a colour because the value has to differ between light and dark, and a `style` attribute cannot hold two answers — a reader on "system" has no `.dark` class at all, so the only place both can live is the stylesheet the app emits into `<head>`. A theme that ignores it renders the name in the ordinary text colour and is still correct, which is what makes the field additive. It will simply not show the board's own hierarchy, which most boards will notice. | ### ViewerModel Who is looking. The only actor data a theme is given. | Field | Type | Notes | |---|---|---| | `isGuest` | `boolean` | | | `userId` | `number \| null` | `null` for a guest. | | `username` | `string \| null` | | | `profileHref` | `string \| null` | | | `avatarUrl` | `string \| null` | | | `canAccessAdminCp` | `boolean` | Whether to render the admin-panel link. A *rendering* hint, resolved by the Authorizer already — a theme must never conclude anything about permissions on its own, and themes stay out of authorization entirely. | | `canAccessModCp` | `boolean` | Whether to render the moderation link. Same shape and same rule as `canAccessAdminCp`: a rendering hint the Authorizer has already decided. Group-level only, which is a real limitation rather than an oversight: a per-forum appointee's queue exists and is reachable, but answering "does this person moderate anything" for them costs the tree, and the shell renders on every page. The moderation control panel is where that link earns its query. | ## Scheduled removals Nothing is deprecated in v0.9. Nothing can be: this is the first frozen contract, so there is no earlier promise to withdraw. --- <!-- docs/plugin-api.md · Plugins --> # The plugin API `@meith/plugin-kit` is the contract between the board and a plugin. This document is the policy — what a plugin is, what it may and may not do, and what the guarantees actually cover. The reference (every hook, every payload) is generated into [Plugin hooks](./plugin-hooks.md). ## Writing a plugin A plugin is a module that calls `definePlugin` and is registered in `community.plugins.ts` — the installed list lives in its own file, beside `community.config.ts`, so the operator CLI can read it without importing the themes' component trees. ```ts export const greeter = definePlugin({ key: "greeter", name: "Greeter", version: "0.1.0", hooks: { // A filter: what it returns replaces the value. "view.footer": (footer) => ({ ...footer, links: [...footer.links, { label: "Rules", href: "/rules" }], }), // An event: its return value is discarded. "post.created": { handler: (post) => report(post.postId), priority: 200 }, }, }) ``` Installing it is `pnpm add`, a line in `community.plugins.ts`, and a redeploy. > [!TIP] > **[`examples/hello-plugin`](https://github.com/meith-dev/meith/tree/main/examples/hello-plugin) > is the worked example to copy** — the smallest plugin that does something > visible with each extension point: a footer-link filter, a region > contribution, a setting, a migration, a task and an admin page, each with a > comment saying why it is shaped the way it is. It ships as reference code > rather than installed; [`examples/README.md`](https://github.com/meith-dev/meith/tree/main/examples) > walks through registering it or your copy of it. ### What a plugin can declare | Field | What it is | |---|---| | `hooks` | Handlers for named hooks. Filters change a value; events observe. | | `settings` | Settings the admin panel renders, stored under `plugin.<key>.<name>`. | | `migrations` | Forward-only SQL, applied in ascending id order and recorded per plugin. | | `tasks` | Scheduled work, registered as `plugin.<key>.<id>` and run by the same tick as core's. | | `adminPages` | Pages mounted under `/admin/plugins/<key>/`. | | `contributions` | Markup in named UI regions. | | `onInstall` / `onEnable` / `onDisable` / `onUninstall` | Lifecycle callbacks — declared and typed, not yet dispatched by the host. See the inventory below. | > [!NOTE] > Everything but the callbacks is **declarative**. A plugin does not call > `registerHook` at import time — it exports an object and the host reads it. > > Registration by side effect makes the installed set depend on module evaluation > order, which differs between the dev server, a bundled build and the worker. > That is the direct cause of the "works locally, missing in production" class of > plugin bug. ## What a plugin cannot do These are not discouraged. There is no API for them. | It cannot | Why | |---|---| | Decide authorization | No hook filters `authorization.can()`, and none ever will. A plugin able to change that answer is a plugin able to grant itself anything | | Reach inside the visibility filter | No hook sits in the query path. A plugin that could rewrite a `where` clause could publish a private forum, and no amount of isolation makes that recoverable | | See an `Actor` | Payloads carry `{ userId, isGuest }`. An `Actor` carries resolved group membership, which invites a plugin to make its own permission decision from group ids | | Open a database connection | Migrations are SQL text the host runs. A plugin does not import `@meith/db` | | Patch core | There is no monkey-patching seam and no way to replace a domain command | | Fill a theme slot | A theme owns its slots. Plugins contribute to *regions* — see below | ## Filters and events | | What it gets | What happens to the return value | |---|---|---| | **Filter** | A value | It is used. Filters chain: each plugin receives what the previous one returned | | **Event** | A notification | Discarded | > [!TIP] > Anything that only wants to *know* — logging, a webhook, a counter — should be > an event. An event handler cannot corrupt the thing it is watching even when it > is wrong. ### Ordering Handlers run in **(priority, plugin key)** order. Lower priority runs first; the default is 100, so a plugin can insert on either side of an unopinionated one without negative numbers. Both halves are declared and total, so two plugins compose the same way on every request, on every instance, in every deployment. Nothing depends on registration order or on how `community.config.ts` happens to list its plugins. ## Failure isolation Every handler runs inside the host's try/catch. | What happens | Result | |---|---| | A filter throws | The value is left as it was, and the chain continues with the next plugin | | A filter returns `undefined` | Treated the same way — that is the shape of a handler that forgot to return | | An event throws | Recorded and forgotten | Nothing a plugin does propagates to the page. That makes plugin failures survivable, **not invisible**: every failure is counted, logged with the plugin key and the hook, and reported by `host.health()`. ### Three limits worth stating A guarantee with an unstated edge is worse than a smaller honest one. > [!WARNING] > **Auto-disable is per instance and in memory.** A plugin that has failed five > times is switched off for the rest of that process, and does not re-enable > itself — a plugin that recovered silently would mean the operator never learned > their board spent a day without the feature they installed. But the counter > resets whenever the platform recycles the instance. > > Auto-disable protects a request path within one instance. Switching a plugin > off across the board is an operator action. **Timing is measured, never enforced.** Each call is timed, and slow ones are logged and counted. There is no timeout, because JavaScript cannot abort a handler: a `Promise.race` that "times out" returns control while the handler keeps running, keeps its connection, and resolves later. That is not a timeout. **UI contributions are isolated when they are built, not while they render.** The host calls your `render` function inside a try/catch, so a throw there drops your contribution and the region renders without it. A node that throws during React's own render cannot be contained from the server — catching that needs an error boundary, and error boundaries are client components. So: build your markup in the function. Do not return a component that does work. ## UI regions Regions are not theme slots, and the distinction is deliberate. If a plugin could fill a slot, an installed plugin would decide what a post looks like — and two plugins filling the same slot would have to be resolved somehow. A region is the other arrangement: an explicit "plugins may add something here" point that a *theme* renders. - The theme keeps control of **where** plugin output appears. - The plugin keeps control of **what** it is. - Several plugins compose by concatenation, in the usual deterministic order. There are six, listed in [Plugin hooks](./plugin-hooks.md). The list is short on purpose — every region is a commitment every theme has to render or silently drop. ## Namespacing A plugin's key namespaces everything it registers, and the host builds the names, so a plugin cannot collide with another or reach a core one. | Thing | Name it gets | |---|---| | Setting | `plugin.<key>.<setting>` | | Task | `plugin.<key>.<task>` | | Admin page | `/admin/plugins/<key>/<path>` | One name in that namespace is the host's: `plugin.<key>._enabled` is the operator's kill switch. A plugin cannot declare it — setting names cannot start with an underscore — which is what makes the collision impossible rather than unlikely. `definePlugin` refuses a key, setting name, task id or page path that would not namespace cleanly — a dot in a plugin key produces an ambiguous setting key, and a slash in a page path escapes the admin prefix. ## Migrations Forward-only, like core's, and for the same reason: a down migration that drops a column is a data-loss button on a live board. Ids look like `0001_add_table` and are applied in **sort** order. > [!IMPORTANT] > `definePlugin` refuses a migration list that is not written in ascending order, > because the failure is otherwise silent: a fresh board applies everything, an > upgraded board skips the id that sorts before the last one applied, and the two > boards end up with different schemas and no error anywhere. ## Versioning `definePlugin` requires semver. The version is the plugin's own — it is what the admin panel shows and what its migration history is recorded against. `apiVersion` declares which plugin-kit major the plugin was written against. The same policy as the theme API applies: a minor adds hooks, payload fields and regions; a major may remove or rename one, and only after a deprecation cycle. ## What is wired, and what is not An honest inventory, because the alternative is a document describing a system that does not run. It is derived rather than remembered: `scripts/hook-callsites.mjs` computes it by scanning the tree, so the generated reference's column cannot drift from the code. **25 of the 95 hooks are wired** — the shell filters, the view models of every reading surface, and the three posting events. The generated reference's wired column is the authoritative list. A hook that is declared but not wired is not broken; it is a call site that has not been written. Registering a handler for one is legal, does nothing, and the reference marks it so you find out before you ship. **`plugins/reference` must handle every wired hook**, enforced by its own test. That is the ratchet: wiring a new call site into the board fails the reference plugin's test until a handler is added there, so a hook cannot join the running product without something proving it fires. **The lifecycle callbacks do not run yet.** `onInstall`, `onEnable`, `onDisable` and `onUninstall` are part of the declared shape and validated like everything else, but no host code dispatches them today. Write them if the shape of your plugin wants them — just do not put anything there that must run for the plugin to be correct. ### The four descriptors execute Migrations are applied by `community upgrade` in dependency order, one transaction each. Settings are stored at `plugin.<key>.<name>` and edited in the control panel. Tasks are registered as `plugin.<key>.<id>` and run by the same tick as everything else. Admin pages are mounted at `/admin/plugins/<key>/<path>`. What that leaves, stated plainly: - **A page cannot reach anything a task cannot.** Both are handed a `PluginRuntimeContext` — resolved settings and a logger — and neither gets the `Actor`, the request, or a database handle. A page renders under an already-authenticated panel route; there is no per-page permission to declare, because a plugin does not get to make that decision. - **A task's failure is not swallowed.** Hooks are isolated because the alternative is a plugin taking down a page render. A task has no page to take down, and the scheduler already records failures and notifies administrators — catching there would turn every failure into a successful run of nothing. - **There is no plugin-run button for migrations**, and there will not be. A schema change belongs to the deploy that shipped the code expecting it. The panel reports which migrations have and have not been applied, which is the part an operator cannot otherwise find out. - **Disabling is durable and immediate; uninstalling is not offered.** The panel's switch writes a row that every instance reconciles against on its next request, so it survives a redeploy — the plugin somebody switched off at 2am is exactly the one that must stay off. Removing a plugin is `pnpm remove`, a line out of `community.plugins.ts`, and a redeploy; a button that dropped the rows and left the code running would produce a state neither installing nor removing does. ## The generated reference is a gate [Plugin hooks](./plugin-hooks.md) is written by `scripts/plugin-hook-docs.mjs` from the registry. `pnpm verify` and CI run `pnpm plugin:docs:check`, which fails when the file and the code disagree. Hook documentation goes stale faster than most, because a hook is added in the feature that needs it and documented, if at all, afterwards. If the check fails, run `pnpm plugin:docs` and commit the result. --- <!-- docs/plugin-hooks.md · Plugins --> # Plugin hooks <!-- GENERATED FILE — do not edit. Written by scripts/plugin-hook-docs.mjs from packages/plugin-kit/src/{hooks, payloads,regions}.ts. Run `pnpm plugin:docs` after changing any of them; `pnpm verify` and CI run `pnpm plugin:docs:check` and fail when this file and the code disagree. --> **95 hooks** — 50 filters, 45 events — and 6 UI regions. **25 are wired**: something in the board fires them today, and the rest are declared but not yet reached by a call site. The wired column is derived from the tree by `scripts/hook-callsites.mjs`, not maintained by hand — a registry entry with no call site is a promise about code that never runs, and it fails in the quietest possible way: the plugin installs, the handler registers, nothing happens. `plugins/reference` is required by its own test to handle every wired hook, so a hook cannot join that column without something proving it fires. A **filter** is handed a value and returns a replacement; its result is used, so a filter that throws or returns nothing leaves the value as it was and the chain carries on with the next plugin. An **event** is told what happened and its return value is discarded — which is why anything that only wants to observe should be one: it cannot corrupt the thing it is watching even when it is wrong. Handlers run in **(priority, plugin key)** order. Both halves are declared, so two plugins compose the same way on every request and on every instance. Every handler is called inside the host’s try/catch and is timed. A plugin that fails repeatedly is switched off for the rest of the process and says so in its health row. See [`plugin-api.md`](./plugin-api.md) for the policy, the lifecycle and the limits. ## Content rendering | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `markdown.parse.text` | filter | — | `string` | `ViewerRef & { source: 'post' \| 'signature' \| 'pm' }` | | `markdown.render.html` | filter | — | `string` | `ViewerRef & { source: 'post' \| 'signature' \| 'pm' }` | | `markdown.directives` | filter | — | `readonly string[]` | `ForumRef \| Record<string, never>` | | `post.body.html` | filter | — | `string` | `PostRef & ViewerRef` | | `signature.html` | filter | — | `string` | `ViewerRef & { authorId: number }` | | `smilies.list` | filter | — | `readonly { readonly code: string; readonly imageUrl: string }[]` | `ViewerRef` | | `word-filter.patterns` | filter | — | `readonly { readonly pattern: string; readonly replacement: string }[]` | `Record<string, never>` | - **`markdown.parse.text`** — The raw Markdown source, before it is parsed. Last chance to rewrite input. - **`markdown.render.html`** — Rendered HTML, after the renderer has constructed it. Anything added here is trusted output and nothing escapes it afterwards. - **`markdown.directives`** — The declarative directive list, so a plugin can add a `:::name` block or `:name[…]` span without core changes. - **`post.body.html`** — One post’s rendered body, in the context of the thread it is being read in. - **`signature.html`** — A member’s rendered signature, wherever it appears. - **`smilies.list`** — The smilie set offered by the editor and substituted at render. - **`word-filter.patterns`** — The render-time word filter’s pattern list. ## View models | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `view.header` | filter | yes | `HeaderModel` | `ViewerRef & RequestRef` | | `view.user-panel` | filter | yes | `UserPanelModel` | `ViewerRef & RequestRef` | | `view.navigation` | filter | — | `NavigationModel` | `ViewerRef & RequestRef` | | `view.footer` | filter | yes | `FooterModel` | `ViewerRef & RequestRef` | | `view.forum-jump` | filter | yes | `ForumJumpModel` | `ViewerRef & RequestRef` | | `view.announcement` | filter | yes | `AnnouncementModel` | `ViewerRef` | | `view.board-index` | filter | yes | `BoardIndexModel` | `ViewerRef` | | `view.forum-row` | filter | yes | `ForumRowSlotModel` | `ViewerRef` | | `view.thread-row` | filter | yes | `ThreadRowSlotModel` | `ViewerRef & ForumRef` | | `view.post-bit` | filter | yes | `PostBitSlotModel` | `ViewerRef & ThreadRef` | | `view.post-actions` | filter | yes | `PostActionsSlotModel` | `ViewerRef & ThreadRef` | | `view.member-profile` | filter | yes | `MemberProfileModel` | `ViewerRef` | | `view.board-stats` | filter | yes | `BoardStatsModel` | `ViewerRef` | | `view.who-is-online` | filter | yes | `WhoIsOnlineModel` | `ViewerRef` | | `view.latest-threads` | filter | yes | `LatestThreadsModel` | `ViewerRef` | | `view.latest-posts` | filter | yes | `LatestPostsModel` | `ViewerRef` | | `view.pagination` | filter | yes | `PaginationModel` | `ViewerRef` | | `view.search-form` | filter | yes | `SearchFormModel` | `ViewerRef` | | `view.error-notice` | filter | yes | `ErrorNoticeModel` | `ViewerRef & RequestRef` | | `view.shell` | filter | yes | `ShellModel` | `ViewerRef & RequestRef` | | `view.notice` | filter | — | `NoticeModel` | `ViewerRef` | | `view.category-block` | filter | — | `CategoryBlockModel` | `ViewerRef` | | `view.subforum-list` | filter | yes | `SubforumListModel` | `ViewerRef & ForumRef` | | `view.forum-display` | filter | yes | `ForumDisplayModel` | `ViewerRef & ForumRef` | | `view.thread-view` | filter | yes | `ThreadViewModel` | `ViewerRef & ThreadRef` | | `view.post-form` | filter | — | `PostFormModel` | `ViewerRef` | | `view.redirect-notice` | filter | — | `RedirectNoticeModel` | `ViewerRef` | - **`view.header`** — The header model, before the theme renders it. - **`view.user-panel`** — The user panel model: greeting, counts, account links. - **`view.navigation`** — The breadcrumb trail. - **`view.footer`** — The footer model, including its link list. - **`view.forum-jump`** — The jump box model. A plugin adding a destination must give it a real forum id — the route re-authorises whatever is submitted. - **`view.announcement`** — One announcement, on its way to the theme. Its body is already rendered HTML from the boardu2019s own renderer, so a plugin replacing it is replacing trusted markup — the one hook where that is true of a body. - **`view.board-index`** — The index page model. - **`view.forum-row`** — One forum row in a listing. Runs once per row — keep it cheap. - **`view.thread-row`** — One thread row in a listing. Runs once per row. - **`view.post-bit`** — One post as the theme will receive it. The busiest hook on the board: it runs once per post on every thread page. - **`view.post-actions`** — The per-post control links. Adding one here does not create permission to use it. - **`view.member-profile`** — A member’s profile model, including its custom fields and action links. - **`view.board-stats`** — The board totals block. - **`view.who-is-online`** — The online list, already resolved against the reader. - **`view.latest-threads`** — The index sidebar’s newest-threads panel. Runs again on every refresh of the live region, not only on the page load — keep it cheap. - **`view.latest-posts`** — The index sidebar’s newest-posts panel. Same refresh cost as view.latest-threads. - **`view.pagination`** — A resolved page-link window. - **`view.search-form`** — The search form model, including its filter options. - **`view.error-notice`** — The error page model. Runs on the page that renders when things are broken. - **`view.shell`** — The page frame’s model. Runs on every page including the error pages. - **`view.notice`** — A board notice or flash message, before the theme renders it. - **`view.category-block`** — One category on the index, with its rendered forum rows. - **`view.subforum-list`** — The compact child-forum list above a thread listing. - **`view.forum-display`** — A forum page’s model, including its rendered regions. - **`view.thread-view`** — A thread page’s model, including its rendered post list. - **`view.post-form`** — The composer page’s model. The form itself is app-rendered and arrives as a region. - **`view.redirect-notice`** — The interstitial shown after a mutation, before the meta refresh fires. ## Posting | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `thread.create.validate` | filter | — | `ValidationMessages` | `{ draft: DraftPayload }` | | `thread.create.before` | filter | — | `DraftPayload` | `ViewerRef` | | `thread.created` | event | yes | `ThreadRef & { authorId: number; subject: string }` | `ViewerRef` | | `post.create.validate` | filter | — | `ValidationMessages` | `{ draft: DraftPayload; threadId: number }` | | `post.create.before` | filter | — | `DraftPayload` | `ViewerRef & { threadId: number }` | | `post.created` | event | yes | `PostRef & { authorId: number }` | `ViewerRef` | | `post.edit.before` | filter | — | `{ readonly body: string; readonly reason: string \| null }` | `PostRef & ViewerRef` | | `post.edited` | event | yes | `PostRef & { editorId: number; revision: number }` | `ViewerRef` | | `post.delete.before` | event | — | `PostRef` | `ModerationRef` | | `post.deleted` | event | — | `PostRef` | `ModerationRef` | | `post.restored` | event | — | `PostRef` | `ModerationRef` | | `thread.moved` | event | — | `{ readonly threadId: number; readonly fromForumId: number; readonly toForumId: number }` | `ModerationRef` | | `thread.merged` | event | — | `{ readonly keptThreadId: number; readonly mergedThreadId: number; readonly postCount: number }` | `ModerationRef` | | `thread.split` | event | — | `{ readonly sourceThreadId: number; readonly newThreadId: number; readonly postCount: number }` | `ModerationRef` | | `thread.locked` | event | — | `ThreadRef & { isLocked: boolean }` | `ModerationRef` | | `thread.stickied` | event | — | `ThreadRef & { isSticky: boolean }` | `ModerationRef` | | `attachment.upload.validate` | filter | — | `ValidationMessages` | `{ readonly filename: string readonly bytes: number /** What the *bytes* say it is, not what the name claims. */ readonly detectedMimeType: string readonly uploaderId: number }` | | `attachment.uploaded` | event | — | `{ readonly attachmentId: number; readonly postId: number \| null; readonly bytes: number }` | `ViewerRef` | | `attachment.deleted` | event | — | `{ readonly attachmentId: number }` | `ViewerRef` | | `poll.created` | event | — | `ThreadRef & { pollId: number; optionCount: number }` | `ViewerRef` | | `poll.voted` | event | — | `{ readonly pollId: number; readonly optionId: number }` | `ViewerRef` | | `rating.recorded` | event | — | `{ readonly threadId: number; readonly rating: number; readonly average: number }` | `ViewerRef` | - **`thread.create.validate`** — Validation messages for a new thread. Returning a non-empty list refuses the post. - **`thread.create.before`** — The thread draft, before it is written. Subject, body, prefix, options. - **`thread.created`** — A thread was created and committed. - **`post.create.validate`** — Validation messages for a reply. - **`post.create.before`** — The reply draft, before it is written. - **`post.created`** — A reply was created and committed. - **`post.edit.before`** — An edit’s new body and reason, before the revision is written. - **`post.edited`** — A post was edited and a revision recorded. - **`post.delete.before`** — A post is about to be soft-deleted. Observation only: refusing is a permission. - **`post.deleted`** — A post was soft-deleted. - **`post.restored`** — A soft-deleted post was restored. - **`thread.moved`** — A thread changed forum. Carries both forum ids. - **`thread.merged`** — Two threads became one. - **`thread.split`** — Posts were split out into a new thread. - **`thread.locked`** — A thread was opened or closed. - **`thread.stickied`** — A thread was pinned or unpinned. - **`attachment.upload.validate`** — Validation messages for an upload, after the magic-byte check. A plugin may refuse a file core would accept; it can never accept one core refused. - **`attachment.uploaded`** — A file finished uploading and re-encoding. - **`attachment.deleted`** — An attachment was removed, by a member or by the orphan sweep. - **`poll.created`** — A poll was attached to a thread. - **`poll.voted`** — A vote was cast. Fires once; the database enforces one per member. - **`rating.recorded`** — A thread rating was recorded or changed. ## Moderation | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `report.created` | event | — | `{ readonly reportId: number readonly target: 'post' \| 'thread' \| 'user' \| 'pm' readonly targetId: number readonly reporterId: number }` | `RequestRef` | | `report.resolved` | event | — | `{ readonly reportId: number; readonly resolution: 'actioned' \| 'rejected' }` | `ModerationRef` | | `approval.queued` | event | — | `{ readonly kind: 'thread' \| 'post' \| 'attachment'; readonly id: number }` | `ViewerRef` | | `approval.decided` | event | — | `{ readonly kind: 'thread' \| 'post' \| 'attachment' readonly id: number readonly approved: boolean }` | `ModerationRef` | | `warning.issued` | event | — | `{ readonly warningId: number readonly userId: number readonly points: number readonly expiresAt: string \| null }` | `ModerationRef` | | `warning.revoked` | event | — | `{ readonly warningId: number; readonly userId: number }` | `ModerationRef` | | `moderation.logged` | event | — | `{ readonly action: string; readonly targetId: number \| null }` | `ModerationRef` | - **`report.created`** — Something was reported. The hook a notifier or a webhook wants. - **`report.resolved`** — A report was closed, with the resolution. - **`approval.queued`** — Content entered the approval queue. - **`approval.decided`** — Queued content was approved or rejected. - **`warning.issued`** — A warning was issued, with its points and expiry. - **`warning.revoked`** — A warning was revoked or expired. - **`moderation.logged`** — A moderation action was written to the log. ## Identity | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `user.register.validate` | filter | — | `ValidationMessages` | `{ readonly username: string; readonly email: string; readonly ipPrefix: string \| null }` | | `user.registered` | event | — | `UserRef & { username: string; requiresActivation: boolean }` | `RequestRef` | | `user.activated` | event | — | `UserRef` | `RequestRef` | | `user.login.attempted` | event | — | `{ readonly username: string readonly outcome: 'ok' \| 'bad-credentials' \| 'locked-out' \| 'banned' /** Truncated. Never a full address. */ readonly ipPrefix: string \| null }` | `RequestRef` | | `user.logged-in` | event | — | `UserRef` | `RequestRef` | | `user.logged-out` | event | — | `UserRef & { reason: 'requested' \| 'revoked' }` | `RequestRef` | | `user.banned` | event | — | `UserRef & { expiresAt: string \| null }` | `ModerationRef` | | `user.unbanned` | event | — | `UserRef & { expired: boolean }` | `ModerationRef` | | `user.groups.changed` | event | — | `UserRef & { primaryGroupId: number; secondaryGroupIds: readonly number[] }` | `RequestRef` | | `user.profile.updated` | event | — | `UserRef & { fields: readonly string[] }` | `RequestRef` | | `user.merged` | event | — | `{ readonly keptUserId: number; readonly mergedUserId: number }` | `RequestRef` | | `user.deleted` | event | — | `UserRef & { reason: 'pruned' \| 'deleted' }` | `RequestRef` | - **`user.register.validate`** — Validation messages for a registration. Where a custom question or an external blocklist belongs. - **`user.registered`** — An account was created, before or after activation depending on the mode. - **`user.activated`** — An account finished activation. - **`user.login.attempted`** — A sign-in was attempted, with the outcome. Never carries the password or the session token. - **`user.logged-in`** — A session was established. - **`user.logged-out`** — A session was ended, by the member or by revocation. - **`user.banned`** — A member was banned, with the expiry when there is one. - **`user.unbanned`** — A ban was lifted or expired and the prior group restored. - **`user.groups.changed`** — Primary or secondary group membership changed. - **`user.profile.updated`** — A member saved profile or option changes. - **`user.merged`** — Two accounts were merged. Carries the winner and the account that went. - **`user.deleted`** — An account was pruned or deleted. ## Mail, notifications, messages | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `notification.create.before` | filter | — | `{ readonly userId: number readonly kind: string readonly subjectText: string readonly href: string } \| null` | `RequestRef` | | `notification.created` | event | — | `{ readonly notificationId: number; readonly userId: number }` | `RequestRef` | | `mail.send.before` | filter | — | `{ readonly to: string readonly subject: string readonly textBody: string readonly htmlBody: string \| null } \| null` | `{ readonly template: string }` | | `mail.sent` | event | — | `{ readonly to: string; readonly template: string }` | `RequestRef` | | `pm.send.before` | filter | — | `{ readonly senderId: number readonly recipientIds: readonly number[] readonly subject: string readonly body: string } \| null` | `RequestRef` | | `pm.sent` | event | — | `{ readonly messageId: number; readonly recipientIds: readonly number[] }` | `RequestRef` | | `subscription.changed` | event | — | `{ readonly userId: number readonly target: 'thread' \| 'forum' readonly targetId: number readonly subscribed: boolean }` | `RequestRef` | | `reputation.changed` | event | — | `{ readonly userId: number; readonly delta: number; readonly total: number }` | `ViewerRef` | - **`notification.create.before`** — A notification about to be created. Returning `null` suppresses it. - **`notification.created`** — A notification was stored. - **`mail.send.before`** — A queued message, before it is handed to the mail driver. Subject, body and recipient; returning `null` drops it. - **`mail.sent`** — A message was accepted by the driver. Not proof of delivery. - **`pm.send.before`** — A private message, before it is stored. - **`pm.sent`** — A private message was delivered to its recipients’ folders. - **`subscription.changed`** — A member subscribed to or unsubscribed from a thread or forum. - **`reputation.changed`** — Reputation was given, changed or removed. ## Search, discovery, syndication | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `search.query.before` | filter | — | `string` | `ViewerRef` | | `search.results` | filter | — | `readonly { readonly postId: number; readonly threadId: number; readonly rank: number }[]` | `ViewerRef & { terms: string }` | | `feed.items` | filter | — | `readonly { readonly title: string readonly href: string readonly publishedAt: string readonly summary: string }[] /** Always a guest: a feed is cached under a shared URL. */` | `{ readonly feed: 'board' \| 'forum' \| 'thread' }` | | `sitemap.entries` | filter | — | `readonly { readonly href: string; readonly lastModified: string \| null }[]` | `{ readonly chunk: number }` | | `metadata.page` | filter | — | `{ readonly title: string readonly description: string \| null readonly canonical: string readonly imageUrl: string \| null }` | `{ readonly route: string }` | - **`search.query.before`** — The parsed search terms, before the query runs. The scope is not filterable. - **`search.results`** — A page of results, already permission-filtered in SQL. A plugin may reorder or drop; adding a row here would add one the viewer may not see. - **`feed.items`** — The items of a feed, rendered as a guest. Anything added is public. - **`sitemap.entries`** — One chunk of the sitemap. - **`metadata.page`** — Title, description and social card for a page. ## Admin and system | Hook | Kind | Wired | Value | Context | |---|---|---|---|---| | `admin.navigation` | filter | — | `readonly { readonly label: string; readonly href: string }[]` | `ViewerRef` | | `settings.saved` | event | — | `{ readonly keys: readonly string[] }` | `{ readonly adminId: number }` | | `task.run.before` | event | — | `{ readonly taskId: string }` | `Record<string, never>` | | `task.run.after` | event | — | `{ readonly taskId: string; readonly ok: boolean; readonly durationMs: number }` | `Record<string, never>` | | `cache.invalidated` | event | — | `{ readonly tag: string }` | `Record<string, never>` | | `plugin.enabled` | event | — | `{ readonly pluginKey: string }` | `Record<string, never>` | | `plugin.disabled` | event | — | `{ readonly pluginKey: string; readonly reason: 'operator' \| 'failures' }` | `Record<string, never>` | - **`admin.navigation`** — The admin panel’s section links, so a plugin page can be reached. - **`settings.saved`** — Board settings changed. Carries the keys, never the values. - **`task.run.before`** — A scheduled task is about to run. - **`task.run.after`** — A scheduled task finished, with its outcome and duration. - **`cache.invalidated`** — A cache tag was invalidated. - **`plugin.enabled`** — A plugin was enabled — including this one, which is how it learns it is on. - **`plugin.disabled`** — A plugin was disabled, by an operator or by the host after repeated failures. Carries the reason. ## UI regions Regions are **not** theme slots. A theme owns its slots; a region is an explicit "plugins may add something here" point that a theme chooses to render, so the theme keeps control of where plugin output appears and the plugin keeps control of what it is. Several plugins contributing to one region compose by concatenation, in the same deterministic order as hooks. | Region | What it is handed | |---|---| | `header.notice` | The viewer. | | `index.footer` | The viewer. | | `postbit.badges` | The viewer, the post id and the author id. | | `postbit.footer` | The viewer, the post id and the author id. | | `profile.panel` | The viewer and the profile’s member id. | | `admin.dashboard` | The viewer. | - **`header.notice`** — Directly below the board header, above the page body. Board-wide notices. - **`index.footer`** — The bottom of the board index, below the statistics block. - **`postbit.badges`** — Beside a post author’s name. Runs once per post on every thread page — the most expensive region on the board, and the one to keep trivial. - **`postbit.footer`** — Below a post body, above its actions. - **`profile.panel`** — A panel on a member’s profile, below the standard fields. - **`admin.dashboard`** — A card on the admin dashboard. Only rendered for administrators. --- <!-- docs/rest-api.md · The API --> # REST API v1 <!-- GENERATED FILE — do not edit. Written by scripts/api-docs.mjs from packages/api/src/{routes,tokens}.ts. Run `pnpm api:docs` after changing either; `pnpm verify` and CI run `pnpm api:docs:check` and fail when this file and the code disagree. --> 7 endpoints, 8 scopes. Base path: `/api/v1`. ## Authentication A bearer token in the `Authorization` header: ``` Authorization: Bearer forum_pat_<lookup>_<secret> ``` A token is a **restriction on an actor, never a grant to one**. Every request resolves the owner’s permissions and asks the Authorizer, exactly as a page does, *in addition to* checking the token’s scope. A token can therefore never reach anything its owner could not; revoking the owner’s access revokes the token’s in the same instant, because nothing is baked in at creation. Every authentication failure is one `401` with one message. The reason — expired, revoked, unknown, malformed — is in the board’s logs and not in the response: telling a caller "expired" confirms the token was real. ## Scopes - `forums:read` - `threads:read` - `threads:write` - `posts:read` - `posts:write` - `members:read` - `search:read` - `admin:read` There is deliberately no `admin:write`. A token is a long-lived string in somebody’s CI configuration; reconfiguring a board should need a person at a keyboard with the admin panel’s re-authentication in front of them. ## Rate limits Metered in **units of work, not requests** — a search is not a forum listing, and a limit that prices them the same invites the expensive call. Every response, refused or not, carries `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset`; a refusal is `429` with `retry-after`. ## Endpoints | Method | Path | Scope | Cost | Summary | |---|---|---|---|---| | `GET` | `/me` | `members:read` | 1 | The token’s owner, and the scopes this token carries. | | `GET` | `/forums` | `forums:read` | 1 | Every forum the token’s owner may see, as a flat list with parent ids. | | `GET` | `/forums/:forumId/threads` | `threads:read` | 1 | Threads in a forum, newest activity first, keyset-paged. | | `GET` | `/threads/:threadId` | `threads:read` | 1 | One thread’s metadata. | | `GET` | `/threads/:threadId/posts` | `posts:read` | 1 | Posts in a thread, oldest first, keyset-paged. | | `POST` | `/threads/:threadId/posts` | `posts:write` | 5 | Post a reply. Subject to the same flood control and moderation as the web form. | | `GET` | `/search` | `search:read` | 10 | Full-text search, filtered to what the token’s owner may read. | ## Errors Every error is the same shape, so a client parses one thing: ```json { "error": { "code": "missing_scope", "message": "…", "requestId": "…" } } ``` `code` is stable and machine-readable; `message` is for a human reading a terminal. `requestId` is the board’s correlation id — quote it in a report and an operator can find the request in their logs. | Status | Code | Meaning | |---|---|---| | 401 | `unauthenticated` | No bearer token, or the token is not valid. | | 403 | `missing_scope` | Authenticated, but this token lacks the endpoint’s scope. | | 403 | `owner_unavailable` | The account the token belongs to can no longer act. | | 404 | `no_such_route` | No such endpoint. | | 429 | `rate_limited` | Over the window budget. See `retry-after`. | | 501 | `not_implemented` | Declared in the registry, handler not yet written. | ## Webhooks The board POSTs a JSON body and four headers: | Header | Meaning | |---|---| | `x-forum-event` | The topic. | | `x-forum-delivery` | Stable across retries — de-duplicate on this. | | `x-forum-timestamp` | Unix seconds, and part of the signed material. | | `x-forum-signature` | `sha256=<hex>` of `HMAC(secret, "<timestamp>.<body>")`. | Verify by recomputing the HMAC over `` `${timestamp}.${rawBody}` `` and comparing in constant time — **and reject anything older than five minutes**. The timestamp is inside the signed material precisely so it cannot be edited; checking the signature without checking the age leaves every captured delivery replayable forever. Delivery is queued, never inline. Failures retry with exponential backoff and jitter (30s doubling, capped at an hour, six attempts) and then **dead-letter** rather than disappearing, so an operator can retry them once the receiver is fixed. A `410 Gone` stops the retries immediately: the receiver has said the endpoint is finished. --- <!-- docs/mybb-parity.md · Migrating from MyBB --> # MyBB parity decisions Every place a Meith board behaves differently from MyBB, what it does instead, and why. **Read this before promising anyone a like-for-like move.** Each entry has the same four parts: | Part | What it tells you | |---|---| | **MyBB** | What the board you are leaving does | | **We** | What this board does instead | | **Why** | The reasoning, so you can judge whether it suits your community | | **Cost** | What an imported board actually loses, stated plainly | > [!NOTE] > An entry is added when a divergence is **chosen**, not when one is discovered > by accident. A surprise is a bug, not a parity decision. ## What is on this page - [Permissions and groups](#permissions-and-groups) - [Posting and Markdown](#posting-and-markdown) - [Spam](#spam) - [Announcements](#announcements) - [Editing and deleting](#editing-and-deleting) - [Moderation](#moderation) - [Warnings](#warnings) - [The moderator log](#the-moderator-log) - [Notifications and digests](#notifications-and-digests) - [Accounts and profiles](#accounts-and-profiles) - [Private messages](#private-messages) - [Buddies, ignoring and signatures](#buddies-ignoring-and-signatures) - [Reputation](#reputation) - [The control panel](#the-control-panel) - [Attachments and avatars](#attachments-and-avatars) - [Reading and discovery](#reading-and-discovery) - [Feeds, URLs and the sitemap](#feeds-urls-and-the-sitemap) - [Parity passes](#parity-passes) --- ## Permissions and groups ### Flood intervals **MyBB** stores `searchfloodtime` (and `floodtime`) as a per-usergroup numeric column, combined like any other numeric limit. **We** do not model flood intervals as a permission field at all. The board setting `search.flood_seconds` holds the interval, and the existing `canBypassFloodCheck` boolean permission exempts a group from it. **Why.** This board has one combination rule for all numerics: take the maximum, with `0` meaning unlimited and therefore beating every other value. That rule is correct for *allowances* — attachment size, posts per day — because a larger number is more permissive. It is exactly backwards for an *interval*, where the most permissive value is the smallest non-zero one. A user in a 30-second group and a 5-second group should get 5 seconds; MAX would give them 30. Keeping the field would have required a fourth combination kind used by two fields, and a permanent footnote on the permission matrix. Modelling it as a setting plus a boolean keeps that rule literally true for every field — the boolean combines by OR, which gives the right answer with no special case. **Cost.** An imported board loses per-group flood granularity: everyone is either subject to the board interval or exempt from it. Reintroducing granularity later means adding a `numeric-min` kind to `packages/core/src/permissions.ts` and one row per actor to the permission fixture. `posting.flood_seconds` is read by the posting path and the exemption is asked for as the global action `flood.bypass`, so no permission field escapes `@meith/authorization`. Administrators bypass it like any other action; the forum matrix does not carry a column for it, because the interval is a board setting rather than a per-forum grant. --- ### Permission field naming **MyBB** uses lowercase, unpunctuated column names (`canpostthreads`, `canviewthreads`, `cansearch`). **We** use camelCase keys (`canPostThreads`) mapped to snake_case columns (`can_post_threads`). **Why.** The keys are consumed as TypeScript property names across three packages, and `canviewothersthreads` is genuinely ambiguous to read. The mapping is mechanical and lives in `packages/db/src/schema/permission-columns.ts`, so importer code can translate a legacy column name in one place. --- ### Separate `canAccessAdminCp` and `isAdministrator` **MyBB** treats admin status and admin CP access as effectively the same thing (`cp_access` gates panel modules for an already-admin user). **We** keep them as two fields: `isAdministrator` grants the permission bypass, `canAccessAdminCp` grants the panel. **Why.** A bypass has to be explicit and logged. Splitting the fields makes it possible to grant a trusted role read access to the panel without also handing it the ability to bypass every forum permission on the board — and makes the audit log meaningful, because a bypass entry now implies a specific field. --- ## Posting and Markdown ### The markup language is Markdown, not BBCode **MyBB** posts are BBCode: `b i u s color size font align url email img quote code php list hr video`, plus smilies, admin-defined custom MyCode, and automatic linkification of bare URLs. **We** post in Markdown. Every board that upgrades has its posts, private messages, signatures, announcements and drafts **converted once**, in the background, by the render backfill; the importer marks what MyBB hands over as BBCode and the same sweep converts it. There is no BBCode renderer left in the tree, and no board runs two markup languages at once. This is the largest single divergence in this document, so what survives and what does not is worth stating precisely. **Converted with no loss.** `b i s url email img quote code list` all have a Markdown spelling, and the converter produces it. A quote keeps its attribution — `[quote='Bob']` becomes `> **Bob wrote:**` above the quoted lines — and a `[code]` body is fenced with a rail long enough that its own backticks cannot close it. **Converted with the styling dropped, the words kept.** `u`, `color` and `size` have no Markdown spelling. `[color=red]stop[/color]` becomes `stop`. Inventing a board-only directive for each would have meant shipping three tags that exist nowhere else, which is the thing Markdown was chosen to stop doing. **This is a real, permanent loss of presentation on an imported board, and it is the one place in this migration where something a member wrote does not come back.** Nothing they *said* is lost — only how it was coloured. **Left as the text it was.** `font`, `align`, `hr`, `video`, `php`, and any custom MyCode the old board defined: an unrecognised tag is escaped and shown as the characters its author typed, so an imported post reads as slightly plainer prose rather than as a hole. **Gained.** Headings, tables, task lists, thematic rules, fenced code with a language, and **auto-linking** — which MyBB had and the BBCode renderer refused. Markdown resolves the ambiguity that made it refusable: a bare URL ends at whitespace and gives back the trailing punctuation that belongs to the sentence, which is a rule that can be written down and tested rather than guessed. ### Quoting fills the box you are looking at **MyBB** quotes by navigating to the reply page, and offers multiquote for collecting several posts first. **We** do both of those and, with JavaScript on, neither navigates: clicking **Quote** puts the quote in the quick reply already on the thread page, opens it, and puts the caret under the quote. Multiquote works the same way — the selections are spent the moment the reply form loads. **The quote comes from the server, by post id.** That is worth stating because the alternative is what most boards do: read the post out of the page and turn it back into markup in the browser. This asks for the post instead, through the same visibility lookup the reply page uses, so a reader cannot quote something they were never shown and a moderator cannot republish a deleted post by quoting it. **Cost:** one request per quote, where a board doing it in the browser makes none. With scripting off, or on a page with no composer, the Quote link is a link to the reply page exactly as it always was. **A directive is not MyBB's custom MyCode.** MyBB's takes a *replacement pattern* — a regular expression and the HTML to put in its place — so an administrator can produce any markup they like from a form. Ours chooses a **name** and whether it is inline or block; members write `:::spoiler` … `:::` or `:spoiler[…]`, and the element is constructed by `@meith/markdown`. That is a real capability difference and a deliberate one: a field that chooses output markup is a second markup language administered through a web form, which is how boards with custom MyCode acquire a permanent XSS surface. Anything that needs bespoke markup is a plugin, where the code is reviewed and installed rather than typed into a text box. **We do not accept raw HTML**, which CommonMark says should pass through. That would need a sanitiser, and a sanitiser is a blocklist; this renderer constructs its output instead, which is why it has never had one. `<script>` in a post is seven escaped characters and a word. Two smaller deviations from CommonMark are worth naming: a single newline is a line break, and there are no indented code blocks. **Cost.** An operator promising a like-for-like move should promise it about the *text*, not about the colours. Members who knew BBCode have to learn a different syntax — the composer's toolbar, its shortcuts and its formatting help exist for exactly that week. --- ## Spam ### Anti-spam: no hosted captcha, and limits are not intervals **MyBB** ships a built-in image captcha, supports reCAPTCHA and hCaptcha, and models flood control as a per-usergroup interval. **We** ship a honeypot, a fill-time floor, admin-defined question challenges and first-post moderation, plus hourly limits on posting, searching, private messages, reports and uploads. There is no image captcha and no hosted provider. **Why.** Three separate reasons, and they are worth keeping apart. *No image captcha.* Generating one means rendering text to an image, which is a dependency, and the accessible fallback is an audio challenge, which is another. Both are defeated by commercial solvers for less than it costs to run them. A question a regular can answer and a script cannot is weaker against a determined human and stronger per unit of effort. *No hosted provider by default.* hCaptcha and reCAPTCHA work, and they mean every visitor's browser contacting a third party before they can register. That is a decision about a board's members rather than a setting, so the `CaptchaProvider` seam is shipped and the service is not. A board that wants one writes a small module against it; no form or call site changes. *Limits beside the interval, not instead of it.* MyBB's flood control is an interval, and this board keeps one (`posting.flood_seconds`, see [flood-intervals](#flood-intervals) for why it is a setting rather than a permission field). An interval does nothing about a script that posts every 31 seconds all night, so the board adds a *limit* — how many in an hour — counted in the database so every instance shares one allowance. The two answer different questions and both are configured. **Cost.** An imported board's captcha configuration does not carry over; the challenge has to be set up again, and the questions written. Its flood settings map onto the interval as before, with the hourly limits starting at zero. --- ## Announcements ### Announcements are not sticky threads **MyBB** has announcements as a first-class thing, and boards frequently use a pinned thread for the same job. **We** have announcements, and they are deliberately *not* threads: nobody can reply to one, it has a start and an end date, and it lives above the forums rather than in the listing. **Why.** A sticky thread is a conversation — it belongs to its author, members reply to it, and taking it down deletes what they said. That is what leaves a three-year-old rules post at the top of a forum on every board that pins one: removing it costs the discussion attached to it. An announcement expires on its own and removing it removes nothing anybody wrote, which is the whole point of having both. Two smaller differences follow. There is no per-group visibility on an announcement: a forum's is shown to whoever can see that forum, resolved through the same filter as everything else, and a board-wide one to everybody. And the dates are entered in **UTC** rather than in the operator's timezone, because the control submits wall-clock text with no zone and the alternative is an announcement that appears at a different hour depending on what `TZ` the container happened to have. --- ## Editing and deleting ### Markup that does not close **MyBB**'s regex passes leave an unmatched `[b]` as literal text, and can emit unbalanced HTML for crossed tags such as `[b][i]x[/b]`. **We** cannot emit unbalanced markup at all: the renderer builds a tree and writes elements out of it, so there is no path by which an opening tag reaches the page without its closing one. An unmatched `**` is two asterisks, an unterminated `` ` `` is a backtick, and an unclosed ``` fence ends at the end of the post rather than swallowing the thread. **Why.** Unbalanced output from a post body is the shape that lets formatting escape a post and affect the rest of the page, so this one is not negotiable regardless of parity. The visible outcome for the common mistake is the same as MyBB's — you see what you typed. --- ### Deleting the first post of a thread **MyBB** lets a member with `candeleteposts` delete any of their own posts, including the opening one; deleting it leaves the thread's remaining replies in place under a first post that no longer exists. **We** refuse it, with a message pointing at thread deletion instead. **Why.** The opening post *is* the thread as far as every listing is concerned — it supplies `first_post_id`, and the thread's title, author and counters are told from it. The two ways to allow the click both lose: deleting only the post leaves a thread with a title, a reply count and nothing to read, and quietly deleting the whole thread means "delete my post" removes other people's replies without saying so. Refusing and naming the alternative is the only option that does what it says. **Cost.** Until the full thread tools exist, a member who wants their thread gone has to ask a moderator. An imported MyBB thread whose first post was deleted arrives with a first post that is soft-deleted rather than missing, which the moderator view shows and the member view skips. --- ### Editing a post you no longer own the window for **MyBB** hides the edit control once `edittimelimit` has passed and refuses the submission server-side. **We** do the same, with one difference worth stating: the window is a **numeric permission**, so the usual combination applies — `0` means unlimited and beats every other value across a user's groups. A member in a 30-minute group and an unlimited group gets unlimited. **Why.** It is the same rule every other numeric on the board follows, and the alternative (minimum-wins) would need a fourth combination kind for one field — the trap already recorded under *flood-intervals*, where minimum-wins genuinely is correct and the field was therefore modelled as a setting instead. An edit window is an *allowance*, so MAX is the right rule and no special case is needed. --- ## Moderation ### Who handles a report **MyBB** has a dedicated permission, `canmanagereportedcontent`, separate from the moderator rights that decide what somebody can actually *do* about a report. **We** scope reports by the sets that already exist: a report about a post or a thread is visible to the moderators of its forum (`moderatedForumIds`, the same set that scopes the approval queue), and a report about a *member* is visible to board staff (`modcp.access`). **Why.** A third permission would let a board grant "can read reports about forum X" to somebody with no power to act on anything in forum X — a role whose only capability is reading complaints about their neighbours. Every report is about content or a person, and the people who can act are the people who should see it. **Cost.** An imported board's `canmanagereportedcontent` grants do not map one-to-one: anybody who held it without moderating a forum loses report access, and anybody who moderates a forum gains it. The importer should surface that as a migration note rather than guessing. --- ### What can be reported **MyBB** allows reports against posts, threads, profiles, private messages and (with plugins) more. **We** ship posts, threads and members. Private messages are absent because they has not been built — there are no tables for them, and a target kind nothing can produce is a promise the board cannot keep. **Why.** Same rule as everywhere else in this build: omit rather than stub. When they land, `REPORT_TARGET_KINDS` gains an entry and `resolveTarget` gains a branch; nothing else changes. --- ### Who can lock, pin and move threads **MyBB** grants these through `moderators` rows (per forum, per right) plus the super-moderator and administrator bypasses. There is no usergroup column for them. **We** do the same, and this is a parity decision only because it is the first place our permission model *diverges from its own pattern*: every other action on the board reads a field off the resolved forum matrix, and these four read an appointment right instead. **Why.** "May lock threads everywhere on the board" is a thing you are appointed to or a thing you bypass into as staff. A usergroup checkbox for it would let a board grant board-wide thread control by adding somebody to a group, with no record of which forums anybody was ever meant to be responsible for. **Cost.** A board that wants a "Junior moderators" group with lock rights everywhere has to appoint the group to each forum — `forum_moderators` accepts a `group_id`, so that is one row per forum rather than one per person, but it is not one checkbox. --- ### Copying a thread **MyBB** offers "copy thread" alongside move, duplicating every post and crediting the copies to their original authors — so one piece of writing raises its author's post count twice. **We** have not built it, and the double-count is why. It is recorded here rather than left as a gap because the *reason* is a product decision somebody has to make: either a copy does not credit anybody (and author counts stop matching the posts that exist), or it credits twice (and post counts stop meaning "things this person wrote"). **Cost.** Moderators split and re-file threads by moving rather than copying. Splitting is the operation that actually covers most of what copy is used for, and it has to answer the same question. --- ### Splitting a thread, and where the pieces land **MyBB** offers "split thread", which takes a checkbox selection of posts, lets the moderator choose a destination forum, and can leave the split-off posts credited however they already were. **We** split "from this post onwards" and land the new thread in the **same forum**, always. **Why.** The two differences answer two different questions. The cut point is a `<select>` of the posts on screen rather than a checkbox set because a select cannot name a post that is not on the page, and arbitrary selection needs the per-post checkbox surface — two selection mechanisms for one operation is worse than one narrower one. The destination is fixed because splitting and moving are two acts: a single operation with a second forum to authorise would let a moderator who may split here, but not post there, place content in a forum they have no standing in. **Cost.** A moderator who wants the split-off thread elsewhere splits, then moves — two operations and two audit rows instead of one. A moderator who wants posts 3, 7 and 12 and not 4–6 cannot express that yet. --- ### Which thread survives a merge **MyBB** merges by thread URL or id and keeps the thread the moderator is looking at, absorbing the one they name. **We** do the same, and refuse to infer it from anything else — not the older thread, not the one with more posts. **Why.** A merge destroys a thread row. Every heuristic for picking the survivor is right most of the time, and the times it is wrong are unrecoverable: the thread somebody meant to keep is gone and its posts are wearing another title. Being explicit costs a moderator nothing, because they already know which one they mean. **Cost.** Merging the wrong way round is still possible — it is a moderator's mistake to make, and it is logged with both ids so it can be seen. What is not possible is the software making it for them. --- ### What a merge does to post counts **MyBB** moves the posts and leaves author post counts alone, which is correct and worth stating because the neighbouring operation gets it wrong: MyBB's *copy* credits duplicated posts to their original authors, counting one piece of writing twice. **We** match MyBB on merge and split, for a reason we can state exactly: neither operation creates or destroys a post, so `users.post_count` never moves. Only `users.thread_count` does, by one — a split creates a thread, a merge destroys one. **Cost.** None here. This is the answer to the question the copy entry above leaves open, and it is the reason we built split before copy. ### Inline moderation offers no "unapprove" **MyBB:** the inline moderation dropdown on a forum listing includes *Unapprove threads*, which sends published content back to the queue. **Here:** it does not. Inline moderation offers approve, delete, restore, lock, unlock, pin, unpin and move; taking a visible thread off the board is `delete`, which is reversible with `restore` and is what a moderator actually wants. **Why:** `unapproved` and `deleted` are both "not counted, not visible", so the two differ only in which list the content appears on afterwards. Sending a published thread to the *approval queue* puts it in front of a moderator as something to decide on, when the decision has already been made — and it makes the queue a mixture of "new content nobody has read" and "old content somebody removed", which is the one thing the queue's ordering (oldest first) relies on not being true. Deleting says what happened and restoring undoes it. ### Bulk moderation chunks rather than refusing **MyBB:** inline moderation acts on whatever was selected, in one request. **Here:** a selection is applied in transactions of 25, up to a ceiling of 500 in one request. The approval queue keeps its hard refusal above 200. **Why:** the two surfaces have different shapes. Nobody hand-selects two hundred items from a queue, so refusing and saying "work through it a page at a time" is honest there. A listing has a "select all" and a moderator clearing a spam run genuinely has hundreds, so refusing would mean the feature does not do the job it exists for. Chunking is safe because every transition is state-guarded — a bulk action that dies halfway is fixed by pressing the button again, and the chunks that already ran report "already in that state". ## Warnings ### Warning levels are points, not percentages **MyBB:** warning levels are expressed as a percentage of a configured maximum, and a member's warning level reads as e.g. "40%". **Here:** levels and warnings are absolute points, and a member is on "6 points" with thresholds at 4, 7 and 10. **Why:** a percentage needs a configured maximum to mean anything, and a board that has never opened the admin screen would have every member permanently at 0% of nothing — which is precisely the state a v1 board is in, because no screen configures `warning_levels` yet. The admin screen that exists is not that one: levels are moderation configuration rather than group permissions, and the seeded ladder is what a board runs on until something owns them. Points are readable on their own, the seeded ladder works on a fresh board, and "2 points, expires after 90 days" is a sentence a moderator can weigh before issuing it. The importer can convert a percentage against the source board's maximum. ### A warning restriction outranks a moderation bypass **MyBB:** a user under a "moderate posts" warning has their posts held; staff permissions and moderator status are resolved separately and can conflict. **Here:** a warning-level restriction is applied *after* `bypassesModeration` and wins. A moderator who is themselves under a moderate-posting warning has their posts held, in every forum, including ones they moderate. **Why:** the bypass means "this forum's approval queue does not apply to you"; the warning means "your posts are reviewed". They are different statements and the second is a sanction a person received. Letting the first cancel the second would make the board's moderators the only members a warning could not reach, which inverts what a warning is for. ### Bans from a warning level are not lifted by revoking the warning **MyBB:** a warning that triggered a ban and is then revoked leaves the ban in place; an administrator lifts it. **Here:** the same, and deliberately. **Why:** the ban lifecycle owns the group the ban captured so it can be restored at expiry. Un-banning from the warning path would restore a group this feature never saw, through a code path that already refuses to run twice. More importantly, a ban is the heaviest thing the board does to somebody and its removal should be a decision a human makes while looking — which is what "a moderator lifts it" means. The revocation still lowers the points, so the level no longer applies and no further action is taken. ## The moderator log ### The moderator log is an allow-list of moderation actions **MyBB:** the moderator log and the administrator log are separate tables. **Here:** they share `admin_log`, and the ModCP filters it by a named list of moderation actions. **Why:** one table means one place a bypass, a settings change and a thread lock are all recorded, which is what an operator wants when reconstructing an incident. The filter is an allow-list rather than a deny-list because the table will keep growing row types: a deny-list turns every future administrative action into a moderator-visible disclosure the day somebody forgets to update it, whereas an allow-list turns a new moderation action into a missing row somebody notices. ### The address lookup finds ranges, not addresses **MyBB:** the ModCP's IP search matches full addresses, which MyBB stores. **Here:** it matches the truncated prefix the board stores, and the screen says so. **Why:** every address is truncated before it is written, so there is no full address to match — this is a consequence of the privacy invariant rather than a choice made here. It is stated on the screen because the difference matters to what a moderator does next: "shares an address" reads as proof, "shares a range" reads as something to check, and only the second is what the data supports. ### Copying a thread credits its authors twice **MyBB:** copying a thread duplicates its posts, and each copy counts towards its author's post count. One piece of writing therefore counts twice. **Here:** the same, chosen deliberately. **Why:** every other counter on this board holds to one definition — `users.post_count` means *posts written* — and the merge/split rule was settled the question by that definition (neither operation duplicates a post, so neither moves an author's total). Copy is the one tool that genuinely creates rows, so the definition and parity actually conflict, and parity won: an imported MyBB board's counts must not change under it, and a moderator using copy expects the same arithmetic they know. The cost is stated rather than hidden: after a copy, `post_count` means "posts attributed to you", which is a slightly different thing from "posts you wrote". `PostgresCounterRecount` agrees with it, because the recount counts rows — so the board stays internally consistent, and a repair run will not quietly undo it. Only visible posts are copied: copying held content would double the approval queue, and copying removed content would republish it. ### Copy is authorised by `thread.move`, at both ends **MyBB:** copy is governed by the same "can manage threads" moderator permission as move. **Here:** `thread.copy` does not exist as a right. Copying reads `thread.move` in the source forum *and* in the destination, exactly as a move does. **Why:** copying is moving that leaves the original behind. It puts content into the destination forum by the same mechanism, so the destination's moderators have precisely the same interest in it — and a separate right would mean an eighth column on `forum_moderators` distinguishing two acts nobody grants separately. Unlike a move, the destination *may* be the source forum: forking a discussion in place is legitimate and there is no pointer to repair, because nothing left. ### A moved thread leaves no redirect stub **MyBB:** moving a thread can leave a "Moved: <title>" row in the source forum, linking to its new home, optionally expiring after a set number of days. **Here:** a move just moves. The schema keeps `moved_to_thread_id` and `ThreadRowModel.isMoved` for a future implementation, and nothing writes them. **Why:** the stub is a second kind of row in every listing query, in a listing that is already the board's most performance-sensitive read, and it has to be filtered, counted and expired everywhere. What it buys is a reader who bookmarked a thread finding it — and search and the thread's own permalink already do that, because the thread keeps its id. Revisit if a real board reports people losing threads after a move. --- ## Notifications and digests ### A notification centre exists at all **MyBB:** has no notification centre. What a member is told arrives as e-mail (a subscribed thread, a warning, a PM alert), plus the "You have N new messages" line in the user CP. When the e-mail is filtered, bounces, or is simply never read, nothing on the board records that the member was told. **Here:** every notification is written to a `notifications` row first and delivered by e-mail second. The board's record is the row; the e-mail is one transport for it, and the transport can be declined. **Why:** a warning that changes what a member may do has to be discoverable from the board itself. Warnings shipped with exactly that gap — a suspended member found out by trying to post and being refused. Making the record the primary artefact also gives every later notifier — subscriptions, private messages, reputation — one place to write to rather than an e-mail template each. **Cost:** one more table on the read path — an unread count in the user panel on every page for a signed-in member, which is why its index is partial over unread rows. ### On-site delivery cannot be switched off; e-mail can **MyBB:** every notification channel is opt-out. A member can disable e-mail about warnings and about subscribed threads. **Here:** the preferences screen configures **e-mail only**. Every kind is recorded in the notification centre regardless. **Why:** the centre is the board's evidence that somebody was told. A member who can erase the record can later say they were never warned, with the board's own data agreeing — which is worse for the member too, since a moderator reviewing an appeal has nothing to look at. Declining e-mail costs nobody anything, because the record survives. **Cost:** a member who does not want to see a notification cannot remove it, only mark it read. If that becomes a real complaint, the answer is a "clear read notifications" control, not a channel switch. ### The reporter is told when their report is closed **MyBB:** tells the reporter nothing. A report is filed and disappears. **Here:** closing a report raises `report.actioned` for the reporter, naming the outcome (actioned or closed without action) and the captured label of what they reported. The moderator's private note is never included — the port that carries the notification has no field that could hold one. **Why:** a report button that silently swallows reports trains members to stop using it, and "we looked and decided not to act" is a legitimate outcome to communicate. E-mail for this kind is **off** by default, because reporting is exactly the act a member repeats and a second message about somebody else's content is not something to opt somebody into. **Cost:** a member who reports a lot gets a lot of on-site notifications. They coalesce per report rather than per target, so closing and re-closing one report is one line. ### A repeated notification is one row with a count **MyBB:** does not have the problem, having no notification store. **Here:** a raise may carry a dedupe key. While the notification it produced is unread, further raises with the same key increment `occurrences` and update the captured facts instead of writing a new row — enforced by a partial unique index rather than a prior read. Once the row is read, the next raise starts a fresh one. **Why:** the first notification the board raises without a human behind it is `system.task_failed`, and a task failing on every tick would otherwise write 1,440 rows a day per administrator, with an e-mail behind each. The count is also the more useful number: "this has failed 40 times" is the difference between a blip and an outage. **Cost:** the *first* occurrence's details are replaced by the latest one. That is deliberate for an operational alert and is why warnings carry no dedupe key at all — two warnings are two things that happened, and collapsing them would hide the one that crossed a threshold. --- ### "Instant" notification means "within a tick" **MyBB:** sends a subscription e-mail during the request that created the post, inside `add_thread`/`add_post`. **Here:** the post commits, and the `subscriptions.instant` task tells the subscribers on its next run — at most a minute later on a board whose tick runs every minute. **Why:** notifying inline is an unbounded loop inside the board's hottest write. One iteration per subscriber, each needing a permission re-check (a subscription is not a standing grant), each potentially a mail send — on a thread with 500 followers that is a posting request that takes seconds and fails if the mail provider is down. Every other side effect on this board already works this way (the outbox, the counter roll-up), and the watermark makes a delayed run indistinguishable from a prompt one except in timing. **Cost:** a subscriber can open a thread and see a reply before the notification about it arrives. That is strictly better than the failure it avoids, and the delay is bounded by the tick interval an operator controls. ### A digest's clock is per member, not per board **MyBB:** has no digests at all — every subscription is instant e-mail or nothing. **Here:** a subscription's cadence is `instant`, `daily`, `weekly` or `none`, and the daily/weekly clock is stored per member *and* per cadence in `digest_runs`. **Why:** a board-wide "send the digests now" schedule delivers everybody's digest at whatever moment the tick happened to fire, and hands somebody who subscribed on Sunday a "weekly" digest on Monday. Per member, the interval means what it says. Per cadence as well, because a member can follow one thread daily and another weekly, and one clock cannot serve both. **Cost:** one row per member per cadence, written only once a digest has actually gone out. A member who has never received one is due immediately, which is what makes a new subscriber's first digest arrive rather than never. ### The unsubscribe link acts on POST, not on GET **MyBB:** unsubscribe links are GETs — following the URL removes the subscription. **Here:** the link opens a page that says what unsubscribing would do and offers one button. The button is the act. **Why:** mail clients, corporate link scanners and preview fetchers request every URL in a message. A GET that unsubscribed would mean a member is unsubscribed by their own spam filter looking at the mail, and they would never know why the notifications stopped. It also matches what one-click unsubscribe (RFC 8058) expects of a mail sender. **Cost:** one extra click for somebody who genuinely wants out. The page needs no login and no JavaScript, so it is the cheapest possible extra click. ### Unsubscribing from a digest does not delete subscriptions **MyBB:** does not have the case, having no digests. **Here:** the digest's unsubscribe link switches subscription **e-mail** off. Every subscription stays, and new posts still appear in the notification centre. **Why:** a digest covers many subscriptions, so "unsubscribe" cannot mean one of them — and taking it to mean "all of them" would delete a member's follow list because they wanted fewer e-mails. The notification record is already separate from the transport; this is that separation applied to the one-click case. The per-thread link in an "as it happens" notification *does* end that one subscription, because there the member knows exactly which thread they are silencing. --- ## Accounts and profiles ### Timezones are IANA names, never offsets **MyBB** stores a numeric offset (`timezone` = `-5`, plus a separate `dst` flag the board or the member toggles). **Here:** an IANA zone name (`America/New_York`), validated against the runtime's own tz database. Offsets are refused *even though `Intl` accepts them*. **Why:** an offset cannot express summer time, so it is wrong for half the year in every zone that observes it — and MyBB's answer to that, a DST flag somebody has to flip, is wrong every year for anybody who forgets. The tz database already knows when the clocks change in every zone; storing the name lets it answer. **Cost:** an imported MyBB board's offsets do not map cleanly — `-5` is `America/New_York` in winter and `America/Chicago`'s summer, and neither is certain. The importer will have to pick a representative zone per offset and say so, rather than pretending the data was there. ### A password change signs out every other device **MyBB:** changing a password keeps other sessions alive. **Here:** every session is revoked, and the device that made the change is immediately given a fresh one. **Why:** changing a password is what somebody does when they think an account is compromised. One that leaves the attacker's session alive has done nothing. Re-issuing for the current device is what stops the safe behaviour from also being the annoying one. **Cost:** somebody who changes their password on a phone is signed out on their desktop. That is the intended outcome, and the screen says so before the button. ### Changing an e-mail address requires confirming the new one **MyBB:** with "verify e-mail" off — the default on many boards — the address changes immediately. **Here:** the address is held in a single-use token and adopted only when the link sent to it is followed. The current password is required to ask. **Why:** two failures, and the second is the serious one. A typo strands an account at an address nobody owns, with no way back except an administrator. And an unattended session becomes a full takeover: change the address, request a password reset, done. Confirming the new address closes both. **Cost:** a member whose new address bounces keeps the old one, which is the safe direction. A board with no mail configured cannot change addresses at all — the UserCP says the link was sent, because from the board's side it was. ### A custom profile field's visibility is per group, not a single "hidden" flag **MyBB:** `profilefields` carries `viewableby` and `editableby` as comma-separated group-id lists, plus `hidden` — and resolution is a substring check against the member's group string. **Here:** a row per (field, group) in `profile_field_groups` with nullable `can_view` / `can_edit`, resolved by the same rule everything else on this board uses: NULL abstains, any explicit grant wins. **Why:** the same shape as `forum_permissions`, so "who can see this" has one mental model rather than a second one that only applies to profile fields. A NULL that abstains is also what makes "staff may edit this" one row instead of a row per group with the other answer copied in — and a comma-separated list of ids cannot express "no opinion" at all. **Cost:** an imported MyBB board's `viewableby=-1` (everyone) maps to the field default and its explicit lists map to grant rows, but MyBB's *deny by omission* does not survive: a group absent from `viewableby` becomes a group with no opinion, which inherits. The importer must write an explicit `false` row per group MyBB omitted, or set `default_visible` false and grant the listed ones. ### Registration asks only for fields the new member's group may edit **MyBB:** a field marked `required` is asked at registration regardless of whether the registering member's group can edit it afterwards. **Here:** `requiredAtRegistration` is intersected with what the board's default member group may edit, so a field they will never be able to change is not asked for either. **Why:** "what you are asked at registration" and "what you may change afterwards" disagreeing is a trap — somebody types an answer they can never correct. Resolving against the group registration *puts them in* (not the guest group they are currently in) is what makes the two consistent. **Cost:** an operator who marks a field required but forgets to let the registered group edit it gets a field that is silently never asked. The CLI's `profile-field:add` says every new field starts editable by every group, which is the state where this cannot bite. ### An emptied field is deleted, not stored as an empty string **MyBB:** `userfields` has a column per field and a text column defaults to `''`, so "not answered" and "answered with nothing" are the same value. **Here:** a row per (member, field), and clearing an answer deletes the row. **Why:** every read on the board would otherwise have to treat two states as one, and one of them would eventually forget — a profile showing an empty "Pronouns:" row is the visible half of that. It is also what makes an unanswered field cost nothing on a board with twelve fields and ten thousand members who filled in two. **Cost:** a column-per-field table is one join cheaper to read. It is also a schema migration every time an operator adds a field, which is the trade MyBB made and this does not. ### Registration confirmation and password reset never say whether an address exists **MyBB:** the lost-password form answers "the e-mail address you entered was not found" for an address it has no account for, and the resend-activation form says so when an account is already active. **Here:** one sentence on every path. An unknown address, an account that is already active, a send that failed and a link that really went out all produce the same notice, and the rate limit is spent *before* the account is looked up so that its refusal cannot be provoked for one address and not another. **Why:** a form that answers "is there an account for this address?" answers it for anybody, one submission at a time — including for a list of addresses somebody bought. That is a membership list the board did not intend to publish, and on a board where membership itself is sensitive it is the whole game. **Cost:** somebody who mistypes their own address is told a link was sent and no link arrives, with nothing on screen to say why. The resend screen names the address it used, which is the one place the typo becomes visible. ### An unconfirmed account is a state on the row, not a usergroup **MyBB:** an account waiting for activation is a *member of the "Awaiting Activation" usergroup*, so its permissions come from that group and activating somebody means moving them between groups. **Here:** `users.state` carries `awaiting_activation`, the group is whatever the board's default is, and confirming an address stamps `users.email_verified_at`. Under the `both` method the stamp is what says "the address is proven, an administrator has not looked yet" — the state does not change until they do. **Why:** a group is how permissions are decided (R4.1), and lifecycle is not a permission. Modelling it as one means every permission question on the board silently depends on account state, and it means a ban — implemented by capturing and restoring the group — cannot be reasoned about independently. It also means the two facts stay separable: an account can be proven and unapproved, which the `both` method needs and a single group membership cannot express. **Cost:** an operator cannot grant unactivated accounts a different permission set by editing a group, because there is no group to edit. Restricting what an unactivated account may do is not a MyBB feature people use — they cannot log in at all — but it is a knob that exists there and does not here. ## Private messages ### A private message is stored once, not once per recipient **MyBB:** `privatemessages` holds a row per copy — the sender's Sent Items and each recipient's Inbox carry the full subject and body. **Here:** `private_messages` holds the content and `private_message_copies` holds one small row per participant. **Why:** a message to twenty people is otherwise twenty copies of the text, and re-rendering one is twenty writes. It also makes quota count *copies* — the thing a member can actually delete — and lets the render cache invalidate private messages the same way it invalidates posts, on the next page load, with no migration. **Cost:** a join on every folder listing, which the folder and message indexes exist for. And a message everybody has deleted leaves an orphan row rather than disappearing by cascade — deliberately, because deleting *your* copy must not reach into somebody else's mailbox. Pruning orphans belongs to the maintenance sweep. ### The quota is storage; the daily cap is separate **MyBB:** `pmquota` caps stored messages and there is no separate send rate for most groups. **Here:** two numbers. `max_private_messages_per_day` has existed since the initial schema and caps sends; `private_message_quota` caps what a member may keep. Both are 0-means-unlimited like every other numeric permission, combined by MAX across groups. **Why:** they answer different abuse questions. A rate limit slows a spammer; a storage limit bounds what the board pays to keep. Collapsing them means a board that wants to allow a hundred stored messages must also allow a hundred a day. **Cost:** one more column on `usergroups`, and an operator has two numbers to think about instead of one. The seeded ladder sets both, so a board nobody configures behaves sensibly. ### A full inbox refuses the whole send, and names who is full **MyBB:** a send to a member over quota fails and reports it. **Here:** the same, extended to multiple recipients — if any one of them is full, **nothing is sent to anybody**, and every full recipient is named. **Why:** partial delivery leaves the sender with a Sent copy claiming a message went somewhere it did not, and no answer to "did it send?". Naming the full recipient trades a small disclosure (their box is full) against the much worse failure of a sender who believes they were heard. **Cost:** one member with a full box blocks a message to nine others until the sender removes their name. That is the intended outcome, and the message says which name to remove. ### Reporting is the only way staff read a private message **MyBB:** a reported PM is copied into the report, and administrators with database access can read any message. **Here:** there is no listing, no search and no browse path into private messages at all. `forReport` takes an id and is reached only from an existing report row, so a moderator reads exactly what was reported and nothing beside it. A message can only be reported by somebody who holds a copy of it, which is also what makes "not yours" and "does not exist" the same answer. **Why:** a moderation tool that can enumerate private messages is a surveillance tool with a moderation feature attached. **Cost:** a moderator cannot see the rest of a conversation for context — only the message that was reported. Reporting each message is the way to give them more, which is also the way the member chooses what staff see. ### Reply addresses the author, not everybody on the message **MyBB:** reply addresses the sender; a separate "reply to all" addresses everyone. **Here:** reply addresses the author, and there is no reply-all. **Why:** bcc. A recipient who was bcc'd is hidden from the other recipients, and a reply-all composed by one of them would either leak that name or silently drop somebody — and whichever it did, it would do it without the member noticing. A message that quietly grows its audience is not what a reply button should mean. **Cost:** answering a group conversation means typing the other names, which the composer shows in the "To" line of the message being replied to. ## Buddies, ignoring and signatures ### Ignoring hides a post's body; it does not remove the post **MyBB:** an ignored member's posts are collapsed client-side, with the body still in the HTML. **Here:** the body is withheld **server-side** — it is not in the response at all — and the post keeps its place in the page and its number in the thread. A placeholder and a per-post reveal link take its place. **Why:** shipping the text and hiding it with CSS is a preference rather than a feature. And filtering the post *out* instead would give every viewer a different page size, make "#12" mean different posts to different people, and land permalinks on the wrong page — which is why the requirement names stable pagination and counts. **Cost:** a thread with an ignored member in it still has their posts in it, as placeholders. That is the intended reading: a conversation with holes in it is still a conversation, and a reader who wants the missing half is one click away. ### Buddy and ignore are one table, and ignoring is not mutual **MyBB:** `userlist` with a `type` column, which is the same shape — but the ignore is often read as symmetric by the code around it. **Here:** one row per **ordered** pair, primary-keyed, so the two lists are mutually exclusive by construction. `(me, them)` is my opinion of them and says nothing about theirs of me. **Why:** a mutual ignore lets anybody silence themselves in somebody else's eyes by ignoring them first, which is a griefing tool rather than a preference. **Cost:** two people who both want to stop reading each other need a row each. That is one extra click, and it is the correct model. ### A blocked private message is refused, not silently discarded **MyBB:** a message to somebody who ignores you is accepted and dropped. **Here:** the send is refused, with the **same wording** as a permission refusal — "X cannot receive private messages" — so it does not disclose the ignore. **Why:** silently discarding it leaves the sender believing they were heard, which is the worst outcome for both people. Naming the ignore would make the send path a way to read somebody's list, and a list that announces itself is one people stop using. The ambiguous refusal is the only option that is honest to the sender without betraying the recipient. **Cost:** a sender cannot tell "they blocked me" from "their group cannot use PMs". That ambiguity is the feature. ### A signature's forbidden constructs render as text rather than refusing the save **MyBB:** per-group switches for images, links and HTML in signatures, enforced by stripping or by refusing. **Here:** a signature is parsed with a **narrower set of constructs** — emphasis, strong, strikethrough, code spans and links. Images, headings, quotes, lists, tables, rules and code blocks are off, so they come out as the characters somebody typed. **Why:** it cannot be bypassed by a construct this build does not know about, and it degrades — somebody pasting a signature from another board gets most of it rather than an error. The image is the one that matters: a remote image under every post is a tracking beacon reporting each reader's IP to whoever hosts it. **Cost:** an imported MyBB signature that used images loses them, visibly, as bracketed text the member can then delete. The importer should strip the tags rather than leave them, and say how many it touched. ### A signature is locked, not deleted **MyBB:** `suspendsignature` with an expiry, plus moderators simply clearing the text. **Here:** a boolean lock with a required reason. The text is kept, is shown back to the member with the reason on their own signature screen, and cannot be edited while locked. **Why:** an emptied signature can be retyped the next minute and says nothing about why it went. Keeping the text is also what lets an appeal look at what was actually there rather than at somebody's recollection. **Cost:** no expiry — an unlock is a second deliberate act. MyBB's timed suspension is the nicer behaviour and needs a scheduled task; it belongs with the maintenance sweep rather than being faked with a column nothing sweeps. ## Reputation ### Reputation has no per-group power multiplier **MyBB:** `reputationpower` makes a moderator's vote worth more than a member's. **Here:** every rating is worth −1, 0 or +1. The per-group settings are *whether* you may rate and *how many a day*. **Why:** a multiplier cannot obey the rule for numeric permissions — MAX across groups with 0 meaning unlimited — because "unlimited power" is meaningless and a multiplier has no unlimited state. It is the same shape as the `searchfloodtime` problem recorded above, and gets the same answer: leave it out rather than invert the combination rule for one field. **Cost:** a board that wants staff endorsements to carry weight cannot express it. An imported `reputationpower` is dropped, and the importer should say so rather than silently scaling everybody's totals. ### Reputation totals are recomputed, not incremented **MyBB:** `users.reputation` is adjusted as ratings are added and removed. **Here:** the column is rebuilt with a `sum` over the live rows, inside the same transaction as whatever changed them. **Why:** an incremented total cannot survive a rating being revised or withdrawn, and when it drifts nobody notices until somebody counts by hand. Same decision this board made for `warning_points` and for the thread and forum counters. **Cost:** one extra aggregate per rating. It is bounded by the number of ratings one member has, and a rating is a deliberate act rather than a hot path. ## The control panel ### The control panel has its own session, with its own timeout **MyBB:** an "admin session" keyed to the board login, with a configurable timeout, plus an optional `ADMIN_BRANCH`-style secret URL. **Here:** a row in `admin_sessions` minted by re-entering the password, with a 30-minute idle timeout, an 8-hour absolute ceiling, and its own cookie (`Path=/admin`, `SameSite=Strict`). A board password change revokes it. **Why:** the threat is an administrator's own browser being used by somebody else, not a password being guessed. A board session lasts days by design; an ACP session that inherited that would make an unattended laptop a board takeover. Separating them is what lets the ACP timeout be short enough to matter. **Cost:** an administrator types their password twice — once for the board, once for the panel — and again after half an hour away. That is the intended price, and the sign-in screen says what it buys. ### The re-authentication clock is separate from the activity clock **MyBB:** the admin session has one timestamp, refreshed on every request. **Here:** `last_seen_at` moves with activity and `authenticated_at` moves only when the password is re-entered. Destructive operations read the second. **Why:** with one timestamp, an administrator who has been clicking around for an hour has a "fresh" session and is never asked again — which makes re-authentication a formality that fires only for people who walked away, i.e. exactly the people who are about to be asked anyway when it expires. **Cost:** a long ACP session asks for the password more than once. Fifteen minutes is the window; it applies only to operations that are destructive. ### The address allowlist is prefixes in the environment, not CIDR in the database **MyBB:** `$config['superadmins']` and an optional IP check in `config.php`. **Here:** `ADMIN_IP_ALLOWLIST`, comma-separated whole addresses or textual prefixes ending in `.` or `:`. Empty means no restriction. **Why:** env rather than a setting, because the allowlist defends against a stolen administrator credential and storing it where that credential could edit it defeats the point. Prefixes rather than CIDR, because a mask is a thing people get wrong by one bit and the failure mode is locking yourself out. And the check runs *before* the board session is read, so a request from outside the list cannot learn that the panel exists. **Cost:** no `/28`-style precision, and no way to change it without a redeploy. Both are deliberate. A deployment behind no proxy — where no forwarded address header arrives — is refused outright when a list is configured, which is the documented failure direction rather than a silent bypass. ## Attachments and avatars ### An attachment is re-encoded, and until it is, it does not exist **MyBB:** an upload is checked against a list of allowed extensions and MIME types, stored, and served. `verify_attachment` looks at the file's magic bytes for images; the file itself is kept as uploaded. **Here:** PNG and JPEG are decoded to raw pixels and written back out by an encoder. The stored object is the encoder's output. The uploaded bytes are held in a separate, unservable object until that succeeds, and are then deleted. A row is `pending` until the re-encode finishes, and nothing will serve a `pending` row. **Why:** validation cannot make a file safe, and no amount of it can. A valid PNG with a ZIP appended after its `IEND` chunk passes every check MyBB makes and every check anyone could make, because the file genuinely *is* a valid PNG. So does one with a payload in an EXIF block aimed at whichever decoder opens it next. None of that survives a decode and re-encode, because the output is written from pixels and has never seen the original bytes. **Cost:** an image is not visible for as long as the queue takes — usually seconds, up to a minute on a board whose tick is the only worker. EXIF is gone, including the orientation tag and any colour profile, which is a real loss for photographers and a real gain for everybody who did not mean to publish where they took the picture. Animated GIF is not accepted at all rather than being silently flattened to one frame. ### Four file types, not an operator-configurable list **MyBB:** the ACP has an attachment-types screen; an operator adds any extension and MIME type they like. **Here:** PNG, JPEG, PDF and ZIP, as a constant. **Why:** a format is on the list only if the board can make a claim about the bytes it serves — either "this was re-encoded" or "this is served as an opaque download and never rendered". A configurable list is a way to accept a format nothing can process, and the switch would be offering an operator a choice the code cannot honour. `text/plain` is the instructive omission: it has no signature, so "is this a text file" can only ever be a guess. **Cost:** no `.docx`, no `.mp3`, no `.7z`, and no way to add one without a release. The admin screen configures *limits*, not *formats*, until something can attest to a new one. ### The download is served by the board, not by the object store **MyBB:** `attachment.php` streams the file through PHP after a permission check. **Here:** the same — a route handler that re-checks `attachment.download` in the attachment's forum, checks that the post and thread are visible to this viewer, and sets `Content-Disposition: attachment` with `nosniff` and a sandboxing CSP. The stored object is always private, even in a public forum, and a signed object-store URL is deliberately not used. **Why:** the parity here is not an accident of implementation. A signed URL is a bearer token that outlives the permission that issued it — move a thread into a private forum and every URL handed out in the last hour still works — and it carries the bucket's headers rather than ours, which is where the safety of serving member-supplied bytes actually lives. **Cost:** the bytes go through the app, so a large attachment costs the board bandwidth and, on a serverless platform, function time. Revisit if the `FileStore` port ever grows the ability to sign *with* response headers. ### Files are submitted with the post, in one form **MyBB:** the composer uploads each attachment over its own request, keyed to a post id or a "posthash" for a post that does not exist yet, and the abandoned ones are swept later. **Here:** the file input is part of the reply form and the files arrive with the message. There is no upload step and no draft token. **Why:** it works with JavaScript off, which the posthash flow does not without a page round trip that loses the typed message. It also removes a whole class of state — a draft attachment waiting for a post that may never come — and with it the sweep for abandoned drafts. **Cost:** a browser cannot repopulate a file input, so a submission that fails validation loses the chosen files even though the message survives. That is true of every no-JS upload. The editor islands are where an incremental upload belongs, and it should be an enhancement over this path rather than a replacement for it. ### An avatar is re-encoded and locked, never linked and never deleted **MyBB:** three ways to have one — upload, a remote URL, or Gravatar. An upload is checked for dimensions and extension and stored as sent. A moderator's remedy is to delete it. **Here:** upload only, decoded and re-encoded from raw pixels like every other image on this board, fitted to 200×200, and unservable until that succeeds. A moderator locks it rather than deleting it. **Why no remote URL:** rendered directly it is a tracking beacon that reports every reader's IP, referrer and user agent to a third party on every page view — which the requirement forbids in as many words. Fetched server-side to avoid that, it is SSRF: an attacker supplies a URL and the board makes the request, from inside whatever network it runs in. The only safe version ends at fetch-validate-re-encode-store, which is what the upload path already is, with an SSRF problem bolted on the front. Gravatar is the remote-URL problem with a better-known third party. **Why a lock and not a delete:** the same argument that applies to signatures, and stronger here. Deleting destroys the evidence — an appeal about a signature can read the text that was kept; an appeal about an image has nothing at all unless the file survives. Locking stops it rendering, stops the member replacing it, keeps the object, and records a reason the member is shown. **Cost:** a member who wants their existing avatar from elsewhere has to download it and upload it, and nobody's Gravatar follows them here. The image loses its EXIF, which is the point. And an upload is not visible for as long as the queue takes, which the screen says rather than leaving somebody to conclude it failed. ### An avatar keeps its aspect ratio; it is not cropped to a square **MyBB:** scales to fit the configured maximum, same as here. **Here:** scaled to fit 200×200, aspect preserved, no crop. **Why:** cropping decides for somebody which part of their picture matters, and a board cannot know. A theme that wants circles can have them in CSS, which is reversible; a crop at upload time is not. **Cost:** a wide image renders wide, so a theme laying out a fixed square has to say `object-fit: cover` rather than assuming. The default theme does. ## Reading and discovery ### "New posts" lists threads, and its window is a day rather than your last visit **MyBB:** `search.php?action=getnew` runs a search for posts made since `lastvisit` and shows the *threads* those posts are in, ordered by last post. A member's `lastvisit` is stamped by the session handling on each new visit. **Here:** `/discover/new` lists threads whose last post landed in the last 24 hours. `/discover/today` uses midnight in the member's own timezone. Both are thread listings ordered by last post, permission-filtered in SQL and keyset-paged. **Why:** a genuine "since your last visit" needs the per-thread read state keeps, and folding it into this query means either a join per row or a second query per page — against a feature specified as *budgeted*, with a test that holds it to one query on two board sizes. MyBB pays that cost as a full search run per page view, which is why the screen is one of the heaviest on a large board and why several hosts disable it. **Cost:** a member who has been away a week sees a day, not a week. The label says so, and `/discover/participated` and the subscription list are the two screens that do not have a window at all. When that read state and this query can be joined without a per-row cost, the window becomes a fallback for guests rather than the rule. ### A busy thread is one row, not forty **MyBB:** the "new posts" and "today's posts" screens are searches over *posts*, so a thread with forty new replies contributes forty hits — collapsed into one thread row by the results template, but counted, paged and ranked as forty. **Here:** every discovery view returns one row per thread, and the `limit` is a limit on threads. **Why:** "what is new" is a question about conversations. Paging over posts means a page of twenty hits can be three threads, the page count is a number about something the member cannot see, and one busy thread buries the rest of the board. **Cost:** the row says *when* the last post was and *who* wrote it, but not how many of the replies are new to this reader — that is the same read-state dependency the window above names. ### Invisible browsing hides you from the count as well as the list **MyBB:** `users.invisible` removes a member from the online list. The board's "N users online" figure is computed from the same session table and the administrator-visible list shows invisible members marked. **Here:** the same setting, and it removes the member from the **count** too, for everybody who cannot see them. Staff — anybody with `modcp.access` — see them listed and marked. **Why:** a member removed from the list but left in the total can be found by subtraction. "Eleven online, ten listed" names an invisible member as surely as printing their name would, and it does it on a page that refreshes. Hiding somebody halfway is worse than not offering the setting, because the member believes they are hidden. **Cost:** the visible total is a different number for staff and for everybody else, which looks like a bug until you know why. The "most ever online" record counts everybody, invisible included, because it is a fact about the board's traffic rather than about who anybody may see — so the record can exceed any total a member has ever been shown. ### An online list says where somebody is only when the reader may know **MyBB:** the online list shows each user's location as a description derived from the script they are on ("Viewing Thread X"), and the thread and forum titles are resolved without reference to the reader. Private forums leak by title through this screen on stock MyBB, which is why several plugins exist to suppress it. **Here:** the location is resolved **in the query, against the reader's own permissions**. A forum they cannot see arrives at the page as null and renders "Somewhere on the board" — there is no title in the data for a theme, a feed or a debug dump to print. A thread needs its forum to be nameable *and* the thread itself to be in the reader's content scope, so a moderator reading a soft-deleted thread does not put its title on the front page. **Why:** the alternative is to fetch titles and let the page decide, which puts the decision in every theme anybody writes, and one of them will get it wrong. **Cost:** the online list cannot be cached across readers — it is one query per reader, which is why it is one query. The location is stored without a query string, so "reading page 4" is not distinguishable from "reading page 1", and a member browsing the admin panel shows as somewhere on the board rather than in the panel. ### Board totals are a rollup with a timestamp, not a live count **MyBB:** `datacache` holds the board statistics and they are updated on the write path — every new post, thread and member updates the cached figures. **Here:** a scheduled task recomputes them every five minutes and the panel says when it last ran. `computed_at` is null before the first run and the panel says "not counted yet" rather than showing zeroes. **Why:** the member count is a count of `users`, and the board index is the most-requested page there is. Updating on the write path is the other way to avoid that scan, and it makes every post pay for a number nobody reads on the posting screen — plus a cache that drifts from the truth with no way to notice. The thread and post totals are summed from the root forums, where the counters have already accumulated the tree, so those two are nearly free; the member count is what sets the shape. **Cost:** the numbers on the index can be five minutes old. They say so. And a brand-new board shows "not counted yet" until the first tick, which is a truer statement than three zeroes. ## Feeds, URLs and the sitemap ### A feed shows what a signed-out visitor sees, whoever fetches it **MyBB:** `syndication.php` resolves the requesting user from their cookie and filters the feed against that member's forum permissions, so a signed-in member's feed carries their private forums. **Here:** every feed is built from the **guest** scope, regardless of who asks. **Why:** a feed URL is handed to software, not read in the browser that holds the cookie. Aggregators, corporate proxies and CDNs cache one response per URL and serve it to everybody who asks for that URL next — so a personalised feed under a shared address is a private forum served to a stranger, in somebody else's cache, with nothing about the request that caused it visible from here. MyBB's version is only safe because most readers never send the cookie at all, which means the personalisation mostly does not happen. **Cost:** a member cannot follow a private forum by RSS. That is a real capability lost, and the honest replacement is subscriptions, which deliver to a member rather than to a URL. A per-member feed token would restore it — a capability URL, cached safely because it is unguessable — and it is a feature with its own decisions to make, not a flag on this one. ### Every page of a thread is its own canonical URL **MyBB:** emits no canonical link. Duplicate URLs for one page — `showthread.php` with and without a `pid`, with and without `page=1` — are left for the crawler to work out. **Here:** every thread and forum page carries `rel="canonical"` naming **the page being read**, with the permalink, cursor and reveal parameters dropped. **Why:** the tempting version points every page at page 1, and it is worse than having none: it asks a crawler to drop every page but the first from its index, which is why so many forums are searchable only for their opening posts. What a canonical is actually for here is collapsing `?post=812`, `?after=…` and `?reveal=…` — three ways to reach one document. **Cost:** a permalink to post 812 is canonicalised to the page containing it, so a search result lands on the page rather than the post. The anchor still works for anybody who follows the original link. ### The sitemap is an index of chunks, ordered by id **MyBB:** ships no sitemap. Plugins that add one generally emit a single document. **Here:** `/sitemap.xml` is always an index. Chunks are 5,000 URLs, keyset-paged on the thread id ascending. **Why:** one document does not survive the target data volume, and switching shapes later means every crawler that cached the old one has to rediscover the new — so it is an index from the first thread. The ordering is by id rather than by activity because a crawler works through the chunks over hours or days, and a boundary that moved whenever somebody posted would make the crawl skip threads and revisit others. **Cost:** a chunk request costs one skip into the primary-key index to find its own starting id — the only OFFSET in this codebase — because the index names the chunks by number before any of them exists. It is paid by crawlers, not readers. ## Parity passes ### The conversion pass The corpus is `packages/markdown/src/bbcode.test.ts`. Every case below is a difference asserted there, so this document and the converter cannot disagree without a test failing. **No MyBB source artefacts are copied**, and that is not only a licensing rule: MyBB's parser is a pile of regular expressions accumulated over fifteen years, and reproducing them would reproduce their bugs as though the bugs were the specification. The corpus is written from the *observable* side — the BBCode people actually type, the shapes that appear in real posts — and every case is a claim about what a reader sees after the conversion. ### Where the conversion is exact Bold, italic, strikethrough, both link forms, images, quotes with their attribution, code blocks, both kinds of list, and case-insensitive tag names. `[B]` matters more than it looks: boards are full of it, and a converter that matched only lower case would turn fifteen years of emphasis into literal text. ### Difference: the text is escaped on the way through **MyBB:** a post is BBCode; `*`, `_`, `#` and `[` in it are punctuation. **Here:** those are Markdown syntax, so the converter escapes them. A post that said `a * b` still says `a * b`; a post that said `# 1 fan` is not a heading; a variable called `snake_case` does not come out half italic. **Why:** without it, every post on a converted board containing an asterisk changes meaning on the day of the upgrade — silently, and in a way nobody could find afterwards. **Cost:** an author who opens an old post in the editor sees backslashes where one was genuinely needed. That is the visible half of a guarantee whose alternative is invisible. ### Difference: URLs and CSS this renderer refuses **MyBB:** has historically rendered `[url=javascript:…]`, `[img]data:…[/img]` and `[color=red;background:…]` with varying degrees of filtering by version. **Here:** refused. The link keeps its text and loses its destination — no anchor, no image element, no attribute. **Why:** each is an XSS in a forum post, and "MyBB renders it" is a description of MyBB's history rather than a requirement. **Cost:** an imported post containing one shows the URL as text instead of a link. That is the intended outcome, and it is visible rather than silent. ### Difference: malformed input is handled consistently **MyBB:** leaves an unclosed tag as literal text in some contexts and swallows it in others, depending on which regular expression ran first. Its behaviour on crossed tags (`[b][i]x[/b][/i]`) likewise depends on the order of replacement. **Here:** the input is parsed, so the answer is the same everywhere. An unclosed tag converts to the text it is; a crossed pair keeps its content; a stray closing tag does not eat the line. **Why:** consistency is worth more than bug-compatibility here, and the rule is chosen so nothing is silently dropped — a post whose second half vanished is worse than a post with a visible `[b]` in it. **Cost:** posts that relied on MyBB's particular recovery may read slightly differently. In every case the text is present. ### Difference: an unknown tag becomes text **MyBB:** drops unknown tags in some paths. **Here:** an unknown tag is escaped and shown, and its content is kept. **Why:** dropping is the worse default. A custom MyCode the old board defined would otherwise silently erase whatever it wrapped, and nobody would know which posts were affected. ### Gap: tags MyBB has and this conversion does not translate `[table]`, `[align]`, `[font]`, `[video]`, `[php]`, and any custom MyCode. Their content survives as text, which is legible; a tag that vanishes takes its content with it. `[table]` is the one most likely to matter — Markdown has tables, and a converter for MyBB's table syntax is a plausible later addition rather than a missing piece of this one. ### Search relevance is ranked within a window **MyBB:** ranks every matching post, however many there are. **This board:** ranks the **20,000 most recent matches** when sorting by relevance. Sorting by newest or oldest reads the whole corpus. ### Why `order by ts_rank_cd(...)` cannot use an index. A relevance score depends on the query, so there is nothing to have indexed in advance, and Postgres has to score every matching row before it can name the top twenty. The load run measured what that costs on a board of 2,343,847 posts: a term matching 96% of them took a **p95 of 5.5 seconds**. The GIN index was present and used throughout — the cost was the ranking, not the lookup. A term matching 1,171 posts, through exactly the same code, took 35 ms. Bounding the ranked set brought the first case to 98 ms. MyBB has the same problem and does not solve it; it is simply rarely provoked, because boards small enough to run MyBB comfortably do not have two million posts of anything. ### Who notices **Almost nobody, and that is the argument.** For any term matching fewer than 20,000 posts the window contains the entire match set and the results are *identical* — same rows, same order. The difference appears only for a term so common that "the single most relevant post" is not a meaningful thing to ask for, and there the answer becomes "the most relevant of the recent ones", which is what a member searching for a ubiquitous word actually wants. The alternative was a five-second page, which is not a page. ### What was not done A search extension (RUM, or an external engine) would rank the whole corpus quickly and properly. It is a runtime dependency and, on most managed Postgres, an extension the operator cannot install — so it stays out until somebody has a board that needs it. --- <!-- docs/development.md · Development --> # Development Running the board on your own machine — to read the code, write a theme, or send a patch. Not to run a board anybody else can reach: that is the [Quickstart](./quickstart.md), and `localhost:3000` is not something people can post on. **You need:** Node 22 or newer, pnpm 10, and Docker if you want a real database. ## Getting it running ```sh git clone https://github.com/meith-dev/meith.git cd meith pnpm install pnpm dev ``` That is already a working board on <http://localhost:3000>, with **no database at all** — see [fixture mode](#fixture-mode-and-why-it-exists) below. Enough to click through every reading surface, try a theme, and see what the software is. For anything that writes — posting, moderation, the installer — you need Postgres: ```sh docker compose -f docker-compose.dev.yml up -d # Postgres on port 55432 cp .env.example .env ``` Then set two lines in `.env`: ```sh DATA_SOURCE=postgres DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/community_test ``` ```sh pnpm community migrate pnpm dev ``` Open <http://localhost:3000/install> and run the installer, which is the same one a real deployment runs. It seals itself when it finishes; on a scratch database that is fine, and `docker compose -f docker-compose.dev.yml down -v` gives you a clean one. The dev compose file uses a **named volume**, so the board survives the container being recreated. It is the `-v` that throws it away. ### Fixture mode, and why it exists With no `DATABASE_URL`, `DATA_SOURCE` falls back to `fixture`: deterministic in-memory repositories with a sample board in them. It is not a mock layer bolted on for tests — it is a driver behind the same interfaces as Postgres, and three things depend on it. - **A fresh checkout runs.** `pnpm install && pnpm dev` needs nothing else, which is the difference between somebody trying this project and closing the tab. - **The production build needs no database.** `next build` prerenders, and a build that opened a connection would fail wherever the build runs before the database is reachable. CI builds in fixture mode; so does the Docker image. - **The test suite is fast**, because most of it never touches a socket. What it deliberately does *not* do is fake a write. Fixture mode has no installer, no presence store and no statistics store, and each says so rather than returning a convincing zero. ## The workspace A pnpm workspace. Applications in `apps/`, everything else in `packages/`, `themes/` and `plugins/`. | Directory | Package | What it is | |---|---|---| | `apps/community` | `@meith/web` | The board itself. `pnpm dev`, on port 3000. | | `apps/web` | `@meith/site` | meith.dev — the landing page and these documents. `pnpm site:dev`, on port 3100. | | `apps/worker` | `@meith/worker` | The tick, as a long-running process. | | `apps/cli` | `@meith/cli` | The operator CLI. `pnpm community …`. | | `packages/*` | `@meith/*` | The domain: accounts, forums, posts, authorization, search, drivers, and the rest. | | `themes/*`, `plugins/*` | | The default theme, a second worked theme, and the reference plugin. | | `examples/*` | | Reference code to copy, not installed: the worked example plugin and theme. See [`examples/README.md`](https://github.com/meith-dev/meith/tree/main/examples). | Every `@meith/*` import resolves through tsconfig path aliases straight to `src/index.ts`. There is no build step between packages, which is why a typecheck is fast and why `pnpm workspace:check` exists — see [the invariant scripts](#the-scripts-that-fail-on-purpose). How those packages relate — the layers, what may import what, and why — is [Architecture](./architecture.md). ## The commands | | | |---|---| | `pnpm dev` | The board, on 3000. | | `pnpm site:dev` | meith.dev, on 3100. | | `pnpm community <command>` | The operator CLI against your `.env`. `--help` lists it. | | `pnpm test` | The whole suite. `pnpm test:watch` while you work. | | `pnpm typecheck` | The workspace. `:app` and `:site` are the two Next projects. | | `pnpm lint` | ESLint. | | `pnpm verify` | **Everything CI's `static` job runs.** Run it before opening a pull request; CI's other jobs build the image and drive a browser. | | `pnpm test:e2e` | Playwright: the no-JS paths, the staff panels, and the accessibility checks. It starts its own Postgres and two dev servers — nothing to install. | `pnpm verify` is the one that matters: invariant guards, the generated-document checks, lint, dependency rules, all three typecheck projects and the full test suite. If it passes locally, CI's `static` job will too. > [!IMPORTANT] > **Do not run `pnpm format`.** It reformats the entire tree — over a thousand > files — and buries whatever you were actually changing. Format the files you > touched, or let your editor do it on save. ## The database in tests `pnpm test` needs no database at all. Repository tests, migrations, anything asserting on real SQL — all of it runs against PGlite, a real Postgres compiled to WebAssembly, booted in-process per suite with the checked-in migration SQL applied. One suite is the exception: `packages/db/src/client.pg.test.ts` needs a real Postgres *server*, because PGlite bypasses the client driver and has accepted a write every real server rejected. It skips unless `TEST_DATABASE_URL` is set: ```sh docker compose -f docker-compose.dev.yml up -d TEST_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/community_test pnpm test ``` CI's `migrations` job sets it, so "it passed locally" covers everything except that one seam — and CI covers the seam. ## The browser suite, and who it can be `pnpm test:e2e` starts everything it needs: a PGlite behind the Postgres wire protocol, a `next dev` against it, and a second empty database and server for `/install`. There is nothing to install and nothing to leave running. **Almost every spec runs with JavaScript disabled.** That is the point rather than a flourish: this board's claim is that a native `<form>` does the work and the islands are optional, so a suite that tested the enhanced path would prove the opposite of what is claimed. A spec that needs a member **registers through the form** — the seeded accounts carry a hash nothing can match, so the only way in is the way a member takes. A spec that needs *staff* cannot do that, because a registration always lands in the Registered group. Two accounts are therefore seeded with a real password, both named in `e2e/support/config.ts`: | Account | Group | For | |---|---|---| | `admin` | Administrators | The control panel. Bypasses forum permissions (R4.2). | | `e2e_moderator` | Super Moderators | Moderation. Deliberately **not** an administrator, so the specs prove the moderator's own path rather than the bypass — and prove the panel is shut to them. | Use `signUp`, `signInAsModerator` and `enterAdminPanel` from `e2e/support/session.ts` rather than repeating the forms; `signUp` also asserts the username fits the board's 30-character maximum, because the registration input silently truncates a longer one and the sign-in that follows then fails with "Incorrect username or password". **One spec runs with scripting on**, `admin-panel-live.spec.ts`, and it is the exception that the rule needs. With scripting off a form post is a full navigation, so the page is re-rendered whatever the action did about caching — which makes the whole suite blind to a panel screen that does not refresh its own list. That blindness was hiding four of them. The suite **shares one database across every spec**, in file order. A spec that changes something every page shows — a board-wide announcement, a board setting, a pinned thread — puts it back, or a later file fails for a reason nothing in it can explain. The specs are typechecked by `pnpm typecheck` with everything else. Playwright transpiles TypeScript without checking it, so until `e2e/` was added to the root project a spec that did not compile failed only when it ran — and a support file that did not compile never failed at all. **Passing is not enough — the run also fails on what the board logged.** `e2e/support/server-errors.ts` is a reporter that reads the dev server's output and fails the run on an unhandled server error however many tests passed. It exists because a green run was hiding fifty-six: every control-panel page threw a `ForbiddenError` on a visit its layout had already answered with the sign-in form, and every spec asserting on that form passed over the top of it. ## The scripts that fail on purpose Several gates in `pnpm verify` exist because something once passed every other check and broke on a clean install. Each is a fact about the repository that nothing else reads: | Script | What it catches | |---|---| | `workspace:check` | A package directory with sources and no `package.json`, or a manifest the lockfile has not seen. Both pass every other gate and fail `pnpm install --frozen-lockfile`, which is CI's first step. | | `guards` | Textual invariants — the things a grep can prove and a type cannot. | | `slots:check` | The server/client boundary in theme slots. | | `hooks:wired` | A hook fired by name that the registry does not declare — the typo that would otherwise be a call nothing listens to. It also derives the wired/unwired list that `pnpm plugin:docs` publishes. | | `theme:docs:check`, `plugin:docs:check`, `api:docs:check`, `perf:docs:check` | A generated reference that has drifted from the code it describes. | | `docs:index:check`, `site:docs:check` | A document in `docs/` that no index links to, or that is neither published nor explicitly repository-only. | ## The generated documents Four documents here are written from the code they describe and must not be edited by hand: ```sh pnpm theme:docs # docs/theme-slots.md, from the theme registry pnpm plugin:docs # docs/plugin-hooks.md, from the hook registry pnpm api:docs # docs/rest-api.md, from the route registry pnpm perf:docs # docs/performance.md, from the last load run ``` `pnpm verify` fails when one is stale, deliberately: a reference read by somebody who cannot see the source is worse than no reference when it is wrong. ## The documentation itself `docs/*.md` is the one editable copy. The site at [www.meith.dev/docs](https://www.meith.dev/docs) renders those same files at build time and holds no copy of any of them, so a correction is one edit in one place. Adding a document means putting it in `docs/`, naming it in `apps/web/content/docs.manifest.json` — under `documents` to publish it, or `internal` to keep it in the repository — and running: ```sh pnpm site:docs # rewrites the documentation table in README.md and checks the set ``` Both index checks fail on a file that is in neither list, so a new document cannot quietly go unlinked. ## Before opening a pull request 1. `pnpm verify` passes. 2. New behaviour has a test that fails without it. 3. [Next.js conventions](./nextjs-conventions.md) — the decisions that would otherwise be re-litigated in every review. ## Where to read next | You want to | Read | |---|---| | How the system fits together | [Architecture](./architecture.md) | | The conventions this codebase holds to | [Next.js conventions](./nextjs-conventions.md) | | To write a theme | [The theme API](./theme-api.md) | | To write a plugin | [The plugin API](./plugin-api.md) | --- <!-- docs/architecture.md · Development --> # Architecture How Meith fits together: the processes it runs as, the layers the code is cut into, the path a request takes, and the seams — data, themes, plugins — that everything else hangs off. It is the map for [working on Meith itself](./development.md); nothing here is needed to run a board. Two properties do most of the explaining, and the rest of this document is largely their consequences: - **Domain logic is framework-free.** Business rules live in packages that import neither Next.js nor a SQL client, behind repository interfaces. That is what lets the same code run in a web request, the worker, the CLI and a unit test without a database. - **The boundaries are checked, not trusted.** Every layering rule below is a hard error in CI — dependency-cruiser for imports, textual guards for what a type cannot see, and a probe for each guard proving it still fires. A convention nobody checks is a convention nobody keeps. ## The processes A running board is one Docker image started three ways — `COMMUNITY_ROLE` picks the entry in [`docker-entrypoint.sh`](../docker-entrypoint.sh) — plus Postgres: ```mermaid flowchart LR reader([reader]) --> proxy["your reverse proxy"] proxy --> web subgraph image ["one image, COMMUNITY_ROLE picks the entry"] migrate["migrate — one-shot, runs first"] web["web — apps/community, Next.js"] worker["worker — apps/worker, ticks every 60s"] cli["forum CLI — docker compose run"] end web --> pg[("Postgres")] worker --> pg migrate --> pg cli --> pg web --- uploads[/"uploads volume"/] worker --- uploads ``` The compose files ([`docker-compose.yml`](../docker-compose.yml) and the Coolify variant) wire the dependency order: `migrate` waits for Postgres to be healthy, `web` and `worker` wait for `migrate` to exit successfully. The `uploads` volume is shared read-write between web and worker because avatars, attachments and the board logo go through one file store; CI proves the sharing by writing a file from one container and reading it from the other. One deployment deliberately breaks this shape: [demo mode](./demo-mode.md). Its compose file ([`docker-compose.demo.coolify.yml`](../docker-compose.demo.coolify.yml)) runs no worker — a `ticker` service drives `POST /api/system/tick` against the web container instead, because the demo's reset task must clear a cache that lives in the web server's own process — carries no volumes, and replaces the `migrate` one-shot with a `seed` that builds the demo board outright. This shape is why the README calls serverless a non-starter: a board needs a scheduler that fires every minute (the worker — or a cron hitting `POST /api/system/tick`), a disk that survives restarts (the volume, or S3), and a process that outlives a request (the queue drain). The architecture assumes all three. The fourth app, `apps/web`, is **meith.dev itself** — the landing page and these documents. It shares no code with the board: its only coupling to the rest of the workspace is reading `docs/*.md` and the generated references off disk at build time. Every page of it is prerendered. It ships as its own image ([`Dockerfile.site`](../Dockerfile.site), a standalone Next.js build) deployed as a separate resource beside the board ([`docker-compose.site.coolify.yml`](../docker-compose.site.coolify.yml)) — it holds no data and reads nothing the board writes, and nobody self-hosting a board needs it. ## The layers A pnpm workspace: applications in `apps/`, everything else in `packages/`, `themes/` and `plugins/`. Imports point strictly downward: ```mermaid flowchart TD apps["apps/ — forum · worker · cli (the composition roots)"] runtime["@meith/runtime — shared non-Next wiring"] db["@meith/db — every Postgres adapter"] drivers["@meith/drivers — queue · cache · files · mail"] domain["~30 domain packages — accounts, forums, threads, posts, moderation, …"] core["@meith/core — types · env · errors · cache tags · permission registry · driver ports"] apps --> runtime apps --> db apps --> drivers apps --> domain runtime --> db runtime --> drivers db --> domain domain --> core db --> core drivers --> core ``` The load-bearing rules, each a named `error` in [`.dependency-cruiser.cjs`](../.dependency-cruiser.cjs): | Rule | What it forbids | |---|---| | `domain-no-next` | A domain package importing `next/*`, `react` or `server-only`. Logic that reaches for `cookies()` cannot run in the worker or the CLI. | | `domain-no-raw-sql-client` | A domain package importing `postgres`, `pg` or `drizzle-orm`. Only `@meith/db` speaks SQL. | | `domain-no-infra-impl` | A domain package importing `@meith/db` or `@meith/drivers`. Domain code sees interfaces, never implementations. | | `core-depends-on-nothing` | `@meith/core` importing any sibling. The graph needs a floor. | | `no-app-internals-from-packages` | A package reaching back up into `apps/`. | | `themes-are-presentation-only` | A theme importing the database or domain logic — theming must not be a security surface. | | `plugins-use-the-kit-only` | A plugin importing anything but `@meith/plugin-kit`. The host isolates failures, not privilege; a plugin with database access can read anything. | | `ui-is-presentation-only` | `@meith/ui` fetching data. | `runtime`, `db`, `drivers` — and `demo`, whose seed and reset speak SQL and run migrations by nature — are deliberately *outside* the protected domain list; "does this module choose an implementation?" is the question that decides which side of the line a package sits on. ### The floor: `@meith/core` Everything imports core; core imports nothing. It holds what every layer must agree on: - **One environment reader** — a zod schema in `env.ts`; `assertRuntimeEnv()` runs once at process start (`apps/community/instrumentation.ts`), so a bad deploy dies at boot rather than 500ing on the first page that reads the offending variable. - **The error taxonomy** — `ValidationError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitedError`. Each maps to a status and a rendered page; callers key off the types. - **Cache tags** — every tag name spelled once in `CacheTags`, because a writer invalidating `"forum-tree"` while a reader cached under `"forumTree"` is stale data no test catches. - **The permission registry** — `PERMISSION_FIELDS` in `permissions.ts`: 46 typed fields (27 forum-scoped, 19 global), each with a `kind` that fixes how values combine across groups: `boolean` is OR, `numeric` is MAX with `0` meaning unlimited, `negative` is AND — a restriction any exempting group lifts everywhere. - **The four driver ports** — `QueueDriver`, `CacheDriver`, `FileStore`, `MailDriver`, bundled as `Drivers` in `ports.ts`. These are the only infrastructure interfaces core declares. Repository interfaces are *not* in core. Each lives beside the logic that consumes it — `ForumRepository` in `@meith/forums`, `TaskRepository` in `@meith/tasks` — so a package's port and its policy travel together. ### The domain The middle of the graph is nearly flat: almost every domain package depends on `@meith/core` alone, with a handful of deliberate edges (`threads` uses `markdown` and `polls`; `avatars` builds on `attachments`; `signatures`, `messages` and `notifications` render through `markdown`). In broad strokes: | Area | Packages | What lives there | |---|---|---| | Identity | `accounts`, `groups`, `admin` | Password and token crypto, sessions, bans, group promotion rules, the ACP's second session with its own clocks and IP allowlist. | | Authorization | `authorization` | The only code that knows how permissions resolve — see [the request path](#a-request-end-to-end). | | Structure & content | `forums`, `threads`, `posts`, `polls`, `drafts`, `attachments`, `avatars` | The forum tree (materialised paths), thread and reply composers, the post editor, and the rule that an upload is made safe by re-encoding, not by validating. | | Members | `profile-fields`, `messages`, `relations`, `reputation`, `signatures` | Custom profile fields, private messages, buddy/ignore, ratings, signatures rendered with a deliberately narrower Markdown feature set. | | Moderation & safety | `moderation`, `antispam` | Approval queue, reports, thread tools and surgery, warnings; rate limits counted in the database, honeypot, question captcha, held first posts. | | Rendering | `markdown` | The one place member text becomes markup: parse → render, word filter, BBCode migration, URL safety. | | Delivery | `notifications`, `subscriptions`, `events` | The single "somebody needs to be told" path, thread/forum following, and the transactional outbox. | | Platform | `settings`, `tasks`, `search`, `api` | The typed settings registry, the scheduled-task contract, the search provider seam, and the REST route registry as data. | | Lifecycle | `install`, `upgrade`, `import`, `create-meith` | The installer's decisions, the upgrade planner, the resumable MyBB import, the project scaffolder. | Each package exports services and **ports**; none of them can see how the ports are implemented. ## The data layer ### `@meith/db` The single package that speaks to Postgres: `postgres.js` under `drizzle-orm`, one process-wide lazy client (`getDb()`), and roughly seventy `Postgres*Repository` classes — one adapter per domain port. Options that are load-bearing rather than tuning: `prepare: false` (transaction-mode poolers) and a small pool (`DATABASE_POOL_MAX`, default 3). Migrations are committed SQL files under `packages/db/migrations/`, ordered by a drizzle-kit journal — some generated from the schema, many hand-written because they are data or online-DDL-sensitive. The runner (`runMigrations()`) takes a session-level advisory lock so concurrent deploys serialise, and has exactly four callers: `community migrate`, `community upgrade`, the web installer, and the `COMMUNITY_ROLE=migrate` one-shot container. Search is Postgres full-text: a `tsvector` column on `posts` (weighted so the thread's title beats a passing mention), a GIN index, keyset paging on `(rank, id)`, and a bounded relevance window — measured at 5.5 s unbounded versus 140 ms bounded on a 2.3M-post board. The column is written on insert and by a resumable backfill task rather than being `GENERATED`, because adding a generated column to a large table is an exclusive-lock outage. ### `@meith/drivers` Implementations of the four core ports, selected by environment: | Port | Implementations | Selected by | |---|---|---| | Queue | `PostgresQueue` (a `jobs` table, `FOR UPDATE SKIP LOCKED`), `MemoryQueue` | `QUEUE_DRIVER` | | Cache | `NextCacheDriver`, `MemoryCache` | `CACHE_DRIVER` | | Files | `LocalFileStore`, `S3FileStore` | `FILESTORE_DRIVER` | | Mail | `ConfiguredMailDriver` → SMTP, HTTP or log | `MAIL_DRIVER`, **or the settings table** | Mail is the deliberate exception to env-only selection: it is board configuration an admin edits at runtime, so `ConfiguredMailDriver` resolves its transport per send — environment first, then the settings table. Demo mode pins mail to nowhere *before* both, because on a demo the settings table is written by whoever visited last. `DATA_SOURCE` does not pick drivers directly; it derives their defaults (`postgres` implies the Postgres queue and the Next cache, `fixture` implies memory) and the composition roots pick the repository set. Every implementation of a port runs the same contract suite from `@meith/testkit`, under its own name. ### Fixture mode With no `DATABASE_URL`, `DATA_SOURCE` falls back to `fixture`: in-memory repositories behind the same interfaces, seeded with a deterministic sample board. Reads are real; **writes are absent rather than faked** — the write-side fields of the container are `null`, and the surfaces that need them say so instead of pretending. Three things depend on this mode: a fresh checkout runs with nothing installed, `next build` prerenders without a database (CI and the Docker build both rely on it), and most of the test suite never touches a socket. Tests that *are* about SQL semantics get a real engine: PGlite runs the actual migration files in-process for the unit suite, and serves the Postgres wire protocol as the database behind the browser tests — plus one suite against a real server in CI for the places PGlite is too forgiving. ### Three composition roots There is intentionally no single factory that wires everything for everyone: - **`apps/community/src/server/container.ts`** — the request path's root, marked `server-only`. Branches on `DATA_SOURCE`, builds every repository, and wraps `ForumRepository` in its caching decorator in *both* branches, so a caching bug shows up in fixture tests too. - **`apps/cli/src/context.ts`** — the CLI's own root (the container is `server-only` and pulls `next/headers`). It shares *policy* instead of wiring: `community user:create` reads the board's stored auth settings so a CLI-created user satisfies the registration form's rules. - **`apps/worker/src/index.ts`** — refuses to start unless `DATA_SOURCE=postgres`, then loops. What they share is `buildSchedulerBundle()` from `@meith/runtime` — the one factory for the task list, its workers and the event handlers, where an absent dependency means the task is not registered at all, rather than registered and failing. ## A request, end to end ```mermaid sequenceDiagram participant B as browser participant E as proxy.ts participant P as page.tsx participant A as Authorizer participant R as repository B->>E: GET /thread/why-meitheal Note over E: cookie triage only — no DB, no authz E->>P: request + path header P->>P: getActor() — session cookie to Actor, guest fallback P->>A: forumMatrix(actor, forumId) A-->>P: resolved ForumPermissions P->>A: can("thread.view") · contentScope(...) P->>R: findById(id, { scope }) Note over R: visibility filtered in the query, never after it R-->>P: rows P->>P: view model → theme slots → plugin filters P-->>B: HTML ``` **`proxy.ts` is not a boundary.** The Edge middleware does cookie-shaped triage — bounce a cookie-less request to `/login`, send a remember-me cookie through single-use rotation at `/auth/resume`, mint the opaque cookie that lets a guest be counted as online — and nothing else. Every page and every Server Action re-checks authorization itself, because an action is a public HTTP endpoint whatever rendered the form. The guest cookie is the one thing the Edge *writes*, and it is minted there because only the middleware can set a cookie on an ordinary page response; it carries nothing but randomness, no code path turns it into an actor, and the presence row it stands for is written by the render, which has the database the Edge does not. **Authorization is one implementation with no way around it.** The `Authorizer` answers `can(actor, action, target)` synchronously over resolved permission sets; resolution (`resolveForumMatrix`) walks the forum's ancestor chain nearest-first *per group*, then combines across groups by each field's kind. Content visibility is a `scope` object compiled into the SQL `where` clause — pages, feeds, search and the REST API all pass through it, which is what the README means by "no path that reads around the rules". **Pages assemble, packages decide.** A page resolves params, reads through the container, builds a JSON-shaped view model in `src/view/`, and hands it to theme slots. Mutations are Server Actions with one shape: parse the form, re-check authorization, call a domain command, map domain errors to form state, redirect outside the `try`. **The REST API is the same stack, not a sibling.** One catch-all route (`/api/v1/[...path]`) dispatches through `ROUTES` — a data table in `@meith/api` of method, path, scope, cost — in a fixed order: match, token, scope, rate limit, then the same `ActorSource` and `Authorizer` as the pages, then a handler that calls the same domain command the web form calls. [`rest-api.md`](./rest-api.md) is generated from that table, and CI fails when they disagree. ## Background work Anything that cannot be afforded inside a request leaves it through the transactional outbox, and everything asynchronous is driven by one scheduler: ```mermaid flowchart LR subgraph tx ["one transaction"] w["write the row"] --> c["counters, in the same tx"] w --> o[("outbox")] end o -- "outbox.relay, 60s" --> j[("jobs queue")] j -- "queue.drain, 60s" --> h["handlers — idempotent, at-least-once"] t["the tick — worker loop, or POST /api/system/tick"] -.claims due tasks.-> o ``` **Events are emitted in the transaction that writes the data** — `emit()` takes the transaction handle explicitly, so a rolled-back write emits nothing. A relay task drains the outbox onto the jobs queue; handlers are idempotent without exception, because at-least-once delivery is the contract. **The tick is a database claim, not a cron expression.** `tick()` runs each task whose interval has elapsed, claiming it with one conditional `UPDATE` on the `tasks` table — so any number of instances can tick concurrently and a task runs once, with a stale-claim timeout for workers that die mid-run. Every task is written as an idempotent *catch-up* operation ("flush what is outstanding"), never "run at 03:00", so a missed day is caught up rather than lost. Eighteen built-in tasks ride this: the outbox relay, queue drain and instant subscriptions at 60 s, down through digest and sweep work to counter reconciliation every six hours. Plugin tasks join the same schedule under a namespaced id. [Demo mode](./demo-mode.md) adjusts the list at both ends: webhook delivery is never registered — the task does not exist rather than existing and refusing — and `demo.reset` joins, added in the web container's composition root rather than `buildSchedulerBundle()`, because that is the only root whose in-process cache the reset can invalidate. The tick has two drivers — the worker process (in-process, every 60 s, keeps running when the web container is down) and the `TICK_SECRET`-guarded HTTP route for deployments where a cron must do it. Same `tick()`, same claim semantics, no coordination needed between them. The demo deployment is the shape that runs on the HTTP driver alone, for the cache-locality reason above. ## The extension surfaces Themes and plugins extend the board through two frozen contracts, and neither can reach past its kit — the dependency rules above make that structural, not aspirational. **A theme fills slots.** `@meith/theme-kit` declares 29 named slots as data, each `server` or `client` — exactly two are client, both editor islands, because a client `PostBit` would ship every post list to the browser. Slot props are view models proven JSON-shaped at compile time (`Serialisable<T>`); no `Date`, no functions, no rows. A slot never renders another slot — the page resolves both and passes rendered regions in, which is what lets a child theme override one slot and have it apply everywhere. Themes resolve their `extends` chain once at boot, and a theme with a missing slot fails the deployment, not the first request that needed it. The server/client boundary is enforced three ways: in the types, at `defineTheme()`, and by a textual check (`pnpm slots:check`) that catches the case the other two cannot — a synchronous component in a `"use client"` file. **A plugin declares hooks.** `@meith/plugin-kit` registers 95 hooks — filters that transform a value, events that notify — with typed payloads. Payloads carry a `ViewerRef` (an id and a guest flag), never an `Actor`: an `Actor` is the input to authorization, and handing one over invites the plugin to make its own permission decisions. No hook exists inside `can()` or the visibility filter, deliberately. A plugin is a declarative object — settings, SQL migrations the *host* runs, scheduled tasks, admin pages, region contributions, lifecycle callbacks — validated at `definePlugin()`. Failure containment is one `try/catch` around every handler call: a throwing filter keeps the previous value, five failures auto-disable the plugin for that instance, and there are deliberately no timeouts — JavaScript cannot abort a running handler, so a "timeout" would return control while the handler keeps its database connection. Slow calls are measured and logged instead. Plugin hooks and domain events are different systems that share some names: a hook is synchronous, in-request, best-effort; a domain event is durable work through the outbox. The app fires both — the hook after the commit, so a plugin is never told about a thread that may still roll back. Both registries generate their references — [`theme-slots.md`](./theme-slots.md) and [`plugin-hooks.md`](./plugin-hooks.md) — and `pnpm verify` fails when either drifts from the code. The policy documents are [the theme API](./theme-api.md) and [the plugin API](./plugin-api.md). ## One more renderer There are two Markdown pipelines in the repository, and they are deliberately not one. `packages/markdown` renders *member* text inside the board — it constructs safe HTML rather than sanitising it, applies the word filter, and exposes narrower feature sets for signatures. The docs site has its own build-time renderer in `apps/web` for *repository* text — these documents — with build-time syntax highlighting and the diagrams on this page. Member input and repository prose have different threat models and different feature needs; sharing a renderer would force one to carry the other's rules. ## What keeps the shape honest The architecture survives contact with contributors because `pnpm verify` checks it mechanically: dependency-cruiser for every arrow in the layer diagram, textual guards (each with a probe proving it still fires) for the invariants a type cannot express, the slot-boundary check, a check that every declared hook has a call site, and staleness checks for every generated reference — including the manifest that publishes these documents. The browser suite holds the same line at runtime: a reporter reads the dev server's output and fails the run on any unhandled server error, however many tests passed. The full list, with what each gate catches, is in [Development](./development.md#the-scripts-that-fail-on-purpose). ## Where to read next | You want | Read | |---|---| | To run it on your machine | [Development](./development.md) | | The per-PR conventions behind these boundaries | [Next.js conventions](./nextjs-conventions.md) | | To write a theme | [The theme API](./theme-api.md) | | To write a plugin | [The plugin API](./plugin-api.md) | | The deployment shapes in detail | [Deploying by hand](./self-hosting.md) | | The board that resets itself | [Demo mode](./demo-mode.md) | --- <!-- docs/nextjs-conventions.md · Development --> # Next.js conventions The decisions that would otherwise be re-litigated in every pull request. Link this from your PR description. > [!NOTE] > Everything here is drawn from code that exists. The file paths are real, and > the failure each rule prevents has actually happened in this repository. > > If you need to depart from a rule, say so in the PR description rather than > quietly doing something else. ## The rules, in one table If you read nothing else on this page, read this. | Rule | Where it is enforced | |---|---| | `app/` reads through the container, not `@meith/db` | Review — two admin pages currently break it | | `"use client"` on leaf components only — never a page, never a layout | Review, and `pnpm slots:check` for themes | | Every Server Action re-checks authorization itself | Review | | `redirect()` goes **outside** the `try` | Review | | Never return a credential in `FormState` | Review — this shipped once, as an account-takeover hole | | `logger()` is called where you log, never bound at module scope | Guard `no-module-scope-logger` | | Cache tags are spelled once, in `CacheTags` | Review | | A cached region never reads `cookies()`, `headers()`, `getActor()` or `getUserId()` | Guard `no-request-state-in-cache` | | Every counter has a recount | Review | | Event handlers are idempotent | Review | | A slot never renders another slot | Review | | View models are JSON-shaped | The compiler, via `Serialisable<T>` | --- ## Where things live ``` apps/community/ app/ Routes only: page.tsx, layout.tsx, route.ts src/server/ Server Actions, the container, request context src/view/ Typed page view models (the theme-facing contract) src/components/ App-specific components; theme slots live in themes/ proxy.ts Cookie triage. NOT authorization. themes/default/ src/theme.ts The manifest: defineTheme({ slots: { … } }) src/slots/ One file per slot. Its "use client" status is checked. src/tokens.ts The typed mirror of globals.css. Kept in sync by a test. ``` A file under `app/` should be short enough to read in one screen. If a page is long, the length is domain logic that belongs in a package, or view-model assembly that belongs in `src/view/`. **`app/` reads through the container in `src/server/container.ts`, not `@meith/db`.** Held by review rather than a tool — dependency-cruiser has no rule for it — and two admin pages (`admin/users/[id]/merge`, `admin/forums/[id]`) currently import `@meith/db` directly. Do not add a third; the rule is the direction of travel. --- ## Server Components by default `"use client"` goes on **leaf interactive components only** — never a page, never a layout. The rule exists because of one number: a guest thread page must ship near-zero JavaScript. Marking `PostBit` as a client component would send the entire post list to the browser and give away the product's main advantage. `theme-kit` declares a server/client kind per slot and the build fails if a theme crosses it. In practice the split looks like the auth forms: - `src/components/auth/login-form.tsx` — `"use client"`, because it calls `useActionState`. - `app/(auth)/login/page.tsx` — a Server Component that resolves `searchParams` and renders the form. The page stays a Server Component even though its child is not. That is the shape to copy. **Anything crossing into a client component must be plain serializable data.** No class instances, no `Date` inside a deeply nested object you have not checked, no functions other than Server Actions. --- ## Server Actions Live in `src/server/*-actions.ts`, marked `'use server'` at the top of the file. ### The adapter shape An action is a **thin adapter**. Parse `FormData`, validate, call a command in a domain package, redirect. All five auth actions follow this and new ones should look boring next to them: ```ts export async function createThreadAction( _prev: FormState, form: FormData, ): Promise<FormState> { const title = field(form, 'title') // 1. read FormData const actor = await getActor() // 2. who is asking const { threads, authorizer } = getContainer() try { authorizer.require(actor, 'thread.post', target) // 3. re-check authz await threads.create({ ... }) // 4. call the command } catch (err) { return toFormState(err, { title }) // 5. domain error → state } redirect(`/thread/${id}`) // 6. redirect on success } ``` ### Rules that are not negotiable **Every action re-checks authorization itself.** Rendering the form is not authorization — an action is a public HTTP endpoint, and nothing stops someone POSTing to it directly. `proxy.ts` is not a boundary either; it only decides whether to bounce a cookie-less request to `/login`. **`redirect()` goes outside the `try`.** It works by throwing, so a `catch` that swallows it turns a successful action into a silent no-op. Look at `auth-actions.ts`: every `redirect` is after the `try/catch`, never inside. **Return a serialisable `FormState`, never throw to the client.** Domain errors (`ValidationError`, `ConflictError`, `ForbiddenError`) are the expected failure channel and become a message on the form. Anything unrecognised is logged and becomes a generic message — see `toFormState`. > [!CAUTION] > **Never return a credential in `FormState`.** It is serialised into the client > payload. > > This is not hypothetical: the password-reset action returned a live reset token > to the browser, and it was an account-takeover hole. --- ## Forms and `useActionState` Every page on the no-JavaScript list must work with JavaScript disabled. That is a hard requirement, not an aspiration, and it shapes how forms are written: - The `<form action={...}>` must work as a native submit. No `onSubmit`, no `preventDefault`, no client-side validation the server does not repeat. - `useActionState` renders the error the action returned. With JS off the page re-renders server-side and shows the same message. - Echo the user's input back in `FormState.values` so a failed submit does not blank the form — **except the password**. > [!IMPORTANT] > **Islands enhance; they never enable.** If removing a client component breaks a > page, it was not an island. Write the server path first and the island second. ### Forms that live in a theme slot A page whose whole content is a form — the composer, and every editor after it — splits in two: the **theme** renders the page around it, the **app** renders the `<form>` into a region. The reason is mechanical rather than stylistic: the form element carries a Server Action reference, and those are not plain data, so they never cross the theme contract. Controls are built from the shared token-styled primitives in `src/components/auth/form-controls.tsx`, which is what keeps an app-owned form looking like part of the theme. A slot model should not carry a prop no theme can fill. If a value only exists after a submit — a preview of what was typed, a per-field error — it belongs inside the form region, not in the view model. --- ## Errors Use the taxonomy in `@meith/core`: `ValidationError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitedError`. Each maps to a status and a rendered page. Throwing a bare `Error` for a user-facing failure is a bug: callers key off the taxonomy. `saveSettings` threw a plain `Error` on an invalid value, which meant the admin panel would have shown "Something went wrong" instead of the actual problem. --- ## Logging `logger()` is called **where you log**, never bound at module scope: ```ts // Wrong — guarded by `no-module-scope-logger`. const log = logger({ module: 'x' }) // Right. logger({ module: 'x' }).warn({ err }, 'something') ``` A module-level instance captures the request context once at import time (i.e. empty), so every line loses its `requestId` — and it builds pino eagerly, which reads `env` and turns importing the module into an environment validation. That broke `next build` once. Never log a password, a token, or a full IP at default level. Pino's redaction covers `token`-shaped keys but **not** a token interpolated into a URL string, which is how one escaped. --- ## Caching Read `packages/core/src/cache.ts` before caching anything. - **Every tag name is spelled once**, in `CacheTags`. Never write a tag as a literal: a writer invalidating `"forum-tree"` while a reader cached under `"forumTree"` is stale data that no test catches. - **`cachedGlobal` is for global data only.** If a value varies by actor it must not go through it. > [!CAUTION] > A cached permission-filtered page is how private forums leak. This is the > reason the caching harness exists at all. - **Invalidate after the write, never before.** Clearing first opens a window where a concurrent read repopulates from the pre-write state and nothing clears it again. `CachedForumRepository` pins this ordering with a test. - A cached region may not read `cookies()`, `headers()`, `getActor()` or `getUserId()` — guarded by `no-request-state-in-cache`. --- ## Counters and event handlers A denormalised counter has three obligations, and a change that adds one has to satisfy all three — the thread and forum counters are the worked example: - **Write it in the transaction that writes the content.** Counters and the row they describe move together or not at all. `applyCreatedContentCounters()` takes the caller's transaction handle for exactly this reason — it has no ambient database handle to reach for. - **Emit the event in the same transaction.** Anything that cannot be afforded inside the request — an ancestor walk, a fan-out — goes through the outbox, so a rolled-back write emits nothing. - **Give it a recount.** Incremental maintenance drifts. Every counter needs a path back to a computed truth, batched and resumable (`PostgresCounterRecount`). A counter with no recount is a number that is wrong forever after one crash. Event handlers live in `packages/runtime/src/event-handlers.ts` and are built per container, never registered onto a module-level singleton — registration throws on a duplicate id, and a dev server re-evaluating the module would hit that on its second pass. **Handlers are idempotent, without exception.** The relay marks an outbox row dispatched after the enqueue returns and the queue re-runs a job whose worker died mid-handler, so every handler is delivered at least once and sometimes twice. A handler that writes a *computed* value gets this for free; one that applies a **delta** must record what it has applied — the counter roll-up ledger is the pattern to copy. --- ## Theme slots Read `packages/theme-kit/src/slots.ts` before adding a page. **Every slot declares `server` or `client`, and there are two client slots.** Both are editor islands. Adding a third means editing a test that argues against it (`slots.test.ts`) — deliberate friction, because a client slot is bytes shipped to every viewer of the page it appears on. `pnpm slots:check` fails the build if a server slot's module starts with `"use client"`, *and* if a client slot's module does not. The second direction matters: such an island renders once and never becomes interactive, which looks correct in a screenshot and does nothing when clicked. **A slot never renders another slot.** The page resolves both and passes the rendered one in: ```tsx const ThreadView = requireSlot(theme, 'ThreadView') const PostBit = requireSlot(theme, 'PostBit') <ThreadView thread={vm.thread} forum={vm.forum} replyHref={vm.replyHref} regions={{ posts: vm.posts.map((post) => <PostBit key={post.id} post={post} regions={{ actions: … }} />), pagination: <Pagination {...vm.pagination} />, quickReply: null, }} /> ``` If `ThreadView` imported `PostBit` itself, a child theme overriding `PostBit` would be ignored inside the parent's `ThreadView`. One place resolves slots, so an override applies everywhere. **Write the slot map literally in the manifest**, one bare imported identifier per slot. A map built by spreading cannot be statically checked, and `slots:check` fails rather than skipping it. --- ## View models Every page has a typed view model in `src/view/`. Pages resolve params, build a view model, and hand it to components; they do not pass rows around. **View models are JSON-shaped**: no `Date`, no `Map`, no functions. `theme-kit` proves this at compile time for every slot model. The reason is not React — it is that a view model is also the REST API's payload, and that a `Date` pushes formatting into every theme, where it becomes a timezone-dependent hydration mismatch. A timestamp crosses as `TimeModel` (`iso` + a preformatted `label`); paging crosses as resolved hrefs, never a function that builds them. **Never link to a route that does not exist.** The user-panel builder earned this rule by example: while the profile and control-panel screens were unbuilt, `buildUserPanelModel` returned an empty link list rather than advertising pages that 404. The screens exist now and the list is populated — the rule outlives the example, so when a view model covers a page that is not built yet, render the absence. **Never expose a database row to a component or an API response.** Row shapes change with migrations, and a component reading `row.password_hash` because it was in scope is exactly the accident the rule prevents. Naming: `<Page>ViewModel` for the page's model (`ThreadViewModel`), and plain nouns for the pieces (`PostBitModel`). These are a **public API** for themes — adding a field is minor, renaming or removing one needs a deprecation cycle. --- ## Testing - Domain logic is unit-tested without a database. - Anything whose behaviour is SQL semantics gets a **real** Postgres via PGlite (`createTestDb`), not a mock. Mocks agree with whatever you assumed. - **Boot once per suite, clear tables in `beforeEach`.** Creating a database per test applies every migration per test and starts tripping timeouts. - Any list page needs a **query-budget assertion** against the seeded board (`expectQueryBudget` in `@meith/testkit`). An N+1 does not fail a test — it passes, slowly, and only on an empty board. - **Prove a new test can fail.** Break the code deliberately, watch it go red, put it back. > [!TIP] > A test that has never failed is not known to test anything. `pnpm > guards:probe` applies the same idea to the textual guards. --- ## Before opening a PR `pnpm verify` — guards, guard probes, the slot boundary check and its probe, lint, dependency-cruiser, all three typechecks, tests. `pnpm build` if you touched anything under `app/` — and if you touched a theme, check the class you used is actually in the built CSS: Tailwind scans `themes/` only because `globals.css` says so, and a missing `@source` is a green build that renders unstyled.