diff --git a/AGENTS.md b/AGENTS.md
index f9faf70..e517e4b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,7 +15,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings).
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames, the analytics socket the admin inbox.
- - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols, and the public `/_contact/files` upload route.
+ - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols, easypow PoW gating (single-use challenges in per-socket RAM, browser solver served at `/_contact/easypow.js`), and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes).
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ``, and llms.txt links point at the `.md` URLs.
- `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `
` and the sitemap, recorded in analytics.
- `data.py` — msgspec Structs for the kanta database.
@@ -49,7 +49,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
## Toolchain
-- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`).
+- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `easypow`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`).
- Key libraries:
- **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML).
- To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header
diff --git a/docs/messages.md b/docs/messages.md
index 388b77f..199ff7a 100644
--- a/docs/messages.md
+++ b/docs/messages.md
@@ -49,12 +49,28 @@ no extra connection). Frames **to** the server:
`pagerite.js` re-sends this on every socket (re)connect, which is the
resync mechanism (the socket idle-closes after 5 min and reconnects on
activity).
-- `{token, msg, fr}` — append a message; `msg` is the Markdown-subset
- source, `fr` the page path it was sent from (stored for context).
+- `{token, msg, pow, fr}` — append a message; `msg` is the Markdown-subset
+ source, `pow` the solved proof-of-work (below), `fr` the page path it
+ was sent from (stored for context).
Frames **from** the server: `{type: "thread", id, created, unread,
messages: [{id, t, admin, html, page}]}` — a full thread snapshot
-(replace/upsert by `id`), or `{type: "contact-error", detail}`.
+(replace/upsert by `id`); `{type: "pow", challenge, required}` — a fresh
+single-use PoW challenge (`required` means the last message was rejected
+for a missing/stale solution and should be re-sent once solved); or
+`{type: "contact-error", detail}`.
+
+### Proof of work (anti-spam)
+
+Sends are gated by easypow (PBKDF2 challenges, ~5 s solving cost). The
+server hands a fresh single-use challenge with every registration/resync
+and after every accepted message; the panel solves it in the background
+with the bundled solver module (served from the installed package at
+`/_contact/easypow.js`, so client and validator versions always match),
+typically finishing while the user is still writing. Each message burns
+one solution — a send with a missing, stale or already-spent solution is
+dropped and answered with a fresh `required` challenge, which the client
+solves and retries once. Challenges live in per-socket RAM state only.
## Admin side
@@ -110,11 +126,16 @@ filename needed — the URL's last segment names the download) and
rendered or sniffed into something executable in the site's origin;
subresource fetches (``, `