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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user