News that finds you, including when nothing of ours is open

The dots covered Reports and Messages from the day those sections existed. The
announcement did not: only a chat reply produced an HX-Trigger, so a scheduled
run that filed a report or posted into Messages lit a green dot in a corner and
said nothing at all. That is precisely the arrival nobody is watching for -- a
chat reply is one you asked for a moment ago and are probably looking at.

So every kind announces, each with its own once-only flag, and the payload is a
list of items rather than of titles, because a notification is a thing you click
and a title cannot say where.

One arrival, three channels, and they must not all fire. A toast for somebody
looking at the page; a count in the tab title while it is hidden, cleared on
focus; a system notification for somebody elsewhere entirely. The service worker
is the only place that can tell them apart -- the server cannot see whether a
window is focused and the page cannot see a push it did not receive -- so it
stays quiet when one of its own windows has focus.

And web push, hand-rolled against RFC 8291 and RFC 8292 with the cryptography
already here for Fernet. It exists because everything else is polled by an open
page, and the arrival worth interrupting somebody for is a schedule firing at
seven in the morning with the laptop shut.

The trade is real and is written down rather than glossed: the POST goes to
Google's or Mozilla's push service, the payload is sealed end to end so they
cannot read it, and what they do learn is that this server sent something and
when. Opt-in per device, off until asked for, and the rest of the system works
without it. Nothing else in LLeMbas contacts an outside service on its own.

The encryption is tested by decrypting it back with an independent
implementation of the specification's other half. There is no other way to know:
a push service accepts the POST and forwards bytes it cannot read, so a wrong
derivation is a notification that never appears, with a 201 in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 11:11:04 +02:00
parent feca8861a1
commit fb54a236ae
18 changed files with 1616 additions and 28 deletions
+43 -2
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 1872 tests, ~2min
pytest # 1897 tests, ~2min
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -120,6 +120,9 @@ src/lembas/
instructions.py (the project's own AGENTS.md),
patch.py (applying a unified diff, and rendering one)
audio.py OpenAI-shaped /v1/audio/* client
push.py web push: the only thing here that talks to an outside
service, and only because there is no other way to reach
a browser that is closed
fetch.py URL retrieval, HTML to text, the SSRF guard
messages.py the one conversation per person: bounded in the request,
unbounded on disk
@@ -971,10 +974,48 @@ page load. The allowlist's lower bound, the handle's `data-resize-min` and the
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
an `HX-Trigger` carrying **items** — each with a kind, a title and a URL, because
a browser notification is a thing you click and a title alone cannot say where.
`unread_notified` (on `Chat` *and* on `Report`) stops the same arrival being
announced every tick. Re-rendering the whole sidebar instead would reset the
folder open/closed state every 10 seconds.
**Everything that can arrive is announced, not only chats.** The dots covered
Reports and Messages from the day those sections existed; the *announcement* did
not, so a scheduled run that filed a report lit a dot in a corner and said
nothing. That is exactly the arrival nobody is watching for — a chat reply is one
you asked for a moment ago.
**One arrival, three channels, and they must not all fire.** A toast for somebody
looking at the page; a count in the tab title (`(3) LLeMbas`) while it is hidden,
cleared on focus; and a system notification for somebody elsewhere entirely. The
service worker is the only place that can tell them apart — it skips
`showNotification` when one of its own windows is `focused`, because the server
cannot see focus and the page cannot see a push it did not receive.
**Web push is the one thing here that contacts an outside service.**
`services/push.py`, hand-rolled against RFC 8291 and RFC 8292 with
`cryptography`, which is already a dependency. It exists because everything else
is polled *by an open page*, and the arrival worth notifying about is a schedule
firing at seven in the morning with the browser shut. The trade is real and
written down in the module: the POST goes to Google's or Mozilla's push service,
the payload is encrypted end to end so they cannot read it, and what they do
learn is that this server sent something and when. Opt-in **per device**, because
the permission and the subscription both belong to a browser.
Three things about it that are easy to get wrong:
- **`announce_later` is called where something arrives**, never from the poll —
the poll needs a page, and this is the case where there is not one. Each
arrival site runs exactly once, which is what makes it fire once with no flag
of its own; borrowing `unread_notified` would let whichever channel got there
first silence the other. It checks for a running loop **before** building the
coroutine, or every synchronous caller raises "never awaited" at its own line.
- **The VAPID keypair is generated once and never regenerated.** Its public half
is inside every subscription a browser holds, so a new one silently
invalidates all of them — notifications simply stop with nothing saying why.
- **404 and 410 delete the subscription**; anything else keeps it. Those two are
the normal end of a subscription's life, not a failure.
**Editing rewinds, it does not branch.** `POST .../messages/{id}/edit` rewrites
a user turn and **deletes everything after it**. Branching would need a UI for
choosing between versions; "go back and try again from here" is what was asked