Metadata-Version: 2.4
Name: tyrtulia
Version: 1.1.0
Summary: A headless forum for small multi-user servers
Keywords: forum,bbs,tilde,pubnix,hal,rest,sse,textboard
Author: Andros Fenollosa
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Flask
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: POSIX :: BSD :: FreeBSD
Classifier: Operating System :: POSIX :: BSD :: OpenBSD
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: BBS
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
Requires-Dist: flask>=3.1
Requires-Dist: gevent>=26.9
Requires-Dist: gunicorn>=26.2
Requires-Dist: huey>=3.5
Requires-Dist: linkify-it-py>=2.0
Requires-Dist: markdown-it-py>=4.0
Requires-Dist: msgspec>=0.22
Requires-Dist: nh3>=0.3
Requires-Dist: packaging>=25.0
Requires-Dist: peewee>=4.5
Requires-Dist: typer>=0.27
Requires-Python: >=3.14
Project-URL: Homepage, https://git.andros.dev/andros/tyrtulia
Project-URL: Repository, https://git.andros.dev/andros/tyrtulia
Project-URL: Issues, https://git.andros.dev/andros/tyrtulia/issues
Description-Content-Type: text/markdown

# Tyrtulia

A headless forum for small multi-user servers.

Tyrtulia is a forum without a user interface, built for small communities on shared Unix servers (tildes, pubnixes, club servers): tens or hundreds of people who know each other. The product is the API; everyone uses, or writes, their own client.

The name comes from *tertulia*, the Spanish word for a group of people who meet regularly to talk.

Tyrtulia is a modern, clean adaptation of [BBJ (Bulletin Butter & Jelly)](https://github.com/bbj-dev/bbj), a textboard for tilde communities. It keeps BBJ's ideas (a small server that is easy to run, several text clients against one API, reading without an account, validation rules shared with clients), rebuilds its foundations on current standards and adds what BBJ was missing: notifications, real time, edit history, search and moderation. See the [comparison with BBJ](#comparison-with-bbj).

## Table of contents

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Getting started for administrators](#getting-started-for-administrators)
- [Clients](#clients)
- [Design principles](#design-principles)
- [Authentication and accounts](#authentication-and-accounts)
- [Roles](#roles)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Administration CLI](#administration-cli)
- [Behaviour](#behaviour)
- [Validation](#validation)
- [Representations](#representations)
- [API reference](#api-reference)
- [Error catalog](#error-catalog)
- [Comparison with BBJ](#comparison-with-bbj)
- [Contributing](#contributing)
- [License](#license)

## Features

- Hypermedia REST API (HAL): clients only know `GET /` and navigate by following links.
- Passwordless authentication: Unix socket for local users, signed SSH challenge for remote access.
- Threads with linear replies and navigable conversations (`replyTo` / `replies`).
- Standard Markdown, with sanitized HTML returned alongside the source.
- Mentions, notifications and thread subscriptions.
- Full edit history with diffs and revert.
- Pinned and closed threads; soft deletion of threads and posts.
- Server-side full-text search, accent and case insensitive.
- Atom feeds for the forum and for each thread.
- Real-time notifications over Server-Sent Events (SSE).
- Moderation: roles, read-only users, bans, hidden posts, public moderation log and reports.
- Rate limiting per user and per IP.
- Discoverable validation through JSON Schema.
- Plain-text configuration (TOML), no admin endpoints.

Out of scope on purpose: images, attachments, categories, anonymous posting.

## Requirements

- Linux or a BSD system, such as FreeBSD or OpenBSD (the Unix socket identity relies on `SO_PEERCRED` on Linux and `getpeereid()` on BSD).
- Python 3.14.
- SQLite 3 with FTS5.
- OpenSSH client (`ssh-keygen`), used to verify signatures.

## Installation

Tyrtulia is published on PyPI as [`tyrtulia`](https://pypi.org/project/tyrtulia/). It installs the `tyrtulia` command (the server and the administration CLI). Install it in its own environment, owned by root and readable by the service user.

With [uv](https://docs.astral.sh/uv/):

```sh
# The interpreter goes to /opt/python so the service user can read it
UV_PYTHON_INSTALL_DIR=/opt/python uv venv --python 3.14 /opt/tyrtulia
uv pip install --python /opt/tyrtulia/bin/python tyrtulia
ln -s /opt/tyrtulia/bin/tyrtulia /usr/local/bin/tyrtulia
```

With pip:

```sh
python3.14 -m venv /opt/tyrtulia
/opt/tyrtulia/bin/pip install tyrtulia
ln -s /opt/tyrtulia/bin/tyrtulia /usr/local/bin/tyrtulia
```

To have a look without installing anything: `uvx tyrtulia --help`.

Some dependencies are compiled when there is no binary wheel for the system:

- **FreeBSD:** `pkg install python314 py314-sqlite3 rust libev libuv c-ares`, and set `GEVENTSETUP_EMBED=0` while installing so gevent uses the system libraries.
- **OpenBSD:** `pkg_add python rust`.

To upgrade, install the new version over the same environment (`uv pip install --python /opt/tyrtulia/bin/python --upgrade tyrtulia` or `/opt/tyrtulia/bin/pip install --upgrade tyrtulia`), run `tyrtulia migrate` as the service user and restart the service.

## Getting started for administrators

A first instance on Linux with systemd, step by step. Run the commands as root. BSD notes follow each step.

1. **Create the service user and its directories.**

   ```sh
   useradd --system --no-create-home --shell /usr/sbin/nologin tyrtulia
   install -d -o tyrtulia -m 0700 /var/lib/tyrtulia
   install -d /etc/tyrtulia
   ```

   BSD: `pw useradd tyrtulia -d /nonexistent -s /usr/sbin/nologin` on FreeBSD, `useradd -d /var/empty -s /sbin/nologin _tyrtulia` on OpenBSD; the database goes to `/var/db/tyrtulia`.

2. **Write the configuration.** List yourself in `admins` with your Unix name. Every key is described in [Configuration](#configuration).

   ```sh
   cat > /etc/tyrtulia/config.toml <<'EOF'
   name = "Forum of example.org"
   admins = ["alice"]
   moderators = []
   EOF
   chown tyrtulia /etc/tyrtulia/config.toml
   ```

   BSD: also set `[listen].socket = "/var/run/tyrtulia/tyrtulia.sock"` and `[storage].database = "/var/db/tyrtulia/tyrtulia.db"`. On FreeBSD the file usually lives in `/usr/local/etc/tyrtulia/config.toml`.

3. **Create the database.**

   ```sh
   sudo -u tyrtulia tyrtulia --config /etc/tyrtulia/config.toml migrate
   ```

4. **Install and start the service.**

   ```sh
   curl -o /etc/systemd/system/tyrtulia.service https://git.andros.dev/andros/tyrtulia/raw/branch/main/contrib/systemd/tyrtulia.service
   systemctl daemon-reload
   systemctl enable --now tyrtulia
   ```

   FreeBSD: download `https://git.andros.dev/andros/tyrtulia/raw/branch/main/contrib/freebsd/tyrtulia` to `/usr/local/etc/rc.d/tyrtulia` (mode `0555`), then `sysrc tyrtulia_enable=YES` and `service tyrtulia start`. OpenBSD: download `https://git.andros.dev/andros/tyrtulia/raw/branch/main/contrib/openbsd/tyrtulia` to `/etc/rc.d/tyrtulia` (mode `0555`), then `rcctl enable tyrtulia` and `rcctl start tyrtulia`.

5. **Check it as a normal user.** The first request through the socket creates your account, with the `admin` role because you are listed in `admins`:

   ```sh
   curl --unix-socket /run/tyrtulia/tyrtulia.sock http://localhost/users/me
   ```

   Every local user can do the same: having a Unix account on the server is the invitation. With [Tyrtulia TUI](https://git.andros.dev/andros/tyrtulia-tui) installed, `tyrtulia-tui` opens the forum straight away.

6. **Open it to the outside (optional).** Enable HTTP on the loopback and put a web server in front of it with TLS. Event streams need buffering off.

   ```toml
   [listen]
   http = "127.0.0.1:7099"
   trusted_proxies = ["127.0.0.1"]
   ```

   ```nginx
   location / {
       proxy_pass http://127.0.0.1:7099;
       proxy_set_header Host $host;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-Forwarded-Proto $scheme;
       proxy_buffering off;
   }
   ```

   Users of the server register their SSH keys through the socket and log in from anywhere with a signed challenge.

7. **Invite people without a Unix account.** Create an SSH key account with their public key:

   ```sh
   sudo -u tyrtulia tyrtulia --config /etc/tyrtulia/config.toml account create bob --key bob.pub
   ```

8. **Day to day.**
   - Change admins, moderators or limits in the configuration and apply it without downtime: `systemctl reload tyrtulia` (`service tyrtulia reload` or `rcctl reload tyrtulia` on BSD).
   - Logs go to the journal (`journalctl -u tyrtulia`) or to syslog on BSD.
   - Make [backups](#backups) of the database.
   - Moderation happens through the API with the moderator links; the [Administration CLI](#administration-cli) covers accounts, identities and the BBJ import.

## Clients

Tyrtulia is headless: people use it through a client.

- **[Tyrtulia TUI](https://git.andros.dev/andros/tyrtulia-tui)**: the terminal client, built with Textual and published on PyPI as [`tyrtulia-tui`](https://pypi.org/project/tyrtulia-tui/). On the server it connects through the Unix socket with no login; from anywhere else it logs in with the user's SSH key. Install it once for every user of the server, or let each user run `uv tool install tyrtulia-tui`.

Any program that speaks HTTP can be a client: it only needs `GET /` and the links in the [API reference](#api-reference).

## Design principles

Modern standards are preferred over custom formats: HAL, RFC 9457, RFC 7396, RFC 6570, JSON Schema, SSE, UUIDv7 and ISO 8601.

| Topic | Decision |
|---|---|
| Discovery | Only `GET /` is known. Everything else is discovered through `_links`, using `href`, `method`, and optionally `templated` (RFC 6570), `type` and `schema`. `_embedded` holds embedded resources. |
| Permissions | Expressed as links. If you can edit, an `edit` link appears; if a thread is closed, `reply` disappears. Links that do not apply are omitted, never `null`. Clients never check roles. |
| Methods and status codes | Real HTTP methods (`GET`, `POST`, `PATCH`, `PUT`, `DELETE`), correct status codes and `Location` on creation. |
| Partial updates | `PATCH` with `application/merge-patch+json` (RFC 7396). |
| Errors | RFC 9457, `application/problem+json`, with a catalog of `type` values and useful links inside the error (for example, the `diff` in an edit conflict). `title` and `detail` are always in English. |
| Media type | Responses are `application/vnd.tyrtulia.v1+json`. Clients may send `Accept` with that type, `application/json` or `*/*`; anything else returns `406 not-acceptable`. |
| Pagination | Cursor based: clients follow `next` and `prev`, and never build cursors (they are opaque). Posts are paginated by number (`after`, `before`), so a client can ask only for what is new since the last post it saw. `perPage` defaults to 20, maximum 100. |
| Concurrency | Each post has a strong `ETag` per version (`"v2"`). A thread's `ETag` is the version of its first post, because the title is versioned with it. Editing a post body or a thread title requires `If-Match`; pinning or closing does not. Missing header: `428`. Stale: `412`. |
| Conventions | JSON in `camelCase`, dates in ISO 8601 UTC, identifiers are UUIDv7 and opaque to clients. `postNumber` and `version` are positions, not identifiers, and stay integers. Posts are numbered from 1. |
| Users in responses | The author is embedded in each resource (`_embedded.author`). |
| Contract | The contract is the link relations (`rel`) and the representations. Paths are an implementation detail. |

## Authentication and accounts

There are no passwords. There are two ways in:

| Method | For | How |
|---|---|---|
| **Unix socket** | Users of the server itself | The server also listens on a Unix socket, and the operating system tells it which Unix user is connecting (`SO_PEERCRED` on Linux, `getpeereid()` on BSD). No token needed. |
| **SSH key** | Accounts created by the CLI, and Unix accounts connecting from outside | Challenge signed with the private key (standard OpenSSH signature, `ssh-keygen -Y sign`, namespace `tyrtulia`). Returns a session token. |

- The socket speaks exactly the same HTTP API: a resource does not depend on the transport.
- The socket identity is guaranteed by the operating system and cannot be spoofed. System accounts (UID below `min_uid`, 1000 by default) and the users listed in `excluded_users` (`nobody` by default, whose UID changes between systems) are excluded. If the server runs in a container that does not share the host users, the socket is not available and SSH is used.
- **SSH challenges** expire after 2 minutes and are single use. The response is the same whether the user exists or not, and a bad signature returns the same message in both cases, so account names are not leaked.
- **Accepted keys:** `ssh-ed25519`, `ecdsa-sha2-*` and `rsa-sha2-*` of 3072 bits or more. Each user manages their own keys (list, add, delete, with fingerprint and last use). On a shared server, a client can import the keys the user already has authorized for SSH.
- **Session tokens** are the only kind of token. They expire after 30 days without use or on logout. The server only stores their hash. They carry the `tyrtulia_ses_` prefix so secret scanners can detect them.
- **Banning** revokes the user's sessions and blocks their SSH keys: they cannot log in (`403 banned`), and any socket request receives `403 banned`.

### Account types

There is no HTTP registration. The Unix user is the source of truth. There are exactly two kinds of account:

- **Unix account:** created automatically on a Unix user's first socket request of any kind (a `GET` is enough), and bound to the pair UID + Unix name.
- **SSH key account:** created by the operator with the CLI, together with its SSH key, for people without a user on the server. The CLI rejects any name that already exists as a Unix user.

Rules:

- An SSH key account can never be used through the socket.
- A Unix account can register SSH keys (from the socket) to connect from outside.
- **Unix identity:** on every socket request, the UID and the name must both match the account. If they do not (a UID reused after deleting a user, or a `usermod -l`), the request is rejected with `409 unix-identity-mismatch` and the operator resolves it with the CLI.
- **Deactivated accounts:** when a Unix user no longer exists, its account is deactivated: its sessions are revoked and it cannot log in, not even with SSH keys. Its posts remain, its profile still loads, and the user appears with `deactivated: true`.
- **Name conflicts:** if a Unix user is created later with the same name as an SSH key account, the socket rejects it (`409 username-conflict`) until the operator renames the SSH key account with the CLI. Accounts are never merged and posts are never reassigned.
- **Names used before:** a name an account gave up (by a rename) stays reserved, so a new Unix user with that name also gets `409 username-conflict`. Roles in the configuration go by name, and this keeps them from passing to someone else. When the operator resolves a conflict with `identity resolve --rename-to`, the name is released for the Unix user, unless it is listed in `admins` or `moderators`: then the configuration has to change first.

On a tilde server, having a Unix account is the invitation.

### Profile

Profiles are minimal:

- `about`: free text in Markdown, public. Returned with its sanitized HTML (`aboutHtml`).
- `email`: optional and unverified. Only visible to the user and to moderators, so moderation can contact them. Tyrtulia never sends email.
- `username`: can only be changed on SSH key accounts, and only to a name that no account uses or has ever used, that does not exist as a Unix user and that is not listed in `admins` or `moderators` (`409 username-taken`). A name used once is reserved forever, so a role from the configuration file never passes to someone else through a rename. After a rename, the old name returns `404`. On Unix accounts the username is always the Unix name. Mentions are stored by user id, so they keep working after a rename.

## Roles

| Role | Can | Assigned by |
|---|---|---|
| `admin` | Everything a moderator can. Runs the instance (configuration file and CLI). | Configuration file (`admins`) |
| `moderator` | Edit, revert, hide, unhide and delete any post; edit, pin, close and delete threads; make users read-only; ban. | Configuration file (`moderators`) |
| `user` | Read and participate. | Default |
| `readonly` | Read only: just `GET` requests (notifications and SSE included) and logging out. Cannot interact in any way: post, edit, subscribe, report, mark notifications, change their profile or keys. All of that returns `403 readonly`. Their notifications have no read state and no counter. | A moderator, through the API |
| `banned` | Nothing. Every authenticated request returns `403 banned`, including reads and socket requests. | A moderator, through the API |

- Moderators act on any post, including posts by other moderators and admins. Only roles are protected: moderators cannot change the role of admins or other moderators (`403 cannot-moderate-staff`).
- Unbanning restores the previous role (`user` or `readonly`) and reactivates the SSH keys.
- Anyone can read without authenticating; writing requires an account with the `user`, `moderator` or `admin` role.

## Configuration

There are no administration endpoints. Whoever runs the server edits a plain-text TOML file with the admins, the moderators and the whole instance configuration. Access control is the file system's.

The file is read from `/etc/tyrtulia/config.toml`, unless `--config` or the `TYRTULIA_CONFIG` environment variable point elsewhere.

```toml
name = "Forum of example.org"
admins = ["alice", "bob"]
moderators = ["carol"]

[listen]
socket = "/run/tyrtulia/tyrtulia.sock"   # enabled by default
http = ""                                # disabled; e.g. "127.0.0.1:7099"
trusted_proxies = ["127.0.0.1"]          # real IP from X-Forwarded-For only from here
public_url = ""                          # e.g. "https://forum.example.org"; absolute URLs use it

[storage]
database = "/var/lib/tyrtulia/tyrtulia.db"

[accounts]
min_uid = 1000                           # below this, system accounts: excluded from the socket
excluded_users = ["nobody"]              # never identified through the socket

[rate_limit]
read_per_minute = 300
write_per_minute = 20
login_per_15_minutes = 10
max_sse_per_user = 5
```

- The Unix socket is enabled by default and HTTP is disabled. HTTP is only needed for access from outside the server, usually behind a web server.
- `public_url` is the public origin of the instance. Absolute URLs (error `type`, feed ids and links) are built from it; when it is empty, from the `Host` of each request. Set it when HTTP is enabled.
- Changes apply on restart or on `SIGHUP`, without stopping the service.

## Deployment

The service does not need root: it runs as a dedicated user that owns the configuration file and the database.

- **Reference: native with systemd (Linux).** Dedicated user; `RuntimeDirectory=tyrtulia` with `RuntimeDirectoryMode=0755` to create `/run/tyrtulia` without root; `StateDirectory=tyrtulia` for `/var/lib/tyrtulia`; a socket with `0666` permissions so any local user can connect. The unit is in `contrib/systemd/tyrtulia.service`.
- **Native with rc.d (FreeBSD and OpenBSD).** The scripts in `contrib/freebsd/tyrtulia` and `contrib/openbsd/tyrtulia` run the service as a dedicated user, create `/var/run/tyrtulia` with mode `0755` and start `tyrtulia serve --pidfile`, so `service tyrtulia reload` or `rcctl reload tyrtulia` sends `SIGHUP`. Set `[listen].socket = "/var/run/tyrtulia/tyrtulia.sock"` and keep the database under `/var/db/tyrtulia`. On FreeBSD, build gevent against the system libraries (`pkg install libev libuv c-ares` and `GEVENTSETUP_EMBED=0`).
- The native modes are the only ones with Unix socket access.
- **Private data on disk.** The database holds emails, hidden and deleted posts, history and reports. Tyrtulia creates it with mode `0600` (and fixes the mode of an existing one); the systemd unit adds `StateDirectoryMode=0700` and `UMask=0077`, and the rc.d scripts run with `umask 077`.
- **HTTP on a shared server.** Local users can connect to `[listen].http` on `127.0.0.1` too, and from there `X-Forwarded-For` would be believed. Let only the web server's user reach that port, for example with `iptables -A OUTPUT -o lo -p tcp --dport 7099 -m owner ! --uid-owner www-data -j REJECT` on Linux or `block return out quick on lo0 proto tcp to port 7099 user != www` with pf on the BSDs.
- **`compose.yaml`:** for a quick start, or for instances that only use SSH keys. Inside a container the host users are not visible, so the Unix socket is not available and `[listen].http` must be enabled.

### Backups

Tyrtulia has no backup command: backups are manual. The database is a single SQLite file and can be copied safely while the service runs:

```sh
sqlite3 /var/lib/tyrtulia/tyrtulia.db ".backup /path/to/tyrtulia-backup.db"
```

## Administration CLI

The CLI is the tool of whoever runs the instance (admins and operator are the same thing). It is not a forum client.

| Command | Purpose |
|---|---|
| `tyrtulia serve [--pidfile <file>]` | Start the server. `--pidfile` writes its PID, for rc.d scripts. |
| `tyrtulia migrate` | Apply database migrations. |
| `tyrtulia account create <name> --key <file>` | Create an SSH key account. Rejects Unix user names and reserved names. |
| `tyrtulia account rename <old> <new>` | Rename an SSH key account. Same rules as above. |
| `tyrtulia account add-key <name> --key <file>` | Add an SSH key to an account. |
| `tyrtulia identity resolve <name>` | Resolve a `unix-identity-mismatch` or a `username-conflict`. |
| `tyrtulia import-bbj <bbj.sqlite> [--trust-unix-names]` | Import a BBJ database. |

**Importing from BBJ.** Each BBJ user becomes an SSH key account without keys: they cannot log in until one is added with `account add-key`. If a BBJ username matches an existing Unix user, the import stops and lists the conflicts so the operator can decide case by case, because BBJ registration was open and the same name does not guarantee the same person. With `--trust-unix-names`, those users' posts go to the matching Unix accounts instead, for instances where the original BBJ was only used by the server's users. Anonymous posts are assigned to a fixed `anonymous` account, which can never log in and whose name is reserved. Identifiers are regenerated as UUIDv7, and BBJ's bracket format is converted to Markdown where possible.

The import is all or nothing. Before writing anything it checks every BBJ username and stops, listing all the conflicts at once, when a name:

| Conflict | Why | How to resolve it |
|---|---|---|
| Matches an existing Unix user | The same name does not guarantee the same person. | Rerun with `--trust-unix-names` if the BBJ users were the server's users. |
| Is not a valid Tyrtulia username (`^[a-z_][a-z0-9_-]{0,31}$`, for example uppercase letters or spaces) | Names are never changed silently. | Rename the user in a copy of the BBJ database and import that copy. |
| Is already used by a Tyrtulia account, or was used before (names are reserved forever) | Accounts are never merged and posts are never reassigned. | Rename the user in a copy of the BBJ database, or rename the existing SSH key account with `account rename`. |

`--trust-unix-names` only resolves the first case. Nothing is imported until the list of conflicts is empty.

## Behaviour

### Posts and conversations

- **Markdown:** CommonMark plus GFM tables, strikethrough and autolinks. The source is stored, and sanitized HTML (`bodyHtml`) is always returned as well. Mentions are rendered as links to the user.
- **Linear replies** that can point to the post they answer (`replyToPostNumber`), as in Discourse.
- **Navigable conversations.** Storage and the default view are linear, which keeps pagination by `postNumber`. On top of that, each post exposes the conversation:
  - `replyTo` links to the post it answers, and `replies` links to the posts that answer it. Clients walk the conversation in both directions by following links.
  - `repliesCount`, to show "3 replies" without another request.
  - Optional `?view=tree` on a thread's posts to receive the conversation already nested: up to 8 levels deep (deeper answers stay at the eighth level) and at most 500 replies per page; the linear view shows every post.
- **Read state is a client concern.** The server does not track what each user has read in threads. The client remembers the last `postNumber` it saw in each thread, compares it with `highestPostNumber` and asks for new posts with `?after=`.

### Mentions, notifications and subscriptions

- **Mentions** with `@username`, outside code and block quotes. At most 10 per post; more is rejected with `422 too-many-mentions`. Editing only notifies new mentions.
- **Notifications** are an inbox with the kinds `reply`, `mention`, `watched` and `report` (the last one only for moderators and admins). There is no `quote` kind: to refer to a post you reply to it, which produces a `reply` notification.
- **One notification per post and user.** When a post matches several kinds, the highest priority wins: `mention` > `reply` > `watched`.
- **Who is notified:** never yourself. Read-only users are notified (without read state). Banned and deactivated users are never notified.
- **Thread subscriptions** have three levels. Creating a thread sets you to `watching`; replying does not change your level; the default is `normal`.
  - `watching`: a `watched` notification for every new post, plus replies and mentions.
  - `normal`: only replies to your posts and mentions.
  - `muted`: nothing from that thread, not even mentions.
- Notification excerpts are computed when read, and are empty if the post has been hidden or deleted.
- Notifications are kept indefinitely.

### Editing and deletion

- **Edit history** with diffs and revert. Reverting creates a new version and never rewrites history. A thread's title is versioned with its first post: those revisions include `title`, and reverting the first post also restores the title.
- History is public, except for deleted posts (nobody sees it) and hidden posts (only moderators and the author).
- **No edit time limit:** the public history makes it unnecessary.
- **Pinned threads:** only moderators pin; pinned threads come first in the index, then the rest by last activity.
- **Closed threads:** only moderators can reply.
- **Soft delete:** a deleted post stays as a tombstone (number, author and dates, no body) so numbering is not broken.
- **Deleting a thread:** the author can only delete it if nobody else has replied (their own replies do not count). With replies, only a moderator can delete it; the author can still delete their first post, which becomes a tombstone, while the thread and its replies remain. A deleted thread returns `404` to everyone except moderators.

### Search and feeds

- **Full-text search** on the server over titles and bodies, accent and case insensitive. Query syntax: words, `"exact phrase"`, `OR`, `NOT` and `prefix*`; anything else returns `400 invalid-query`. Each result has a `highlight`: an HTML snippet with the matches in `<mark>`, safe to insert.
- **Atom feeds** for the forum and for each thread, discoverable through the `alternate` relation. Entry ids are `urn:uuid:<id>` and the content is the sanitized HTML.
- Neither feeds nor search include hidden or deleted content.

### Real time

- **One SSE stream per user**, carrying only their notifications (what you watch arrives as `watched`). There are no per-thread or public streams.
- Several connections per user are allowed (several clients at once), up to `max_sse_per_user`.
- Events carry the minimum plus links to the resource (see [the example](#user-events)).
- Resume with `Last-Event-ID` for 24 hours; if the id is gone, the server sends `event: reset`. Heartbeat every 15 seconds.

### Moderation

- **Hidden posts** carry a reason. The body and the reason are only visible to moderators and to the author; everyone else sees that the post is hidden.
- **Public moderation log** with action, reason, moderator and target. Every moderator action is logged, except a moderator editing, reverting or deleting their own posts or threads (that is ordinary authoring).
- **Reports:** a user sends an internal message to the moderators about a post, a thread or a user. It arrives as a `report` notification to every moderator and admin (inbox and SSE). Reports are not public and do not appear in the moderation log.
- **Rate limiting:**
  - `GET` and `HEAD` count as reads; everything else counts as a write. An open SSE connection counts as one read.
  - `POST /ssh-challenges` and `POST /sessions` also count in the login limit, per IP.
  - Through the socket, limits apply per user only; over HTTP, per user and per IP. The real IP is taken from `X-Forwarded-For` only when the request comes from `trusted_proxies`.
  - Responses include the `RateLimit` and `RateLimit-Policy` headers, and `429` comes with `Retry-After`.

## Validation

Every link that sends a body carries a `schema` property pointing to a JSON Schema (draft 2020-12) with the rules: required fields, lengths, formats and allowed values. Clients validate before sending without hard-coding the rules, and the server applies exactly the same ones (`400 validation-failed` otherwise).

| Field | Rule |
|---|---|
| `username` | `^[a-z_][a-z0-9_-]{0,31}$` |
| Thread `title` | 5 to 200 characters |
| Post `body` | 1 to 32,000 characters |
| `about` | Up to 1,000 characters |
| `reason` (edit, hide, ban, read-only) | Up to 500 characters |
| Report `message` | 1 to 2,000 characters |
| SSH key `title` | Up to 100 characters |
| `email` | Email format, up to 254 characters |
| `perPage` | 1 to 100, default 20 |
| Text (title, body, about, reasons, report message, SSH key title) | No control characters except tab and line breaks, and no bidirectional overrides (`U+202A`–`U+202E`, `U+2066`–`U+2069`): they would drive the terminals of readers |
| Request body | Up to 256 KiB (`413 request-too-large`) |

Schemas: `ssh-challenge`, `session`, `profile`, `ssh-key`, `thread`, `thread-update`, `subscription`, `post`, `post-update`, `notification-update`, `notifications-read`, `report`, `moderation`.

## Representations

Examples use these identifiers and users: thread `019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f`, post #4 `019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e`; `alice` opened the thread, `bob` wrote post #4, `carol` is a moderator.

"Can write" below means an authenticated user with the `user`, `moderator` or `admin` role. Every link that changes something also requires being able to write: a read-only author, for example, gets no `edit` link.

### Link objects

```json
{"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/posts", "method": "POST", "schema": "/schemas/post"}
```

| Property | Required | Meaning |
|---|---|---|
| `href` | Yes | Target URL, relative to the instance. |
| `method` | Yes | HTTP method to use. |
| `templated` | No | `true` if `href` is an RFC 6570 template. |
| `type` | No | Media type of the target, when it is not the API's own (`text/event-stream`, `application/atom+xml`, `text/x-diff`). |
| `schema` | No | JSON Schema for the body the link expects. |

### Root

Authenticated as `alice`, through the socket:

```json
{
    "name": "Forum of example.org",
    "_links": {
        "self": {"href": "/", "method": "GET"},
        "latest": {"href": "/threads", "method": "GET"},
        "watching": {"href": "/threads?filter=watching", "method": "GET"},
        "createThread": {"href": "/threads", "method": "POST", "schema": "/schemas/thread"},
        "search": {"href": "/search{?q,author,in}", "method": "GET", "templated": true},
        "user": {"href": "/users/{username}", "method": "GET", "templated": true},
        "me": {"href": "/users/me", "method": "GET"},
        "notifications": {"href": "/notifications", "method": "GET"},
        "events": {"href": "/events", "method": "GET", "type": "text/event-stream"},
        "modlog": {"href": "/modlog", "method": "GET"},
        "alternate": {"href": "/threads.atom", "method": "GET", "type": "application/atom+xml"}
    }
}
```

| `rel` | Shown when |
|---|---|
| `self`, `latest`, `search`, `user`, `modlog`, `alternate` | Always |
| `watching`, `me`, `notifications`, `events` | Authenticated |
| `createThread` | Can write |
| `sshChallenge`, `login` | Not authenticated (never through the socket) |

### UserSummary

Embedded in threads, posts, revisions, notifications and log entries.

```json
{
    "username": "bob",
    "deactivated": false,
    "_links": {"self": {"href": "/users/bob", "method": "GET"}}
}
```

### User

```json
{
    "username": "bob",
    "about": "Sysadmin. I like **SSE**.",
    "aboutHtml": "<p>Sysadmin. I like <strong>SSE</strong>.</p>",
    "role": "user",
    "deactivated": false,
    "postsCount": 42,
    "threadsCount": 5,
    "createdAt": "2026-03-02T10:15:00Z",
    "_links": {
        "self": {"href": "/users/bob", "method": "GET"},
        "threads": {"href": "/threads?author=bob", "method": "GET"},
        "report": {"href": "/reports", "method": "POST", "schema": "/schemas/report"}
    }
}
```

| `rel` | Shown when |
|---|---|
| `self`, `threads` | Always |
| `report` | Can write, and it is not yourself |
| `ban` or `unban`, `readonly` or `unreadonly` | Moderator, and the user is not staff |

### CurrentUser

The `User` of the authenticated user, plus private fields and links.

```json
{
    "username": "bob",
    "about": "Sysadmin. I like **SSE**.",
    "aboutHtml": "<p>Sysadmin. I like <strong>SSE</strong>.</p>",
    "email": "bob@example.org",
    "role": "user",
    "deactivated": false,
    "postsCount": 42,
    "threadsCount": 5,
    "unreadNotificationsCount": 3,
    "createdAt": "2026-03-02T10:15:00Z",
    "_links": {
        "self": {"href": "/users/me", "method": "GET"},
        "threads": {"href": "/threads?author=bob", "method": "GET"},
        "edit": {"href": "/users/me", "method": "PATCH", "schema": "/schemas/profile"},
        "sshKeys": {"href": "/users/me/ssh-keys", "method": "GET"},
        "notifications": {"href": "/notifications", "method": "GET"},
        "events": {"href": "/events", "method": "GET", "type": "text/event-stream"},
        "logout": {"href": "/sessions/current", "method": "DELETE"}
    }
}
```

| `rel` | Shown when |
|---|---|
| `self`, `threads`, `sshKeys`, `notifications`, `events` | Always |
| `edit` | Can write |
| `logout` | Authenticated with a session token (not through the socket) |

`unreadNotificationsCount` is absent for read-only users.

### SshKey

```json
{
    "id": "019a2e10-77cc-7a1d-8b2e-3c4d5e6f7a8b",
    "title": "laptop",
    "fingerprint": "SHA256:3q2+7wXbG0yTuFq8aN1sKkz9Jm5vQe4Lr6Hc2Pd0Tbw",
    "createdAt": "2026-10-01T09:00:00Z",
    "lastUsedAt": "2026-10-18T08:12:44Z",
    "_links": {
        "delete": {"href": "/users/me/ssh-keys/019a2e10-77cc-7a1d-8b2e-3c4d5e6f7a8b", "method": "DELETE"}
    }
}
```

`delete` is shown when the user can write. The `sshKeys` collection carries a `create` link (`POST`, schema `ssh-key`) under the same condition.

### Thread

Detail view, as seen by `alice` (the author):

```json
{
    "id": "019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f",
    "title": "SSE behind Nginx?",
    "pinned": false,
    "closed": false,
    "postsCount": 12,
    "highestPostNumber": 12,
    "subscription": "watching",
    "createdAt": "2026-10-17T18:02:11Z",
    "lastPostedAt": "2026-10-18T09:40:00Z",
    "_embedded": {
        "author": {"username": "alice", "deactivated": false, "_links": {"self": {"href": "/users/alice", "method": "GET"}}},
        "lastPoster": {"username": "carol", "deactivated": false, "_links": {"self": {"href": "/users/carol", "method": "GET"}}},
        "firstPost": {"...": "Post"}
    },
    "_links": {
        "self": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "GET"},
        "posts": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/posts", "method": "GET"},
        "reply": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/posts", "method": "POST", "schema": "/schemas/post"},
        "edit": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "PATCH", "schema": "/schemas/thread-update"},
        "subscribe": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f/subscription", "method": "PUT", "schema": "/schemas/subscription"},
        "report": {"href": "/reports", "method": "POST", "schema": "/schemas/report"},
        "alternate": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f.atom", "method": "GET", "type": "application/atom+xml"}
    }
}
```

- Response header: `ETag: "v2"` (version of the first post).
- `subscription` only for authenticated users. `deletedAt` only for moderators, on deleted threads.
- In collections, threads come without `_embedded.firstPost`.

| `rel` | Shown when |
|---|---|
| `self`, `posts`, `alternate` | Always |
| `reply` | Can write, and the thread is open (or you are a moderator) |
| `edit` | Author or moderator |
| `pin`, `close` | Moderator (same `href` and method as `edit`, schema `thread-update`) |
| `delete` | Author while nobody else has replied, or moderator |
| `subscribe` | Can write |
| `report` | Can write, and it is not your thread |

### Post

As seen by `bob` (the author):

```json
{
    "id": "019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e",
    "postNumber": 4,
    "replyToPostNumber": 2,
    "body": "Add `X-Accel-Buffering: no` to the response. @alice",
    "bodyHtml": "<p>Add <code>X-Accel-Buffering: no</code> to the response. <a href=\"/users/alice\">@alice</a></p>",
    "version": 2,
    "repliesCount": 1,
    "hidden": false,
    "createdAt": "2026-10-17T18:30:00Z",
    "editedAt": "2026-10-17T18:50:00Z",
    "_embedded": {
        "author": {"username": "bob", "deactivated": false, "_links": {"self": {"href": "/users/bob", "method": "GET"}}}
    },
    "_links": {
        "self": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
        "up": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "GET"},
        "replyTo": {"href": "/posts/019a2f4f-1c2d-7e3f-9a4b-5c6d7e8f9a0b", "method": "GET"},
        "replies": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/replies", "method": "GET"},
        "version-history": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions", "method": "GET"},
        "edit": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "PATCH", "schema": "/schemas/post-update"},
        "delete": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "DELETE"},
        "report": {"href": "/reports", "method": "POST", "schema": "/schemas/report"}
    }
}
```

- Response header: `ETag: "v2"`.
- `editedAt` is absent if the post was never edited; `replyToPostNumber` and `replyTo` are absent if it answers no post.
- **Hidden:** everyone sees `hidden: true`; only moderators and the author also receive `body`, `bodyHtml` and `hiddenReason`.
- **Deleted (tombstone):** only `id`, `postNumber`, `createdAt`, `deletedAt`, `_embedded.author` and the `self` and `up` links.
- With `view=tree`, each post carries its replies in `_embedded.replies`.

| `rel` | Shown when |
|---|---|
| `self`, `up`, `replies` | Always |
| `replyTo` | The post answers another post |
| `version-history` | Not deleted; if hidden, only to moderators and the author |
| `edit`, `delete` | Author or moderator, and not deleted |
| `hide` or `unhide` | Moderator (same `href` and method as `edit`) |
| `report` | Can write, and it is not your post |

### Revision

```json
{
    "version": 1,
    "body": "Add `X-Accel-Buffering: no`.",
    "reason": "Initial version",
    "createdAt": "2026-10-17T18:30:00Z",
    "_embedded": {
        "editor": {"username": "bob", "deactivated": false, "_links": {"self": {"href": "/users/bob", "method": "GET"}}}
    },
    "_links": {
        "self": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/1", "method": "GET"},
        "latest-version": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
        "diff": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/1?format=diff", "method": "GET", "type": "text/x-diff"},
        "revert": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/1/revert", "method": "POST"}
    }
}
```

- Revisions of the first post also include `title`.
- In the `version-history` collection, revisions come without `body`.
- `revert` is shown to the author and moderators, on any version except the current one.

### Notification

```json
{
    "id": "019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a",
    "kind": "mention",
    "read": false,
    "excerpt": "Add X-Accel-Buffering: no to the response. @alice",
    "createdAt": "2026-10-17T18:30:00Z",
    "_embedded": {
        "actor": {"username": "bob", "deactivated": false, "_links": {"self": {"href": "/users/bob", "method": "GET"}}},
        "thread": {"id": "019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "title": "SSE behind Nginx?", "_links": {"self": {"href": "/threads/019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f", "method": "GET"}}}
    },
    "_links": {
        "self": {"href": "/notifications/019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a", "method": "GET"},
        "target": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
        "markRead": {"href": "/notifications/019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a", "method": "PATCH", "schema": "/schemas/notification-update"}
    }
}
```

- `_embedded.thread` is present when the target belongs to a thread (a report about a user has none).
- Read-only users get no `read` and no `markRead`.

### Collections

```json
{
    "_embedded": {"threads": [{"...": "Thread"}]},
    "meta": {"perPage": 20, "hasNext": true, "hasPrevious": false},
    "_links": {
        "self": {"href": "/threads", "method": "GET"},
        "next": {"href": "/threads?cursor=eyJrIjoiMjAyNi0xMC0xOFQwOTo0MDowMFoifQ", "method": "GET"}
    }
}
```

- The embedded key is the plural of the item: `threads`, `posts`, `revisions`, `notifications`, `sshKeys`, `results`, `entries`.
- `next` and `prev` only appear when there is a next or previous page.
- The `notifications` collection adds `meta.unreadCount` and a `markAllRead` link (`POST`, schema `notifications-read`), except for read-only users.
- The `sshKeys` collection adds `create` when the user can write; the `threads` collection adds `createThread` under the same condition.

### Moderation log entry

```json
{
    "id": "019a2f70-44ee-7a6f-8d7a-8e9f0a1b2c3d",
    "action": "postHidden",
    "reason": "Off-topic flamewar",
    "createdAt": "2026-10-18T11:00:00Z",
    "_embedded": {
        "moderator": {"username": "carol", "deactivated": false, "_links": {"self": {"href": "/users/carol", "method": "GET"}}}
    },
    "_links": {
        "target": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"}
    }
}
```

`action` is one of: `postEdited`, `postReverted`, `postHidden`, `postUnhidden`, `postDeleted`, `threadPinned`, `threadUnpinned`, `threadClosed`, `threadReopened`, `threadDeleted`, `userBanned`, `userUnbanned`, `userMadeReadonly`, `userReadonlyRemoved`.

### Search result

```json
{
    "highlight": "Add <mark>X-Accel-Buffering</mark>: no to the response.",
    "_embedded": {
        "post": {"...": "Post"},
        "thread": {"...": "Thread"}
    }
}
```

### Error

```json
{
    "type": "https://tyrtulia.example/errors/edit-conflict",
    "title": "The post was modified by someone else",
    "status": 412,
    "detail": "Your copy is version 2, the current version is 3",
    "instance": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e",
    "_links": {
        "latest-version": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e", "method": "GET"},
        "diff": {"href": "/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e/revisions/3?format=diff&from=2", "method": "GET", "type": "text/x-diff"}
    }
}
```

## API reference

Paths are the planned implementation, not the contract: clients discover them by following `_links` from `GET /`. The contract is the relation (`rel`) listed with each endpoint.

### Conventions

```sh
API=https://tyrtulia.example      # HTTP listener
TOKEN=tyrtulia_ses_xxxxxxxx       # session token (HTTP only)
JSON='Content-Type: application/json'

# Example identifiers (UUIDv7)
THREAD=019a2f4e-8b1c-7d2e-9f30-4a5b6c7d8e9f
POST=019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e
NOTIF=019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a
KEY=019a2e10-77cc-7a1d-8b2e-3c4d5e6f7a8b
EVENT=019a2f61-33dd-7f5e-8c6f-7d8e9f0a1b2c
```

- Through the Unix socket no token is needed: replace `$API` with `http://localhost` and add `--unix-socket /run/tyrtulia/tyrtulia.sock`.
- Access levels: **public** (unauthenticated), **user** (authenticated), **writer** (can write: `user`, `moderator` or `admin` role), **author** (the author, or a moderator), **moderator**. **author** and **moderator** also require being able to write.
- Errors common to every endpoint, not repeated below: `400 validation-failed`, `401 unauthenticated`, `403 forbidden`, `403 readonly` (anything except `GET` and logout), `403 banned`, `404 not-found`, `406 not-acceptable`, `413 request-too-large` and `415 unsupported-media-type` (requests with a body), `429 rate-limited`.

### Root and authentication

#### Root
Entry point. Returns the forum name and the links available to whoever asks.
- **Endpoint:** `GET /` · rel: `self` (root) · public
- **Parameters:** none.
```sh
curl $API/
```
**Response:** `200` with a [Root](#root).

#### Request an SSH challenge
First step of remote login.
- **Endpoint:** `POST /ssh-challenges` · rel: `sshChallenge` · public
- **Parameters:** `username` (body).
```sh
curl -X POST $API/ssh-challenges -H "$JSON" -d '{"username": "alice"}'
```
**Response:** `201` with `{"challenge", "namespace": "tyrtulia", "expiresAt"}`. Same response whether the user exists or not. Expires in 2 minutes.

#### Log in
Sends the signed challenge and receives a token.
- **Endpoint:** `POST /sessions` · rel: `login` · public
- **Parameters:** `username`, `challenge`, `signature` (body).
```sh
SIG=$(printf %s "$CHALLENGE" | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n tyrtulia)
curl -X POST $API/sessions -H "$JSON" \
  -d "$(jq -n --arg c "$CHALLENGE" --arg s "$SIG" '{username:"alice", challenge:$c, signature:$s}')"
```
**Response:** `201` with `{"token", "expiresAt"}` and `me` and `logout` links. Errors: `401 invalid-signature`, `403 banned`.

#### Log out
Revokes the current token.
- **Endpoint:** `DELETE /sessions/current` · rel: `logout` · user (session token only)
- **Parameters:** none.
```sh
curl -X DELETE $API/sessions/current -H "Authorization: Bearer $TOKEN"
```
**Response:** `204`.

### Users

#### Get a profile
- **Endpoint:** `GET /users/{username}` · rel: `user` (templated, on the root), `self` (on embedded users) · public
- **Parameters:** `username` (path).
```sh
curl $API/users/alice
```
**Response:** `200` with a [User](#user). Former usernames return `404`.

#### Get my profile
- **Endpoint:** `GET /users/me` · rel: `me` · user
- **Parameters:** none.
```sh
curl $API/users/me -H "Authorization: Bearer $TOKEN"
```
**Response:** `200` with a [CurrentUser](#currentuser).

#### Edit my profile
- **Endpoint:** `PATCH /users/me` · rel: `edit` · writer
- **Parameters:** `about`, `email`, `username` (SSH key accounts only) (body, all optional).
```sh
curl -X PATCH $API/users/me -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/merge-patch+json' -d '{"about": "Hello from the tilde", "email": "alice@example.org"}'
```
**Response:** `200` with the [CurrentUser](#currentuser). Errors: `409 username-taken`, `403 fields-not-allowed` (changing `username` on a Unix account).

#### List my SSH keys
- **Endpoint:** `GET /users/me/ssh-keys` · rel: `sshKeys` · user
- **Parameters:** none.
```sh
curl $API/users/me/ssh-keys -H "Authorization: Bearer $TOKEN"
```
**Response:** `200` with a collection of [SshKey](#sshkey).

#### Add an SSH key
- **Endpoint:** `POST /users/me/ssh-keys` · rel: `create` · writer
- **Parameters:** `publicKey` (required, an `authorized_keys` line), `title` (optional; defaults to the key comment) (body).
```sh
curl -X POST $API/users/me/ssh-keys -H "Authorization: Bearer $TOKEN" -H "$JSON" \
  -d "{\"publicKey\": \"$(cat ~/.ssh/id_ed25519.pub)\"}"
```
**Response:** `201` with the [SshKey](#sshkey). Errors: `409 key-already-registered`, `422 invalid-public-key`.

#### Delete an SSH key
- **Endpoint:** `DELETE /users/me/ssh-keys/{keyId}` · rel: `delete` · writer
- **Parameters:** `keyId` (path).
```sh
curl -X DELETE $API/users/me/ssh-keys/$KEY -H "Authorization: Bearer $TOKEN"
```
**Response:** `204`.

### Threads

#### List threads
Pinned threads first, then the rest by last activity.
- **Endpoint:** `GET /threads` · rel: `latest`, `threads`, `watching` · public
- **Parameters:** `author`, `filter=watching` (requires a user), `cursor`, `perPage` (query).
```sh
curl "$API/threads?perPage=20"
```
**Response:** `200` with a collection of [Thread](#thread).

#### Create a thread
Creates a thread with its first post. The author is set to `watching`.
- **Endpoint:** `POST /threads` · rel: `createThread` · writer
- **Parameters:** `title`, `body` (body, Markdown).
```sh
curl -X POST $API/threads -H "Authorization: Bearer $TOKEN" -H "$JSON" \
  -d '{"title": "SSE behind Nginx?", "body": "Does anyone have it working?"}'
```
**Response:** `201` with the [Thread](#thread), including `_embedded.firstPost`, and a `Location` header. Error: `422 too-many-mentions`.

#### Get a thread
- **Endpoint:** `GET /threads/{threadId}` · rel: `self`, `up` · public
- **Parameters:** `threadId` (path).
```sh
curl $API/threads/$THREAD
```
**Response:** `200` with the [Thread](#thread) and its `ETag`. A deleted thread returns `404 not-found` to everyone except moderators, who receive it with `deletedAt`.

#### Edit a thread
Changes the title (author) or pins and closes it (moderator).
- **Endpoint:** `PATCH /threads/{threadId}` · rel: `edit`, `pin`, `close` · author / moderator depending on the field
- **Parameters:** `If-Match` (header, required when `title` changes), `title` (author; creates a revision of the first post), `reason` (optional, with `title`), `pinned`, `closed` (moderator) (body).
```sh
curl -X PATCH $API/threads/$THREAD -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v2"' \
  -H 'Content-Type: application/merge-patch+json' -d '{"title": "SSE behind Nginx: solved"}'
```
**Response:** `200` with the [Thread](#thread) and the new `ETag`. Errors: `403 fields-not-allowed`, `428 precondition-required`, `412 edit-conflict`.

#### Delete a thread
Soft-deletes the whole thread.
- **Endpoint:** `DELETE /threads/{threadId}` · rel: `delete` · author while nobody else has replied / moderator
- **Parameters:** `threadId` (path).
```sh
curl -X DELETE $API/threads/$THREAD -H "Authorization: Bearer $TOKEN"
```
**Response:** `204`. Error: `409 thread-has-replies` (author, with replies from other people).

#### Subscribe to a thread
- **Endpoint:** `PUT /threads/{threadId}/subscription` · rel: `subscribe` · writer
- **Parameters:** `level`: `watching`, `normal` or `muted` (body).
```sh
curl -X PUT $API/threads/$THREAD/subscription -H "Authorization: Bearer $TOKEN" -H "$JSON" \
  -d '{"level": "watching"}'
```
**Response:** `200` with `{"level": "watching"}`.

### Posts

#### List a thread's posts
Linear view by default, or the nested conversation.
- **Endpoint:** `GET /threads/{threadId}/posts` · rel: `posts` · public
- **Parameters:** `after`, `before` (post number), `perPage`, `view=tree` (query).
```sh
curl "$API/threads/$THREAD/posts?after=9"
```
**Response:** `200` with a collection of [Post](#post) in ascending order. With `view=tree`, pagination applies to root posts (those that do not answer another post) and each one includes its replies nested in `_embedded.replies`, up to 8 levels deep (deeper answers stay at the eighth level) and at most 500 replies per page.

#### Reply
Adds a post to the thread. Notifies mentioned users, the author of the answered post and whoever watches the thread.
- **Endpoint:** `POST /threads/{threadId}/posts` · rel: `reply` · writer
- **Parameters:** `body` (Markdown), `replyToPostNumber` (optional) (body).
```sh
curl -X POST $API/threads/$THREAD/posts -H "Authorization: Bearer $TOKEN" -H "$JSON" \
  -d '{"body": "Add `X-Accel-Buffering: no`. @alice", "replyToPostNumber": 2}'
```
**Response:** `201` with the [Post](#post) and a `Location` header. Errors: `409 thread-closed`, `422 too-many-mentions`.

#### Get a post
- **Endpoint:** `GET /posts/{postId}` · rel: `self`, `replyTo`, `target` · public
- **Parameters:** `postId` (path).
```sh
curl -i $API/posts/$POST
```
**Response:** `200` with the [Post](#post) and its `ETag`.

#### List replies to a post
- **Endpoint:** `GET /posts/{postId}/replies` · rel: `replies` · public
- **Parameters:** `cursor`, `perPage` (query).
```sh
curl $API/posts/$POST/replies
```
**Response:** `200` with a collection of [Post](#post).

#### Edit or hide a post
Edits the body (creating a revision) or hides the post (moderator).
- **Endpoint:** `PATCH /posts/{postId}` · rel: `edit`, `hide`, `unhide` · author / moderator to hide
- **Parameters:** `If-Match` (header, required when `body` changes), `body`, `reason`, `hidden`, `hiddenReason` (body).
```sh
curl -X PATCH $API/posts/$POST -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v2"' \
  -H 'Content-Type: application/merge-patch+json' -d '{"body": "Fixed text", "reason": "typo"}'
```
**Response:** `200` with the [Post](#post) and the new `ETag`. Errors: `428 precondition-required`, `412 edit-conflict` (with `latest-version` and `diff` links), `422 too-many-mentions`.

#### Delete a post
Soft delete: a tombstone remains. Deleting the first post does not delete the thread.
- **Endpoint:** `DELETE /posts/{postId}` · rel: `delete` · author
- **Parameters:** `postId` (path).
```sh
curl -X DELETE $API/posts/$POST -H "Authorization: Bearer $TOKEN"
```
**Response:** `204`.

### History

#### List revisions
- **Endpoint:** `GET /posts/{postId}/revisions` · rel: `version-history` · public (see restrictions)
- **Parameters:** `postId` (path), `cursor`, `perPage` (query).
```sh
curl $API/posts/$POST/revisions
```
**Restrictions:** no revision is served for a deleted post (`404 not-found`); for a hidden one, only to moderators and the author.

**Response:** `200` with a collection of [Revision](#revision) without `body`, newest first.

#### Get a revision or a diff
- **Endpoint:** `GET /posts/{postId}/revisions/{version}` · rel: `self`, `diff` · public (see restrictions)
- **Parameters:** `format=diff`, `from` (base version, defaults to the previous one) (query).
```sh
curl "$API/posts/$POST/revisions/1?format=diff"
```
**Restrictions:** same as listing revisions.

**Response:** `200` with the [Revision](#revision), or `text/x-diff` with a unified diff.

#### Revert to a revision
Creates a new version with the old content.
- **Endpoint:** `POST /posts/{postId}/revisions/{version}/revert` · rel: `revert` · author
- **Parameters:** `If-Match` (header, required).
```sh
curl -X POST $API/posts/$POST/revisions/1/revert -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v3"'
```
**Response:** `200` with the [Post](#post) and the new `ETag`. On the first post it also restores the title. Errors: `428 precondition-required`, `412 edit-conflict`.

### Search

#### Search
- **Endpoint:** `GET /search` · rel: `search` · public
- **Parameters:** `q` (required, see [Search and feeds](#search-and-feeds)), `author`, `in=title`, `cursor`, `perPage` (query).
```sh
curl -G $API/search --data-urlencode 'q="sse nginx"'
```
**Response:** `200` with a collection of [Search result](#search-result) by relevance. Error: `400 invalid-query`.

### Notifications

#### List notifications
- **Endpoint:** `GET /notifications` · rel: `notifications` · user
- **Parameters:** `filter=unread` (not for read-only users), `cursor`, `perPage` (query).
```sh
curl "$API/notifications?filter=unread" -H "Authorization: Bearer $TOKEN"
```
**Response:** `200` with a collection of [Notification](#notification), newest first, with `meta.unreadCount` (except for read-only users).

#### Mark a notification
- **Endpoint:** `PATCH /notifications/{notificationId}` · rel: `markRead` · writer
- **Parameters:** `read` (body).
```sh
curl -X PATCH $API/notifications/$NOTIF -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/merge-patch+json' -d '{"read": true}'
```
**Response:** `200` with the [Notification](#notification).

#### Mark all as read
- **Endpoint:** `POST /notifications/read-state` · rel: `markAllRead` · writer
- **Parameters:** `before` (body): id of the newest notification seen; later ones stay unread.
```sh
curl -X POST $API/notifications/read-state -H "Authorization: Bearer $TOKEN" -H "$JSON" -d "{\"before\": \"$NOTIF\"}"
```
**Response:** `204`.

### Real time

#### User events
The user's only SSE stream: `notification.created` for every new notification.
- **Endpoint:** `GET /events` · rel: `events` · user
- **Parameters:** `Last-Event-ID` (header, optional) to resume.
```sh
curl -N $API/events -H "Authorization: Bearer $TOKEN" -H "Last-Event-ID: $EVENT"
```
**Response:** `200` `text/event-stream`:

```text
id: 019a2f61-33dd-7f5e-8c6f-7d8e9f0a1b2c
event: notification.created
data: {"kind":"mention","_links":{"self":{"href":"/notifications/019a2f60-11bb-7e4c-9a5d-6b7c8d9e0f1a","method":"GET"},"target":{"href":"/posts/019a2f51-02aa-7c3b-8d4e-5f6a7b8c9d0e","method":"GET"}}}

: heartbeat
```

`event: reset` if the `Last-Event-ID` is older than 24 hours: the client reloads through REST. Several connections per user, up to `max_sse_per_user`; beyond that, `429 too-many-connections`. The server also keeps at most 1,000 open streams in total.

### Feeds

#### Forum feed
- **Endpoint:** `GET /threads.atom` · rel: `alternate` (on the root) · public
- **Parameters:** none.
```sh
curl $API/threads.atom
```
**Response:** `200` `application/atom+xml` with the last 30 threads, `ETag` and `Last-Modified`. Excludes deleted threads.

#### Thread feed
- **Endpoint:** `GET /threads/{threadId}.atom` · rel: `alternate` (on the thread) · public
- **Parameters:** `threadId` (path).
```sh
curl $API/threads/$THREAD.atom
```
**Response:** `200` `application/atom+xml` with the last 30 posts, `ETag` and `Last-Modified`. Excludes hidden and deleted posts.

### Moderation

#### Ban a user
Sets the role to `banned`, revokes their sessions and blocks their SSH keys.
- **Endpoint:** `PUT /users/{username}/ban` · rel: `ban` · moderator
- **Parameters:** `reason` (body, required; public in the moderation log).
```sh
curl -X PUT $API/users/spammer/ban -H "Authorization: Bearer $TOKEN" -H "$JSON" -d '{"reason": "spam"}'
```
**Response:** `204`. Error: `403 cannot-moderate-staff`.

#### Unban a user
- **Endpoint:** `DELETE /users/{username}/ban` · rel: `unban` · moderator
- **Parameters:** `username` (path).
```sh
curl -X DELETE $API/users/spammer/ban -H "Authorization: Bearer $TOKEN"
```
**Response:** `204`. The user returns to the role they had before the ban (`user` or `readonly`) and their SSH keys are reactivated.

#### Make a user read-only
Sets the role to `readonly`: they can read but not interact in any way.
- **Endpoint:** `PUT /users/{username}/readonly` · rel: `readonly` · moderator
- **Parameters:** `reason` (body, required; public in the moderation log).
```sh
curl -X PUT $API/users/troll/readonly -H "Authorization: Bearer $TOKEN" -H "$JSON" -d '{"reason": "flamewar"}'
```
**Response:** `204`. Error: `403 cannot-moderate-staff`.

#### Remove read-only
- **Endpoint:** `DELETE /users/{username}/readonly` · rel: `unreadonly` · moderator
- **Parameters:** `username` (path).
```sh
curl -X DELETE $API/users/troll/readonly -H "Authorization: Bearer $TOKEN"
```
**Response:** `204`. Back to the `user` role.

#### Moderation log
Public record of moderation actions, newest first.
- **Endpoint:** `GET /modlog` · rel: `modlog` · public
- **Parameters:** `cursor`, `perPage` (query).
```sh
curl $API/modlog
```
**Response:** `200` with a collection of [Moderation log entry](#moderation-log-entry).

#### Report
Sends an internal message to the moderators. Creates a `report` notification for every moderator and admin.
- **Endpoint:** `POST /reports` · rel: `report` (on posts, threads and users) · writer
- **Parameters:** `target` (URL of the post, thread or user), `message` (body).
```sh
curl -X POST $API/reports -H "Authorization: Bearer $TOKEN" -H "$JSON" \
  -d "{\"target\": \"/posts/$POST\", \"message\": \"Spam with links\"}"
```
**Response:** `202`. Error: `422 invalid-target`.

### Validation

#### Get a schema
- **Endpoint:** `GET /schemas/{name}` · `schema` property of a link · public
- **Parameters:** `name` (path), one of the [schemas](#validation).
```sh
curl $API/schemas/thread
```
**Response:** `200` `application/schema+json` with the JSON Schema.

## Error catalog

All errors use `Content-Type: application/problem+json` (RFC 9457), with `type` under `https://<instance>/errors/`.

| `type` | Status | When |
|---|---|---|
| `validation-failed` | 400 | The body or the query does not match the rules. |
| `invalid-query` | 400 | Invalid search syntax. |
| `unauthenticated` | 401 | Missing authentication or invalid token. |
| `invalid-signature` | 401 | Wrong SSH signature (same message whether the user exists or not). |
| `forbidden` | 403 | No permission for this action. |
| `readonly` | 403 | Any request other than `GET` or logout from a read-only user. |
| `banned` | 403 | Any request from a banned user. |
| `fields-not-allowed` | 403 | A field in the `PATCH` is not allowed for you; nothing is applied. |
| `cannot-moderate-staff` | 403 | Banning or making read-only a moderator or admin. |
| `not-found` | 404 | Does not exist, or exists but you cannot see it. |
| `not-acceptable` | 406 | `Accept` asks for an unsupported media type or API version. |
| `thread-closed` | 409 | Replying to a closed thread without being a moderator. |
| `thread-has-replies` | 409 | The author tries to delete a thread with replies from other people. |
| `key-already-registered` | 409 | The SSH key is already registered. |
| `username-conflict` | 409 | New Unix user with the name of an SSH key account, or with a name another account used before. |
| `username-taken` | 409 | The username exists, existed, or is reserved in the configuration file. |
| `unix-identity-mismatch` | 409 | The UID and Unix name do not match the account. |
| `edit-conflict` | 412 | Stale `If-Match`. |
| `request-too-large` | 413 | The body is larger than 256 KiB. |
| `unsupported-media-type` | 415 | `PATCH` without `application/merge-patch+json`, or a body that is not JSON. |
| `too-many-mentions` | 422 | More than 10 mentions in a post. |
| `invalid-public-key` | 422 | SSH key with an unsupported format or type. |
| `invalid-target` | 422 | The report target is not a valid post, thread or user. |
| `precondition-required` | 428 | Missing `If-Match` when editing. |
| `rate-limited` | 429 | Rate limit exceeded (with `Retry-After`). |
| `too-many-connections` | 429 | More open SSE connections than `max_sse_per_user`. |

## Comparison with BBJ

[BBJ](https://github.com/bbj-dev/bbj) (Bulletin Butter & Jelly, a pun on *bulletin board* and *PB&J*) is a textboard for tilde communities, written in Python with CherryPy and SQLite, with a terminal client (urwid), an Emacs client and a minimal web interface. Tyrtulia keeps its spirit and rebuilds its foundations. These tables are informative: they summarise what Tyrtulia brings compared to BBJ.

### Foundations

| Area | BBJ | Tyrtulia |
|---|---|---|
| API style | RPC: every call is `POST /api/<endpoint>` with a JSON body | Hypermedia REST (HAL): clients only know `GET /` and follow links |
| Permissions | Clients ask the server (`is_admin`, `edit_query`) | Expressed as links that appear or disappear |
| Authentication | Unsalted SHA-256 of the password sent on every request; the hash works as the password | No passwords: Unix socket (`SO_PEERCRED` or `getpeereid()`) and signed SSH challenge with revocable session tokens |
| Registration | Open, optionally with invitation codes | No HTTP registration: Unix users, or SSH key accounts created by the operator |
| Errors | 6 custom numeric codes | RFC 9457 with a catalog of types |
| Users in responses | Separate `usermap` | `_embedded.author` |
| Versioning | None | Versioned media type |
| Pagination | Everything returned at once | Cursor based, posts by number |
| Concurrency | Last edit wins silently | `ETag` and `If-Match` |
| Identifiers and dates | UUID1, floating point timestamps | UUIDv7, ISO 8601 UTC |
| Text format | Custom bracket language (`[bold: ...]`, `[red: ...]`) | Standard Markdown |
| Validation | `db_validate` endpoint | JSON Schema linked from each form |
| Configuration | `config.json` | TOML |

### Features

| Feature | BBJ | Tyrtulia |
|---|---|---|
| Anonymous reading | Yes | Yes |
| Anonymous posting | Yes | No |
| Reply to a specific post | `>>N` quotes in the text | `replyToPostNumber`, with `replyTo` / `replies` links and an optional tree view |
| Mentions | No | Yes |
| Notifications | No (pending since 2017) | Inbox with `reply`, `mention`, `watched` and `report` |
| Thread subscriptions | No | `watching`, `normal`, `muted` |
| Real time | No (polling `message_feed`) | SSE with resume (`Last-Event-ID`) |
| Edit history | No: edits overwrite the post | Full history with diffs and revert |
| Edit time limit | 24 hours | None (history makes it unnecessary) |
| Deletion | Deletes the row | Tombstone, numbering preserved |
| Pinned threads | Yes | Yes |
| Closed threads | No | Yes |
| Search | Done by each client over downloaded data | Server-side full text, accent and case insensitive |
| Feeds | No | Atom for the forum and each thread |
| Roles | Admin or not | `admin`, `moderator`, `user`, `readonly`, `banned` |
| Hidden posts | No | Yes, with a reason |
| Moderation log | No | Public |
| Reports | No | Internal message to moderators |
| Rate limiting | No | Per user and per IP |
| Profile | Bio, quip, color | About, private email |
| Import from BBJ | Not applicable | Administration CLI |

### What stays the same

- A small server that is easy to run, built around SQLite.
- Several text clients (terminal, Emacs) against one documented API.
- Reading without an account.
- Validation rules shared between server and clients.
- The tilde and smolweb spirit: no images, no attachments, no categories.

## Contributing

Contributions are welcome! Please see the [contribution guidelines](https://git.andros.dev/andros/contribute) for instructions on how to submit issues or pull requests.

## License

Tyrtulia is free software, released under the [GNU General Public License v3.0 or later](LICENSE).
