Skip to main content

Tyrtulia

A headless forum for small communities on shared Unix servers (tildes, pubnixes, club servers). It needs very few resources, between 60 and 70 MB of RAM and about 50 MB of disk space.

You need a client to take part: the terminal client Tyrtulia TUI or the Emacs client tyrtulia.el. Anyone can write their own client.

It is a modern, clean adaptation of BBJ (Bulletin Butter & Jelly), 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.

Table of contents

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.

Other uses

Tyrtulia is a store of threaded Markdown documents with public history, search, notifications and feeds, where every process on the server is already identified as its Unix user. That makes it useful beyond a forum.

  • Wiki and documentation. Community guides, FAQs or decision records, each one a thread whose first post is the page: public history with diffs and revert, full-text search, and the replies as its discussion page. See Threads as wiki pages.
  • Shared announcements and notifications. Administrators post maintenance windows and changes in a pinned, closed thread; whoever watches it gets them in real time. Scripts, cron jobs and hooks can post through the socket with no credentials, and a mention (@alice) notifies someone directly: a persistent, searchable wall. A login script can show unread notifications the same way. Automated posts need a regular account (UID at or above [accounts].min_uid) or an SSH key account, since system accounts are rejected.
  • Group blog. Each thread is an article and its replies are the comments. The forum's Atom feed is the subscription, and Tyrtulia Web can publish it on the web. Only people with an account can comment.
  • Teaching platform. On a school or university server, students already have Unix accounts, so nobody signs up: the teacher moderates, each exercise or question is a thread, and the search keeps past answers at hand. Since the product is a hypermedia API discovered from GET /, writing a client is also a good exercise in REST and HAL, in any language, against the same instance.

Everything is public to anyone who can read the instance: there are no private messages, so do not use it for secrets or personal data. The index has no categories; for unrelated purposes, run one instance each, which is cheap.

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.13 or newer (the system Python of Debian 13 and Ubuntu 26.04 works).
  • SQLite 3 with FTS5.
  • OpenSSH client (ssh-keygen), used to verify signatures.

Installation

Tyrtulia is published on PyPI as 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.

Install with "tyrtulia<2". A 2.0.0 was published by mistake (the same code as 1.2.0) and cannot be withdrawn from PyPI yet. <2 always gets the newest 1.x and never that release. The quotes keep the shell from reading < as a redirection.

With uv:

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

With pip:

python3 -m venv /opt/tyrtulia
/opt/tyrtulia/bin/pip install "tyrtulia<2"
ln -s /opt/tyrtulia/bin/tyrtulia /usr/local/bin/tyrtulia

To have a look without installing anything: uvx --from "tyrtulia<2" 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<2" or /opt/tyrtulia/bin/pip install --upgrade "tyrtulia<2"), 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.

    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.

    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.

    sudo -u tyrtulia tyrtulia --config /etc/tyrtulia/config.toml migrate
    
  4. Install and start the service.

    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:

    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 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.

    [listen]
    http = "127.0.0.1:7099"
    trusted_proxies = ["127.0.0.1"]
    
    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:

    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 of the database.
    • Moderation happens through the API with the moderator links; the Administration CLI covers accounts, identities and the BBJ import.

Clients

Tyrtulia is headless: people use it through a client.

Any program that speaks HTTP can be a client: it only needs GET / and the links in the 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 A login message that names the instance, the user and a 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.

  • What is signed: the message returned with each challenge, never the bare challenge:

    tyrtulia login
    origin: https://forum.example.org
    username: alice
    challenge: 3q2-7wXbG0yTuFq8aN1sKkz9Jm5vQe4Lr6Hc2Pd0Tbw
    

    The origin is [listen].public_url or, when it is empty, the scheme and host of the request. Clients check that it is the instance they are talking to before signing, so a signature made for one instance (perhaps a malicious one) is useless on any other, and it is only valid for that user and that challenge.

  • 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.

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:

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 creation date, newest first.
  • 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).
  • 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.

Threads as wiki pages

A thread also works as a collaborative wiki page. The first post is the page: its author and the moderators keep it up to date, with no edit time limit, and every change stays in the public history with diffs and revert. Pin it so it stays at the top of the index, close it to keep it clean, or leave it open so the replies act as its discussion page. Like any thread, it is found by the full-text search.

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 1 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.

{"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:

{
    "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.

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

User

{
    "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.

{
    "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

{
    "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):

{
    "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):

{
    "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

{
    "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

{
    "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

{
    "_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

{
    "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

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

Error

{
    "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

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.
curl $API/

Response: 200 with a Root.

Request an SSH challenge

First step of remote login.

  • Endpoint: POST /ssh-challenges · rel: sshChallenge · public
  • Parameters: username (body).
curl -X POST $API/ssh-challenges -H "$JSON" -d '{"username": "alice"}'

Response: 201 with {"challenge", "message", "namespace": "tyrtulia", "expiresAt"}. message is the text to sign (see what is signed). Same response whether the user exists or not. Expires in 2 minutes.

Log in

Sends the signed login message and receives a token.

  • Endpoint: POST /sessions · rel: login · public
  • Parameters: username, challenge, signature (body). The signature is of the challenge's message.
REPLY=$(curl -s -X POST $API/ssh-challenges -H "$JSON" -d '{"username": "alice"}')
CHALLENGE=$(echo "$REPLY" | jq -r .challenge)
MESSAGE=$(echo "$REPLY" | jq -r .message)
echo "$MESSAGE" | grep -qx "origin: $API" || { echo "Not this instance: refusing to sign"; exit 1; }
SIG=$(printf %s "$MESSAGE" | 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.
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).
curl $API/users/alice

Response: 200 with a User. Former usernames return 404.

Get my profile

  • Endpoint: GET /users/me · rel: me · user
  • Parameters: none.
curl $API/users/me -H "Authorization: Bearer $TOKEN"

Response: 200 with a CurrentUser.

Edit my profile

  • Endpoint: PATCH /users/me · rel: edit · writer
  • Parameters: about, email, username (SSH key accounts only) (body, all optional).
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. 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.
curl $API/users/me/ssh-keys -H "Authorization: Bearer $TOKEN"

Response: 200 with a collection of 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).
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. 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).
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 creation date, newest first.

  • Endpoint: GET /threads · rel: latest, threads, watching · public
  • Parameters: author, filter=watching (requires a user), cursor, perPage (query).
curl "$API/threads?perPage=20"

Response: 200 with a collection of 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).
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, 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).
curl $API/threads/$THREAD

Response: 200 with the 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).
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 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).
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).
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).
curl "$API/threads/$THREAD/posts?after=9"

Response: 200 with a collection of 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).
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 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).
curl -i $API/posts/$POST

Response: 200 with the Post and its ETag.

List replies to a post

  • Endpoint: GET /posts/{postId}/replies · rel: replies · public
  • Parameters: cursor, perPage (query).
curl $API/posts/$POST/replies

Response: 200 with a collection of 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).
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 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).
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).
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 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).
curl "$API/posts/$POST/revisions/1?format=diff"

Restrictions: same as listing revisions.

Response: 200 with the 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).
curl -X POST $API/posts/$POST/revisions/1/revert -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v3"'

Response: 200 with the Post and the new ETag. On the first post it also restores the title. Errors: 428 precondition-required, 412 edit-conflict.

Search

  • Endpoint: GET /search · rel: search · public
  • Parameters: q (required, see Search and feeds), author, in=title, cursor, perPage (query).
curl -G $API/search --data-urlencode 'q="sse nginx"'

Response: 200 with a collection of 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).
curl "$API/notifications?filter=unread" -H "Authorization: Bearer $TOKEN"

Response: 200 with a collection of Notification, newest first, with meta.unreadCount (except for read-only users).

Mark a notification

  • Endpoint: PATCH /notifications/{notificationId} · rel: markRead · writer
  • Parameters: read (body).
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.

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.
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.
curl -N $API/events -H "Authorization: Bearer $TOKEN" -H "Last-Event-ID: $EVENT"

Response: 200 text/event-stream:

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.
curl $API/threads.atom

Response: 200 application/atom+xml with the last 30 threads created, newest first, ETag and Last-Modified. Each entry is the thread's first post: replies do not change it, and its updated is the last edit of that post. Excludes deleted threads.

Thread feed

  • Endpoint: GET /threads/{threadId}.atom · rel: alternate (on the thread) · public
  • Parameters: threadId (path).
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).
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).
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).
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).
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).
curl $API/modlog

Response: 200 with a collection of 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).
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.
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 (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 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.

Metadata

Release files for tyrtulia 1.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tyrtulia 1.4.0
File Size Uploaded Provenance Actions
tyrtulia-1.4.0.tar.gz 124.9 kB Without provenance Details for tyrtulia-1.4.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for tyrtulia 1.4.0
File Interpreter ABI Platform Actions
tyrtulia-1.4.0-py3-none-any.whl Python 3 none any Details for tyrtulia-1.4.0-py3-none-any.whl

Total release size: 253.8 kB

Release files / tyrtulia-1.4.0.tar.gz

Download URL tyrtulia-1.4.0.tar.gz
Size 124.9 kB
Tags Source
SHA-256 checksum
How to use checksums
3dbea7ffe67ccaa0356a55c6da528d07eeb49c0a93c30ce341bc5c4f442c200b
BLAKE2b-256 checksum
How to use checksums
58d3df7f788324cc7997fa5186883067ca01c55718a47f74fc5db3403c2d2e00
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.3 {"installer":{"name":"uv","version":"0.13.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tyrtulia-1.4.0-py3-none-any.whl

Download URL tyrtulia-1.4.0-py3-none-any.whl
Size 128.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b869946caca17503c419b579797e92880c91927fb5318fde7e4b63b764ddbbfb
BLAKE2b-256 checksum
How to use checksums
80e6bfc6c4902372df01846ab362cc1696712323623fccffeae2c6b482b4c0ba
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.3 {"installer":{"name":"uv","version":"0.13.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for tyrtulia 1.4.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page