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 9761082fa1
commit 54ed030732
16 changed files with 1553 additions and 22 deletions
+2
View File
@@ -93,6 +93,7 @@ from lembas.db.models.user import (
ROLE_ADMIN,
ROLE_PENDING,
Group,
PushSubscription,
Session,
User,
user_groups,
@@ -100,6 +101,7 @@ from lembas.db.models.user import (
__all__ = [
"AUTHOR_MODEL",
"PushSubscription",
"AUTH_KEY",
"AUTH_METHODS",
"AUTH_PASSWORD",
+7
View File
@@ -59,6 +59,13 @@ class Report(UUIDPrimaryKey, Timestamps, Base):
# NOT NULL with a scalar default so `migrations._add_column_sql` can backfill
# it if this column is ever added to a table that already has rows.
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Whether its arrival has already been announced. The dot can be shown for
# as long as it is unread; the toast and the browser notification must fire
# once. Without this the poll would announce the same report every ten
# seconds until somebody opened it, which is the shape of notification
# nobody leaves switched on. `Chat.unread_notified` exists for exactly this
# and this is the same pair.
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Why a run produced nothing worth reading. A scheduled report that failed
# is still a report -- one that silently did not appear is indistinguishable
# from a schedule that never fired.
+44
View File
@@ -105,3 +105,47 @@ class Session(UUIDPrimaryKey, Timestamps, Base):
Index("ix_sessions_user_id", Session.user_id)
class PushSubscription(UUIDPrimaryKey, Timestamps, Base):
"""One browser, on one device, that has agreed to be told.
Per device rather than per account, and that is not a detail: the permission
and the subscription both belong to a browser, so somebody signed in on a
laptop and a phone has two of these and revoking one must not silence the
other. It is also why there is no "notifications on" column on `User` -- the
presence of a row here *is* the state, and it cannot drift from what the
browser thinks.
`endpoint` is chosen by the browser vendor and is the address their push
service will accept a message at. Unique, because a browser that
re-subscribes hands back the same one and two rows would mean two
notifications for one arrival.
`p256dh` and `auth_secret` are the browser's half of the encryption. Stored
as the browser gave them, base64url: they are public key material and a
per-subscription salt, not credentials -- what they protect is the payload,
and a database holding them can already read everything the payload could
say. See services/push.py.
"""
__tablename__ = "push_subscriptions"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
endpoint: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
p256dh: Mapped[str] = mapped_column(String(255), nullable=False)
auth_secret: Mapped[str] = mapped_column(String(64), nullable=False)
# Which device this is, for a list somebody can revoke from. Whatever the
# browser says about itself, trimmed; never parsed.
label: Mapped[str] = mapped_column(String(200), default="")
# The last refusal from the push service, kept so a subscription that has
# stopped working says why rather than being silently useless. A 404 or 410
# deletes the row instead -- that is the end of its life, not a fault.
last_error: Mapped[str] = mapped_column(Text, default="")
user: Mapped[User] = relationship()
Index("ix_push_subscriptions_user_id", PushSubscription.user_id)