Web dashboard: shared and step 1 agent briefs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -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<i64>`
|
||||
(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<T>`, `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
|
||||
`<details>` persistence bits); the rating fetch enhancement is step 2.
|
||||
5. **`AppState` refactor** (§4.2): `config: Arc<RwLock<Arc<Config>>>`,
|
||||
`config_path: Option<PathBuf>`, `web: Arc<WebState>`, `AppState::config()`;
|
||||
`server::serve(config, config_path, db)`; `main::serve` passes
|
||||
`Config::resolve_path(cli.config)`. Existing handlers call `state.config()`.
|
||||
`WebState { jobs: Arc<dyn JobRunner>, 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::<SocketAddr>` 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.
|
||||
Reference in New Issue
Block a user