From bf2ab8441ef0ceefb78ab070aa89c01b43a9f132 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Thu, 3 Sep 2026 04:01:25 +0000 Subject: [PATCH] Web dashboard: shared and step 1 agent briefs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM --- docs/plans/briefs/web-dashboard/00-shared.md | 43 +++++++ .../briefs/web-dashboard/01-foundation.md | 113 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 docs/plans/briefs/web-dashboard/00-shared.md create mode 100644 docs/plans/briefs/web-dashboard/01-foundation.md diff --git a/docs/plans/briefs/web-dashboard/00-shared.md b/docs/plans/briefs/web-dashboard/00-shared.md new file mode 100644 index 0000000..a5df4e3 --- /dev/null +++ b/docs/plans/briefs/web-dashboard/00-shared.md @@ -0,0 +1,43 @@ +# Shared brief — web dashboard implementation agents + +You are implementing one step of `docs/plans/2026-09-03-web-dashboard.md` in the +`daily-epub` Rust crate. Read that plan **in full** before writing code — every +decision in it is settled (§0), and §2 records verified facts about the crates and +the host. Also read `docs/plans/2026-08-15-implementation-notes.md` (conventions: +runtime sqlx queries with manual row mapping, `jiff` for time with RFC3339 UTC +strings in SQLite, `thiserror`/`anyhow` errors, askama templates, no network in +tests, rustfmt defaults, no `unwrap()` outside tests, tracing spans). + +## Ground rules + +- Work on the current branch (`web-dashboard`) in place. Do **not** commit; the + orchestrator reviews and commits. Do not create branches or stash. +- Do not edit earlier migrations (`0001`–`0003`). Step 1 creates + `migrations/0004_web.sql`; later steps may append a new migration file only if + the plan says so. +- Templates for the web live in `src/web/templates/` (`.html`, HTML-escaped by + default); `askama.toml` lists both template dirs. EPUB templates in + `src/epub/templates/` are untouched. +- Every `|safe` in a template must be one of the sanitized inputs listed in plan + §16; user text is never `|safe`. +- The existing routes (`/r/…`, `/opds…`, `/files/…`, `/healthz`, `/issues.json`) + and their tests keep working unchanged. +- Tests: unit tests inline per module; router tests with + `tower::ServiceExt::oneshot` against `server::router(...)` over a temp DB + (`tempfile`), never the network, never systemd. Add the tests the plan's §17 + lists for your step. +- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and `cargo test` + green. Finish with those three commands and report their output. +- **Sandbox note:** inside your sandbox `bind()` on 127.0.0.1 is forbidden, so + exactly these pre-existing tests fail there and must be ignored: + `curate::llm::tests::anthropic_*` (4), `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`, + `server::tests::*` (5 that bind a listener), and any test in `tests/m7_server.rs`. + Do not "fix" them. Any **new** router test must use `oneshot` and not bind. + The orchestrator runs the full suite outside the sandbox. +- Don't gold-plate: implement what the plan says for your step and nothing from + later steps beyond stubs the plan explicitly asks for. Where the plan's + sketch and the real crate API disagree, follow the real API (read the crate + source in `~/.cargo/registry` or `~/.cargo/git`) and note the deviation. +- When you finish, write a short handoff at + `docs/plans/briefs/web-dashboard/handoff-step.md`: what landed, deviations + from the plan and why, anything left for the next step, test counts. diff --git a/docs/plans/briefs/web-dashboard/01-foundation.md b/docs/plans/briefs/web-dashboard/01-foundation.md new file mode 100644 index 0000000..c214ded --- /dev/null +++ b/docs/plans/briefs/web-dashboard/01-foundation.md @@ -0,0 +1,113 @@ +# Step 1 — Foundation + +Read `docs/plans/briefs/web-dashboard/00-shared.md` first, then the plan +`docs/plans/2026-09-03-web-dashboard.md`. This step is plan §19 item 1. Sections +that govern it: §2 (verified crate facts — read carefully, especially the +axum-login git rev API), §3 (routes and access levels), §4 (architecture, state, +templates, design system), §5 (migration and data model), §6 (auth, sessions, +CSRF, throttle), §7 (public site), §15 (config keys and dependencies), §16 +(security checklist), §17 (tests for the parts you build). + +## Deliverables + +1. **Dependencies** (§15): `axum-login` from git at rev + `151c72d7a1b4646830f86b4332e6bd6e34d719a7`, `password-auth`, `tower_governor` + (feature `axum`), `time`, `async-trait`, `rpassword`, `toml_edit`, `toml` + (promote to direct), dev `tower` with `util`. Use `cargo add`; do not hand-pin + old versions; do not add `tower-sessions` or `argon2` separately. Confirm + `cargo tree -d` shows a single sqlx and a single `libsqlite3-sys`. +2. **Migration `migrations/0004_web.sql`** exactly as §5.1 (users, sessions + + indexes, `rating_events.user_id`, `config_changes`, `profile_versions`, + `jobs`, `runs.report_json`, `issues.issue_json`), plus the index + `idx_candidate_runs_article_run ON candidate_runs(article_id, run_id DESC)` + from §9.3 (put it in this migration so step 3 needs no new file). +3. **Data model changes** (§5.2–§5.4): `RatingEvent.user_id: Option` + (bound by `append_rating_event`, read back by `current_ratings*` / + `rated_article_from_row`); `db::finish_run` writes `runs.report_json`; + `pipeline::record_issue` writes `issues.report_json` (fixing the never-written + column) and `issues.issue_json` (the `Issue` with every + `pick.article.content_html` emptied); `GenerateOutcome.run_id: i64`. Add the + `db` accessors the loader needs (`issue_by_date`, `issue_dates`/listing for + the archive, `latest_issue_date`). +4. **`src/web/` skeleton** (§4): `mod.rs` (`WebState`, `Html`, `WebError`, + `Page` layout context, pagination + time helpers), `session.rs` + (`SqliteSessionStore`, `Backend`, `Credentials`, `AuthSession` alias, `Viewer`, + `require_same_origin` middleware), `users.rs` (Role, User with redacting + Debug, hash/verify wrappers, username/password rules, the CLI operations), + `public.rs` (`PublicIssue` + `/`, `/issues`, `/issues/{date}` public branch, + `/feed.xml`, `/robots.txt`), `issue.rs` (the `IssueView` loader with + `issue_json` → row fallback per §5.2; the *full* page template is step 2 — + for now a signed-in viewer on `/` and `/issues/{date}` may see the public + rendering plus the downloads list, and the loader must be complete and + tested), `static/{app.css,app.js,favicon.svg}` served from `/static/{file}` + with sha256 ETag / 304 and `Cache-Control: public, max-age=86400`, and the + templates `layout.html`, `error.html`, `login.html`, `account.html`, + `issue_public.html`, `issue_list.html`, `feed_entry.html`, `_pagination.html`. + `app.css` implements the §4.4 design tokens, masthead, reading column, the + dashboard table/badge/kv/funnel/spark/rating classes (so later steps only add + to it), light and dark. `app.js` can be minimal now (the `data-confirm` and + `
` persistence bits); the rating fetch enhancement is step 2. +5. **`AppState` refactor** (§4.2): `config: Arc>>`, + `config_path: Option`, `web: Arc`, `AppState::config()`; + `server::serve(config, config_path, db)`; `main::serve` passes + `Config::resolve_path(cli.config)`. Existing handlers call `state.config()`. + `WebState { jobs: Arc, started_at, config_mtime }` — define a + minimal `JobRunner` trait + `DisabledRunner`/`MockRunner` in `src/web/mod.rs` + or a small `src/web/dashboard/jobs.rs` stub now so the state shape is final + (step 6 fills in `SystemdRunner` and the pages). +6. **Auth** (§6): session layer, auth layer, governor on `POST /login` + (`into_make_service_with_connect_info::` in `serve`; a periodic + `retain_recent` task; a periodic `delete_expired` task), `/login` GET/POST, + `/logout`, `/account` (change password, sign out everywhere), the origin-check + middleware on every POST except `/r/*`, `login_required!` / + `permission_required!` route layers on the sub-routers (a `/dashboard` stub + router with an admin-only placeholder overview page is fine so the guard + tests in §17 can run now), and the 403 → site error page `map_response`. + `/files/*` accepts a session or Basic auth per §8's last paragraph. +7. **Security headers** (§16) on every app response: CSP, nosniff, referrer + policy; `Cache-Control: no-store` on `/dashboard/*`; `Vary: Cookie` on HTML; + public pages `public, max-age=300` only without a session cookie. +8. **CLI** `daily-epub users add|passwd|role|disable|enable|list|logout` (§6.1), + none of which take the run lock; `users add --admin` bootstraps. +9. **Config** (§15): the new `[server]` keys with validation, `Config::default()`, + `config.example.toml`, README table (the key-for-key test must pass). +10. **README**: the route table gains the new public routes and the + session-or-Basic note on `/files/*`; a sentence that `SmartIpKeyExtractor` + trusts `X-Forwarded-For` because only nginx reaches the bind address. +11. **Tests** from §17: migration, users/passwords, session store, login flow + (cookie flags, disabled user, password change logs out other sessions, + logout, anonymous `/` sets no cookie, `next` validation), guards, origin + check, throttle (burst 3 test config), public rendering + (`public_issue_carries_no_generated_text`, comment links, no World Briefing, + feed is valid XML with one entry per issue, robots.txt, cache headers), + issue_json round trip + fallback loader + `runs.report_json` / + `issues.report_json` written and `/issues.json` returning real reports, and + the `/files/epub` session-or-Basic behaviour. The binary-level test in + `tests/m7_server.rs` (serves `/`, `/issues`, `/feed.xml`, `/login`; + `/dashboard` redirects; `users add` then a real login over TCP) — add it, but + remember it cannot run inside your sandbox. + +## Notes and traps + +- Read the axum-login source at the pinned rev under `~/.cargo/git/checkouts` + after `cargo add` to confirm the `Require` builder, `AuthSession::user().await`, + `login(&self, &user)`, the macros' signatures, and the session data key + `"axum-login.data"`. §2 describes what was verified; trust the source over the + plan's sketch if they differ, and record the difference in your handoff. +- `tower-sessions` 0.15 `SessionStore` still uses `async_trait`; axum-login's + own traits do not. +- `password_auth::verify_password` blocks — run it in `spawn_blocking`, and use + a fixed dummy hash for unknown usernames so timing does not reveal existence. +- The governor's `per_second` takes seconds per token: `login_window_minutes * + 60 / login_attempts`. +- The `sessions.user_id` column is denormalized best-effort from + `record.data["axum-login.data"]["user_id"]` — verify the actual shape axum-login + stores (it may be a struct with `user_id` and `auth_hash`). +- `PublicIssue::from(&Issue)` is the only constructor and carries no generated + text; the no-leak test renders `fixtures::issue()` publicly and asserts the + Brief, every summary, every `why`, article body sentences and comment strings + are absent. +- The `serve_file` Basic-auth fallback must keep the existing OPDS client + behaviour byte for byte when no session cookie is present. +- Keep `server.rs` as the router root: `router()` merges `web::router()` + sub-routers; do not move the existing handlers.