Evidence — Todo 15: NtfyPublisher + ntfy access snippet
=======================================================
Files created:
- crates/news-server/src/notify/mod.rs
- crates/news-server/src/notify/ntfy.rs
- deploy/ntfy-access-snippet.md
Files modified:
- crates/news-server/src/lib.rs (added `pub mod notify;`)
- crates/news-server/Cargo.toml (reqwest, url, tokio time feature, wiremock dev-dep)
- Cargo.toml (workspace `url = "2"`)
Verification commands
---------------------
1) cargo fmt --all --check
Output: (no output) → exit 0
2) cargo clippy --workspace --all-targets -- -D warnings
Output tail:
Checking news-server v0.1.0 (...)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.34s
→ exit 0
3) cargo test -p news-server
Output tail:
running 20 tests
test notify::ntfy::tests::normal_lane_sends_priority_three ... ok
test notify::ntfy::tests::bypass_lane_sends_priority_five ... ok
test notify::ntfy::tests::request_has_required_headers ... ok
test notify::ntfy::tests::retries_once_on_server_error_then_err ... ok
...
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Ntfy tests added:
- notify::ntfy::tests::request_has_required_headers
- notify::ntfy::tests::bypass_lane_sends_priority_five
- notify::ntfy::tests::normal_lane_sends_priority_three
- notify::ntfy::tests::retries_once_on_server_error_then_err
Key implementation notes
------------------------
- Header shape mirrors movie_recs_notify: `Title`, `Tags`, `Priority`, `Markdown: yes`, `Content-Type: text/plain; charset=utf-8`, plus ntfy-specific `Click` and `Actions`.
- Actions format follows ntfy docs short form with `; ` separators:
`view, Open article, ; http, Interested, /api/feedback, method=POST, body='{"story_cluster_id":"...","kind":"interested"}'; http, Not interested, ...`
- JSON bodies are wrapped in single quotes so the comma/colon-rich payload does not break ntfy's simple action parser.
- Auth: `NTFY_TOKEN` → `Authorization: Bearer ...`; `NTFY_USER`+`NTFY_PASS` → reqwest `.basic_auth(...)` (Basic base64); otherwise none.
- Retry: transport errors and 5xx sleep 500 ms and retry exactly once; 4xx returns `Err` immediately. No unwrap/expect/panic.
- `feedback_base_url` is read from `NEWS_API_BASE_URL` with default `http://localhost:3000`.
--- deploy/ntfy-access-snippet.md full content ---
# ntfy access provisioning for `news-triage`
The ntfy server is configured with a deny-by-default posture:
```yaml
# /home/connor/data/ntfy/etc/server.yml
auth-default-access: "deny-all"
```
Because of that posture, the `news-bot` user must be created and granted
read/write access to the `news-triage` topic before `news-server` can publish
notifications. These are one-time operator actions.
Run from the host that owns the `ntfy` container:
```bash
# Create the publishing user (interactive password prompt).
sudo podman exec -it ntfy ntfy user add news-bot
# Grant read/write access to the news-triage topic.
sudo podman exec ntfy ntfy access news-bot news-triage rw
```
Store the resulting password in the deployment secret path (`~/.env` for the
`news-backend` compose block) as `NTFY_USER` and `NTFY_PASS`.