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,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<N>.md`: what landed, deviations
|
||||||
|
from the plan and why, anything left for the next step, test counts.
|
||||||
@@ -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