Skip to main content

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

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

# 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:

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.

    useradd --system --no-create-home --shell /usr/sbin/nologin tyrtulia
    install -d -o tyrtulia -m 0755 /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.

  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.

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.

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

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

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, 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", "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).
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.
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 last activity.

  • 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 all its replies nested in _embedded.replies.

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

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, ETag and Last-Modified. 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.
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.
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.

Roadmap

Version 1 is the API plus the administration CLI. After that:

  • Clients that connect to the API: Emacs, a TUI, web and access over SSH. BBJ shows that a complete TUI is what makes people actually use a forum on a tilde server, so it is the next priority after the API.
  • SSE in browsers. EventSource cannot send an Authorization header (neither can WebSocket), so the web client will authenticate with a session cookie: HttpOnly, Secure and SameSite=Strict, holding the same kind of session token. EventSource sends it automatically on same-origin requests. Because cookies are sent by the browser on its own, every request authenticated by cookie that is not GET must also pass an Origin check against the instance's own origin (CSRF protection). Other clients keep using the Authorization header.
  • Reports with a moderation queue (status, assignment, resolution), if internal messages fall short.

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.0.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.0.0
File Size Uploaded Provenance Actions
tyrtulia-1.0.0.tar.gz 117.3 kB Without provenance Details for tyrtulia-1.0.0.tar.gz

Built distribution (wheel)

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

Total release size: 240.1 kB

Release files / tyrtulia-1.0.0.tar.gz

Download URL tyrtulia-1.0.0.tar.gz
Size 117.3 kB
Tags Source
SHA-256 checksum
How to use checksums
ed6d2345ed119d35427c2996cf93f5e1709157d027e8938f5eafc2da16c0785a
BLAKE2b-256 checksum
How to use checksums
2bd7bef297ea205eee2eaf5a56aa857a4984f987d1908be556aaeabaf6631408
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.0.0-py3-none-any.whl

Download URL tyrtulia-1.0.0-py3-none-any.whl
Size 122.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c12e5a3b3d5ec113d4d230c6715fb2f28881d045beb397f44d68e7b539670324
BLAKE2b-256 checksum
How to use checksums
78202a46e2325742369dc1a2474e9148b11d26843c81bec62ace65f7db0aa8f8
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.0.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