steid

@jamesgill /

feat: /api/me and a shared identity read model

describe_identity resolves an Actor into a displayable Identity -- user id,
email, and handle -- composing the user with their personal organisation so
callers don't each need to know the handle lives on the org.

Both the home page and /api/me now go through it. That was the point of the
endpoint: the claim that the application layer is transport-neutral had two
milestones of assertion behind it and exactly one consumer. Now it has two,
and the home page shows a handle instead of a raw UUID as a side effect.

A dangling session -- one naming a user or org that no longer exists --
resolves to None rather than erroring, so it renders as signed out instead
of a 500. Both cases are tested.

The JSON response is a DTO, not the domain type. Deriving Serialize on
entities means a new field leaks into the wire format the moment someone
adds one.

/api/me answers 401 when anonymous, matching the convention that /me
describes an authenticated caller.

Verified: 401 anonymous, 200 with correct JSON signed in, home page renders
the same handle, 401 again after logout.

91 tests. Milestone 1 complete.

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

6 files changed+201 −11

src/application/identity.rs+130 −0View file
@@ -0,0 +1,130 @@
1+use crate::domain::{
2+ Actor, Email, OrgName, UserId,
3+ repository::{OrgRepository, UserRepository},
4+};
5+
6+use super::error::Result;
7+
8+/// Who an actor actually is, resolved for display.
9+///
10+/// A read model, not an entity: it composes the user with their personal organisation
11+/// so callers don't each have to know that a handle lives on the org rather than the
12+/// user.
13+#[derive(Debug, Clone, PartialEq, Eq)]
14+pub struct Identity {
15+ pub user_id: UserId,
16+ pub email: Email,
17+ /// The public handle — the `{owner}` segment of this user's URLs.
18+ pub handle: OrgName,
19+}
20+
21+/// Resolves an actor into a displayable identity.
22+///
23+/// `Ok(None)` for an anonymous actor, and also for an authenticated one whose user or
24+/// organisation has since gone: a dangling session should render as signed out, not as
25+/// an error page.
26+pub async fn describe_identity(
27+ actor: &Actor,
28+ users: &impl UserRepository,
29+ orgs: &impl OrgRepository,
30+) -> Result<Option<Identity>> {
31+ let Some(user_id) = actor.user_id() else {
32+ return Ok(None);
33+ };
34+
35+ let Some(user) = users.find_by_id(user_id).await? else {
36+ return Ok(None);
37+ };
38+
39+ let Some(org) = orgs.find_by_id(&user.personal_org_id).await? else {
40+ return Ok(None);
41+ };
42+
43+ Ok(Some(Identity {
44+ user_id: user.id,
45+ email: user.email,
46+ handle: org.name,
47+ }))
48+}
49+
50+#[cfg(test)]
51+mod tests {
52+ use super::*;
53+ use crate::{
54+ domain::{OrgId, Organization, PasswordHash, User},
55+ infrastructure::repository::{InMemoryOrgRepo, InMemoryUserRepo},
56+ };
57+
58+ async fn fixture() -> (InMemoryUserRepo, InMemoryOrgRepo, User) {
59+ let users = InMemoryUserRepo::new();
60+ let orgs = InMemoryOrgRepo::new();
61+ let org = Organization::new(OrgId::generate(), "jimbo", None).expect("valid org");
62+ orgs.save(&org).await.expect("save org");
63+ let user = User::new(
64+ UserId::generate(),
65+ Email::new("jimbo@example.com").expect("valid email"),
66+ PasswordHash::from_trusted("$argon2id$test"),
67+ org.id.clone(),
68+ );
69+ users.save(&user).await.expect("save user");
70+ (users, orgs, user)
71+ }
72+
73+ #[tokio::test]
74+ async fn an_anonymous_actor_has_no_identity() {
75+ let (users, orgs, _) = fixture().await;
76+
77+ let identity = describe_identity(&Actor::Anonymous, &users, &orgs)
78+ .await
79+ .expect("describe");
80+
81+ assert_eq!(identity, None);
82+ }
83+
84+ #[tokio::test]
85+ async fn an_authenticated_actor_resolves_to_its_handle_and_email() {
86+ let (users, orgs, user) = fixture().await;
87+
88+ let identity = describe_identity(&Actor::User(user.id.clone()), &users, &orgs)
89+ .await
90+ .expect("describe")
91+ .expect("should resolve");
92+
93+ assert_eq!(identity.user_id, user.id);
94+ assert_eq!(identity.email.as_str(), "jimbo@example.com");
95+ assert_eq!(identity.handle.as_str(), "jimbo");
96+ }
97+
98+ #[tokio::test]
99+ async fn a_session_for_a_deleted_user_reads_as_signed_out() {
100+ let (users, orgs, _) = fixture().await;
101+
102+ let identity = describe_identity(&Actor::User(UserId::generate()), &users, &orgs)
103+ .await
104+ .expect("describe");
105+
106+ assert_eq!(
107+ identity, None,
108+ "a dangling session should render as signed out, not error"
109+ );
110+ }
111+
112+ #[tokio::test]
113+ async fn a_user_whose_org_is_missing_reads_as_signed_out() {
114+ let users = InMemoryUserRepo::new();
115+ let orgs = InMemoryOrgRepo::new();
116+ let user = User::new(
117+ UserId::generate(),
118+ Email::new("orphan@example.com").expect("valid email"),
119+ PasswordHash::from_trusted("$argon2id$test"),
120+ OrgId::generate(),
121+ );
122+ users.save(&user).await.expect("save user");
123+
124+ let identity = describe_identity(&Actor::User(user.id), &users, &orgs)
125+ .await
126+ .expect("describe");
127+
128+ assert_eq!(identity, None);
129+ }
130+}
src/application/mod.rs+2 −0View file
@@ -6,6 +6,7 @@
66 pub mod claim;
77 pub mod config;
88 pub mod error;
9+pub mod identity;
910 pub mod login;
1011 pub mod port;
1112 pub mod session;
@@ -13,5 +14,6 @@ pub mod session;
1314 pub use claim::{OwnerSpec, claim_instance, is_claimed};
1415 pub use config::AppConfig;
1516 pub use error::{Error, Result};
17+pub use identity::{Identity, describe_identity};
1618 pub use login::login;
1719 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
src/infrastructure/web/api.rs+41 −0View file
@@ -0,0 +1,41 @@
1+//! JSON API.
2+//!
3+//! Not a milestone of its own — every use case gets a second surface here where that
4+//! makes sense. Having two consumers is what keeps the application layer honest about
5+//! staying transport-neutral.
6+
7+use serde::Serialize;
8+use topcoat::{
9+ Result,
10+ context::Cx,
11+ router::{content::Json, error::unauthorized, route},
12+};
13+
14+use super::context::identity;
15+
16+/// The authenticated user, as JSON.
17+///
18+/// A DTO rather than the domain type: serialisation is a wire concern, and deriving
19+/// `Serialize` on domain entities lets a field leak into a response the moment someone
20+/// adds one.
21+#[derive(Debug, Serialize)]
22+struct Me {
23+ id: String,
24+ email: String,
25+ handle: String,
26+}
27+
28+/// Who the caller is.
29+///
30+/// 401 when anonymous, matching the convention that `/me` describes an authenticated
31+/// caller and has nothing to say without one.
32+#[route(GET "/api/me")]
33+async fn me(cx: &Cx) -> Result<Json<Me>> {
34+ let identity = identity(cx).await?.ok_or_else(unauthorized)?;
35+
36+ Ok(Json(Me {
37+ id: identity.user_id.to_string(),
38+ email: identity.email.to_string(),
39+ handle: identity.handle.to_string(),
40+ }))
41+}
src/infrastructure/web/context.rs+18 −2View file
@@ -15,9 +15,9 @@ use topcoat::{
1515 };
1616
1717 use crate::{
18 application::{is_claimed, resolve_actor},
18+ application::{Identity, describe_identity, is_claimed, resolve_actor},
1919 domain::{Actor, SessionTokenHash},
20 infrastructure::repository::{SqliteSessionRepo, SqliteUserRepo},
20+ infrastructure::repository::{SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo},
2121 };
2222
2323 /// The one-time token that authorises claiming an unclaimed instance.
@@ -55,6 +55,22 @@ pub async fn current_actor(cx: &Cx) -> Result<Actor> {
5555 .map_err(server_error)
5656 }
5757
58+/// Who the current actor actually is, resolved for display.
59+///
60+/// `None` when anonymous, or when the session outlived the user it names.
61+pub async fn identity(cx: &Cx) -> Result<Option<Identity>> {
62+ let actor = current_actor(cx).await?;
63+ let pool = pool(cx).clone();
64+
65+ describe_identity(
66+ &actor,
67+ &SqliteUserRepo::new(pool.clone()),
68+ &SqliteOrgRepo::new(pool),
69+ )
70+ .await
71+ .map_err(server_error)
72+}
73+
5874 /// Whether this installation has an owner yet.
5975 pub async fn claimed(cx: &Cx) -> Result<bool> {
6076 let users = SqliteUserRepo::new(pool(cx).clone());
src/infrastructure/web/mod.rs+1 −0View file
@@ -1,5 +1,6 @@
11 //! The web surface: pages, forms, and the request-scoped helpers they use.
22
3+pub mod api;
34 pub mod context;
45 pub mod layout;
56 pub mod pages;
src/infrastructure/web/pages.rs+9 −9View file
@@ -5,32 +5,32 @@ use topcoat::{
55 view::view,
66 };
77
8use crate::domain::Actor;
9
10use super::context::{claimed, current_actor};
8+use super::context::{claimed, identity};
119
1210 /// The home page.
1311 ///
1412 /// A placeholder until Milestone 2 makes `/{owner}` the real one, but enough to prove
1513 /// the identity flow end to end.
14+///
15+/// Reads through the same `describe_identity` use case that `/api/me` uses — which is
16+/// the point of keeping it in the application layer rather than querying here.
1617 #[page("/")]
1718 async fn home(cx: &Cx) -> Result {
1819 if !claimed(cx).await? {
1920 return Err(redirect("/setup").into());
2021 }
2122
22 let actor = current_actor(cx).await?;
23
2423 view! {
2524 <h1>"steid"</h1>
26 (match &actor {
27 Actor::User(id) => view! {
28 <p>"Signed in as " <code>(id.as_str())</code></p>
25+ (match identity(cx).await? {
26+ Some(identity) => view! {
27+ <p>"Signed in as " <strong>(identity.handle.as_str())</strong></p>
28+ <p>(identity.email.as_str())</p>
2929 <form method="post" action="/logout">
3030 <button type="submit">"Sign out"</button>
3131 </form>
3232 },
33 Actor::Anonymous => view! {
33+ None => view! {
3434 <p>"Not signed in."</p>
3535 <p><a href="/login">"Sign in"</a></p>
3636 },