steid

@jamesgill /

feat: claim, sign in, and sign out through the browser

The identity work is now reachable: / redirects to /setup while unclaimed,
/setup claims the instance with the token printed at boot, and the owner
lands signed in. /login and /logout complete the loop.

The setup token is registered in app context only while the instance is
unclaimed, so a claimed installation has no token for a claim attempt to
match against -- defence in depth behind the use case's own check.

Pages render inside a root #[layout("/")] rather than a shell helper:
view! needs the request context in scope, so a plain fn cannot build one.
Layouts wrap by path prefix, which also removed the duplicated <html> from
the home page.

Request helpers are functions taking cx, not middleware or extractors --
Topcoat's guidance and the safer shape. A page that forgets current_actor
gets no actor; a route added without its middleware would silently get
someone else's.

Verified end to end against a fresh database: wrong token refused with no
user created, correct token creates org + user + owner membership and issues
a __Host- session cookie, the cookie authenticates, logout clears both the
cookie and the row, a wrong password bounces, the right one signs in, and
re-claiming with the still-valid token is refused with the owner intact.

87 tests, clean clippy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparent54a0eafBrowse files14223e7699645bcb53f5fd2d3ba73e46deda88be

7 files changed+343 −40

src/infrastructure/web.rs+0 −34
@@ -1,34 +0,0 @@
1use sqlx::SqlitePool;
2use topcoat::{
3 Result,
4 context::{Cx, app_context},
5 router::page,
6 view::view,
7};
8
9/// Placeholder home page.
10///
11/// Milestone 0 only: it renders a value read through the pool to prove config,
12/// database, and app context are wired end to end. Milestone 1 replaces it with the
13/// profile page, which is the real home page.
14#[page("/")]
15async fn home(cx: &Cx) -> Result {
16 let pool: &SqlitePool = app_context(cx);
17 let sqlite_version: String = sqlx::query_scalar("select sqlite_version()")
18 .fetch_one(pool)
19 .await?;
20
21 view! {
22 <!DOCTYPE html>
23 <html>
24 <head>
25 <title>"steid"</title>
26 topcoat::dev::script()
27 </head>
28 <body>
29 <h1>"steid"</h1>
30 <p>"sqlite " (sqlite_version)</p>
31 </body>
32 </html>
33 }
34}
src/infrastructure/web/context.rs+73 −0View file
@@ -0,0 +1,73 @@
1+//! Request-scoped helpers.
2+//!
3+//! Functions taking `cx: &Cx`, not middleware or extractors — Topcoat's guidance, and
4+//! the safer shape: a page that forgets to call `current_actor` gets no actor, whereas
5+//! a route added without its middleware silently gets someone else's.
6+
7+use std::time::SystemTime;
8+
9+use sqlx::SqlitePool;
10+use topcoat::{
11+ Result,
12+ context::{Cx, app_context},
13+ router::error::internal_server_error,
14+ session,
15+};
16+
17+use crate::{
18+ application::{is_claimed, resolve_actor},
19+ domain::{Actor, SessionTokenHash},
20+ infrastructure::repository::{SqliteSessionRepo, SqliteUserRepo},
21+};
22+
23+/// The one-time token that authorises claiming an unclaimed instance.
24+///
25+/// Present in app context only while the installation is unclaimed.
26+pub struct SetupState(pub crate::domain::SetupToken);
27+
28+pub fn pool(cx: &Cx) -> &SqlitePool {
29+ app_context::<SqlitePool>(cx)
30+}
31+
32+/// Anything below the web layer failing is a 500 — the visitor can't act on it, and
33+/// the detail belongs in the log rather than the page.
34+fn server_error<E>(error: E) -> topcoat::Error
35+where
36+ E: std::error::Error + Send + Sync + 'static,
37+{
38+ eprintln!("steid: {error}");
39+ internal_server_error(error).into()
40+}
41+
42+/// Who is making this request.
43+///
44+/// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
45+/// or it has expired.
46+pub async fn current_actor(cx: &Cx) -> Result<Actor> {
47+ let presented = session::token_hash(cx)
48+ .await?
49+ .map(|hash| SessionTokenHash::from_trusted(hex(&hash)));
50+
51+ let sessions = SqliteSessionRepo::new(pool(cx).clone());
52+
53+ resolve_actor(presented.as_ref(), SystemTime::now(), &sessions)
54+ .await
55+ .map_err(server_error)
56+}
57+
58+/// Whether this installation has an owner yet.
59+pub async fn claimed(cx: &Cx) -> Result<bool> {
60+ let users = SqliteUserRepo::new(pool(cx).clone());
61+
62+ is_claimed(&users).await.map_err(server_error)
63+}
64+
65+/// The setup token, when the instance is still unclaimed.
66+pub fn setup_token(cx: &Cx) -> Option<&crate::domain::SetupToken> {
67+ topcoat::context::try_app_context::<SetupState>(cx).map(|state| &state.0)
68+}
69+
70+/// Renders a `TokenHash` as lowercase hex for storage.
71+pub fn hex(hash: &session::TokenHash) -> String {
72+ hash.iter().map(|byte| format!("{byte:02x}")).collect()
73+}
src/infrastructure/web/layout.rs+26 −0View file
@@ -0,0 +1,26 @@
1+use topcoat::{Result, router::layout, view::view};
2+
3+/// The HTML shell every page renders inside.
4+///
5+/// A layout rather than a helper function: `view!` needs the request context in scope,
6+/// so a plain `fn shell(..)` cannot build one. Layouts wrap by path prefix, and this
7+/// one is at `/`, so it wraps everything.
8+#[layout("/")]
9+async fn root_layout(slot: Result) -> Result {
10+ let content = slot?;
11+
12+ view! {
13+ <!DOCTYPE html>
14+ <html lang="en">
15+ <head>
16+ <meta charset="utf-8" />
17+ <meta name="viewport" content="width=device-width, initial-scale=1" />
18+ <title>"steid"</title>
19+ topcoat::dev::script()
20+ </head>
21+ <body>
22+ <main>(content)</main>
23+ </body>
24+ </html>
25+ }
26+}
src/infrastructure/web/mod.rs+6 −0View file
@@ -0,0 +1,6 @@
1+//! The web surface: pages, forms, and the request-scoped helpers they use.
2+
3+pub mod context;
4+pub mod layout;
5+pub mod pages;
6+pub mod setup;
src/infrastructure/web/pages.rs+39 −0View file
@@ -0,0 +1,39 @@
1+use topcoat::{
2+ Result,
3+ context::Cx,
4+ router::{error::redirect, page},
5+ view::view,
6+};
7+
8+use crate::domain::Actor;
9+
10+use super::context::{claimed, current_actor};
11+
12+/// The home page.
13+///
14+/// A placeholder until Milestone 2 makes `/{owner}` the real one, but enough to prove
15+/// the identity flow end to end.
16+#[page("/")]
17+async fn home(cx: &Cx) -> Result {
18+ if !claimed(cx).await? {
19+ return Err(redirect("/setup").into());
20+ }
21+
22+ let actor = current_actor(cx).await?;
23+
24+ view! {
25+ <h1>"steid"</h1>
26+ (match &actor {
27+ Actor::User(id) => view! {
28+ <p>"Signed in as " <code>(id.as_str())</code></p>
29+ <form method="post" action="/logout">
30+ <button type="submit">"Sign out"</button>
31+ </form>
32+ },
33+ Actor::Anonymous => view! {
34+ <p>"Not signed in."</p>
35+ <p><a href="/login">"Sign in"</a></p>
36+ },
37+ }?)
38+ }
39+}
src/infrastructure/web/setup.rs+165 −0View file
@@ -0,0 +1,165 @@
1+//! First-run claim and sign-in.
2+
3+use serde::Deserialize;
4+use topcoat::{
5+ Result,
6+ context::Cx,
7+ router::{
8+ content::Form,
9+ error::{SeeOther, redirect, see_other},
10+ page, route,
11+ },
12+ session,
13+ view::view,
14+};
15+
16+use crate::{
17+ application::{OwnerSpec, claim_instance, end_session, login, record_session},
18+ domain::Actor,
19+ infrastructure::{
20+ password::Argon2Hasher,
21+ repository::{SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo},
22+ },
23+};
24+
25+use super::context::{claimed, hex, pool, setup_token};
26+
27+#[derive(Debug, Deserialize)]
28+struct ClaimForm {
29+ token: String,
30+ handle: String,
31+ email: String,
32+ password: String,
33+}
34+
35+#[derive(Debug, Deserialize)]
36+struct LoginForm {
37+ email: String,
38+ password: String,
39+}
40+
41+#[page("/setup")]
42+async fn setup_page(cx: &Cx) -> Result {
43+ if claimed(cx).await? {
44+ return Err(redirect("/").into());
45+ }
46+
47+ view! {
48+ <h1>"Claim this instance"</h1>
49+ <p>"The setup token was printed to the server log at startup."</p>
50+ <form method="post" action="/setup">
51+ <p><label>"Setup token " <input type="text" name="token" required="true" /></label></p>
52+ <p><label>"Handle " <input type="text" name="handle" required="true" /></label></p>
53+ <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
54+ <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
55+ <button type="submit">"Claim"</button>
56+ </form>
57+ }
58+}
59+
60+#[route(POST "/setup")]
61+async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
62+ // Absent once claimed, so a claimed instance cannot be re-claimed even if the
63+ // use case were somehow reached.
64+ let token = setup_token(cx).ok_or_else(|| redirect("/"))?;
65+
66+ let pool = pool(cx).clone();
67+ let users = SqliteUserRepo::new(pool.clone());
68+ let orgs = SqliteOrgRepo::new(pool.clone());
69+ let memberships = SqliteMembershipRepo::new(pool.clone());
70+ let sessions = SqliteSessionRepo::new(pool);
71+
72+ let actor = claim_instance(
73+ &form.token,
74+ token,
75+ &OwnerSpec {
76+ handle: form.handle,
77+ email: form.email,
78+ password: form.password,
79+ },
80+ &users,
81+ &orgs,
82+ &memberships,
83+ &Argon2Hasher::new(),
84+ )
85+ .await
86+ .map_err(|error| {
87+ eprintln!("steid: claim rejected: {error}");
88+ redirect("/setup")
89+ })?;
90+
91+ sign_in(cx, &actor, &sessions).await?;
92+
93+ Ok(see_other("/"))
94+}
95+
96+#[page("/login")]
97+async fn login_page(cx: &Cx) -> Result {
98+ if !claimed(cx).await? {
99+ return Err(redirect("/setup").into());
100+ }
101+
102+ view! {
103+ <h1>"Sign in"</h1>
104+ <form method="post" action="/login">
105+ <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
106+ <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
107+ <button type="submit">"Sign in"</button>
108+ </form>
109+ }
110+}
111+
112+#[route(POST "/login")]
113+async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther> {
114+ let pool = pool(cx).clone();
115+ let users = SqliteUserRepo::new(pool.clone());
116+ let sessions = SqliteSessionRepo::new(pool);
117+
118+ let actor = login(&form.email, &form.password, &users, &Argon2Hasher::new())
119+ .await
120+ .map_err(|error| {
121+ eprintln!("steid: login rejected: {error}");
122+ redirect("/login")
123+ })?;
124+
125+ sign_in(cx, &actor, &sessions).await?;
126+
127+ Ok(see_other("/"))
128+}
129+
130+#[route(POST "/logout")]
131+async fn logout(cx: &Cx) -> Result<SeeOther> {
132+ // Both halves: the client discards its token, and the record goes. Doing only the
133+ // first leaves the session valid server-side.
134+ if let Some(hash) = session::stop(cx).await? {
135+ let sessions = SqliteSessionRepo::new(pool(cx).clone());
136+ end_session(
137+ &crate::domain::SessionTokenHash::from_trusted(hex(&hash)),
138+ &sessions,
139+ )
140+ .await
141+ .map_err(topcoat::Error::from)?;
142+ }
143+
144+ Ok(see_other("/"))
145+}
146+
147+/// Issues a session for an authenticated actor and records it.
148+async fn sign_in(cx: &Cx, actor: &Actor, sessions: &SqliteSessionRepo) -> Result<()> {
149+ let Some(user_id) = actor.user_id() else {
150+ return Ok(());
151+ };
152+
153+ let session = session::start(cx).await?;
154+
155+ record_session(
156+ crate::domain::SessionTokenHash::from_trusted(hex(&session.token_hash)),
157+ user_id.clone(),
158+ session.expires_at,
159+ sessions,
160+ )
161+ .await
162+ .map_err(topcoat::Error::from)?;
163+
164+ Ok(())
165+}
src/main.rs+34 −6View file
@@ -1,5 +1,13 @@
1use steid::{application::AppConfig, infrastructure};
2use topcoat::router::{Router, RouterBuilderDiscoverExt};
1+use steid::{
2+ application::{AppConfig, is_claimed},
3+ domain::SetupToken,
4+ infrastructure::{self, repository::SqliteUserRepo, web::context::SetupState},
5+};
6+use topcoat::{
7+ cookie::RouterBuilderCookieExt,
8+ router::{Router, RouterBuilderDiscoverExt},
9+ session::{RouterBuilderSessionExt, SessionConfig},
10+};
311
412 #[tokio::main]
513 async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -8,13 +16,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
816 let config = AppConfig::from_env()?;
917 let pool = infrastructure::database::connect(&config.database_url).await?;
1018
11 let router = Router::builder()
19+ let mut builder = Router::builder()
20+ .cookies()
21+ .sessions(SessionConfig::default())
1222 .discover()
1323 .app_context(config)
14 .app_context(pool)
15 .build();
24+ .app_context(pool.clone());
1625
17 topcoat::start(router).await?;
26+ // The setup token exists only while the instance is unclaimed, so a claimed
27+ // installation has no token in context for a claim attempt to match against.
28+ if !is_claimed(&SqliteUserRepo::new(pool)).await? {
29+ let token = SetupToken::generate();
30+ announce_setup(&token);
31+ builder = builder.app_context(SetupState(token));
32+ }
33+
34+ topcoat::start(builder.build()).await?;
1835
1936 Ok(())
2037 }
38+
39+/// Prints the claim instructions. The only time the token is ever revealed.
40+fn announce_setup(token: &SetupToken) {
41+ println!();
42+ println!(" This steid has no owner yet. Claim it at /setup with:");
43+ println!();
44+ println!(" {}", token.reveal());
45+ println!();
46+ println!(" The token is held in memory only — restarting issues a new one.");
47+ println!();
48+}