news-cycle.timer runs poll then notify every 15 minutes; news-digest.timer runs
the briefing at 08:45 daily. Both enabled and verified: a manual cycle fetched
1047 items, embedded 95 new ones and clustered them in 74s.
Units are installed as COPIES rather than symlinks. The previous service's units
were symlinked into docs/projects/news/deploy/, and when that repo was renamed
to news_triage they became dangling and the timer failed every ten minutes,
unnoticed, until this rebuild found it. Copying costs a re-copy after editing;
symlinking cost days of silent failure.
Deliberate details: the poll ExecStart is `-` prefixed so a failed fetch does not
skip notify; RandomizedDelaySec spreads polling off the quarter hour rather than
hitting 56 sources at once; the digest is Persistent so a machine that was off
at 08:45 still sends rather than skipping the day.
Connor pointed out he may use the site from a laptop away from home. The gated
/ui/feedback/ path was worse than just inconvenient: fetch() follows redirects,
so an expired Authelia session would go 302 -> login page -> HTTP 200, and the
UI would mark the card labelled having recorded nothing. Silent data loss on the
one signal the system learns from.
Both surfaces now post to the same HMAC-signed public endpoint, with
`redirect: 'error'` so a redirect fails loudly instead of looking like success.
The gated route is gone -- one mechanism, not two.
/config shows what every block actually matches right now, and a box to try a
filter before saving it: describe what you want caught, see what it catches and
what sits just below the line, move the threshold, then save it as a veto or a
demote. That boundary view is the point -- a crypto filter at 0.55 catches 131
clusters including "Security updates for Monday", and at 0.62 catches 4, which
you can only know by looking.
Saves use tomlkit rather than tomllib plus a dump, because profile.toml carries
the reasoning behind every constant and a comment-stripping round-trip would
quietly destroy the record of why each number is what it is. Verified: 97
top-level comment lines in, 97 out.
The config mount had to stop being :ro, and ~/config/news2 is now owned
10001:1000 group-writable so the container can save while hand-editing still
works. `:U` would have fixed the container and locked the user out.
Applied to ~/compose.yml (snapshot: ~/compose.yml.pre-news-v2). The image is
tagged localhost/news-py:latest rather than reusing the registry tag, so the
Rust image stays on disk as the rollback path, and the old database at
./data/news/db is untouched -- rollback is a two-line edit.
Verified from the public internet, not the LAN:
GET / -> 403, Authelia gating browser paths
POST /api/feedback/../bogus -> {"error":"bad signature"} from the APP,
proving the carve-out bypasses Authelia
POST /api/feedback/../<valid HMAC> -> {"ok":true}, and the row persists
That last line is the loop that never fired once in v0.2, and it is now proven
working from off-LAN with a signature check in front of it.
Applied to the live Caddyfile (snapshot: ~/Caddyfile.pre-news-public) and
reloaded; ddns_update published the A record and Let's Encrypt issued a cert.
The block moved out of the "Internal only -- no public DNS record" section into
the Public section rather than just gaining a marker in place. Those section
headers are load-bearing documentation, and a public block sitting under an
internal header would make the file lie about its own security posture.
/api/feedback/* bypasses Authelia because ntfy runs action buttons from the
phone and a native app cannot complete an interactive login -- the exemption
ntfy, Immich and Jellyfin already have on this box. That path is HMAC-signed
per (cluster, kind), so a leaked URL casts one vote on one story.
Verified after reload: / and /healthz return 403 from Authelia while
/api/feedback/... returns 404, and a 404 from the still-running old backend is
what proves Caddy routed it through unauthenticated.
Worth recording, since it is irreversible: publishing the name also publishes it
to Certificate Transparency, which is append-only and world-readable. The
hostname is public forever regardless of any later change.
Two-stage build. The builder bakes the embedding model into the image (~65 MB),
so a cold container needs no outbound access to Hugging Face and starts scoring
in 0.4s -- verified with HF_HUB_OFFLINE=1.
Three things the smoke test caught that would each have failed in production:
- Debian's base image already ships a system account named "news", so useradd
exits 9 on the collision. The runtime user is "app".
- The image runs as uid 10001 rather than root, so a host-owned volume gives
SQLite "attempt to write a readonly database". Compose needs the `U` mount
flag.
- The baked model cache was root-owned, making fastembed log a permission
warning and discard its tree cache on every start.
Deployment targets the EXISTING service rather than a parallel one: same vhost,
and NTFY_TOPIC=news-triage because the old service defaulted to that rather than
setting it, so the phone subscription keeps working.
The Caddy change is the consequential one and is documented as such: the vhost
moves from LAN-only to public, with /api/feedback/* bypassing Authelia because
ntfy runs action buttons from the phone and a native app cannot complete an
interactive login -- the same exemption ntfy, Immich and Jellyfin already have
on this box. That path is HMAC-signed per (cluster, kind).
ntfy push is deliberately short: a title, one line, and a Click through to the
story. The digest is a WEB PAGE, not a push payload -- the old system sent the
whole report as one long markdown message and ntfy turned it into a .md
attachment, which is what made it miserable to read on a phone.
Feedback buttons finally work. The old ones never fired once: NEWS_API_BASE_URL
was unset so they were dropped behind a single boot WARN, and /api/feedback had
no auth anyway. ntfy runs http actions FROM THE PHONE, so the endpoint must be
public -- which is why each button now carries an HMAC signed over
(cluster_id, kind). A leaked URL authorises one vote on one story rather than
acting as a bearer token for the whole model. Missing config drops the buttons
loudly instead of silently.
Digest prose is one LLM call per day through LiteLLM, and that is the entire
generative-model surface of this system -- clustering, relevance, importance and
vetoes are all embeddings and arithmetic. Feed text is passed as JSON data,
never interpolated into prompt prose, so a headline cannot read as an
instruction. Any failure falls back to the plain list; the digest never depends
on a model being reachable. Verified against the live proxy: 20s, themed
paragraphs, no invented facts.
Digest page is mobile-first, dark-mode aware, and renders from notification_log
so it is a record of what was actually sent.
deploy/README.md documents the Caddy vhost and env the buttons require, and uses
a python healthcheck rather than curl -- the v0.2 image shipped no curl, so its
healthcheck failed forever while the service was fine.
62 tests.