steid

@jamesgill /

feat: identity domain model

Typed IDs, Email, PasswordHash, OrgName, Organization, User, Membership,
Role, Actor, DomainError. 21 tests.

Notable choices:

Actor is an enum with an explicit Anonymous variant rather than an
Option<UserId>. Attempt #2 modelled the unauthenticated case as a
placeholder UserId("ssh-anonymous") and it became a security hole -- a
variant cannot be forgotten the way a sentinel can.

PasswordHash implements Debug by hand to redact the digest and has no
Display, so a hash cannot reach a log line by accident.

Role::from_str returns Result, never Option. A silently-defaulted role
surfaces days later as the wrong permissions.

Value objects pair new() (validates user input) with from_trusted() (skips
validation for rows already validated on the way in), so tightening a rule
later cannot make stored rows unreadable.

Split the crate into a library plus a thin binary. The domain layer has no
consumers until the next commit, and in a bare binary that reads as ~30
dead-code warnings; as a library these are public API. It also unlocks the
tests/ integration tests the runbook asks for. Verified Topcoat's link-time
page discovery still works from a library -- the page serves unchanged.

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

14 files changed+670 −6

Cargo.lock+1 −0View file
@@ -1786,6 +1786,7 @@ dependencies = [
17861786 "sqlx",
17871787 "tokio",
17881788 "topcoat",
1789+ "uuid",
17891790 ]
17901791
17911792 [[package]]
Cargo.toml+1 −0View file
@@ -10,3 +10,4 @@ serde = { version = "1.0.229", features = ["derive"] }
1010 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros"] }
1111 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
1212 topcoat = "0.5.0"
13+uuid = { version = "1.24.0", features = ["v4"] }
src/application/config.rs+5 −2View file
@@ -42,8 +42,11 @@ mod tests {
4242 /// Builds a config from an explicit iterator rather than the process environment,
4343 /// so tests don't race on shared global state.
4444 fn from_pairs(pairs: &[(&str, &str)]) -> Result<AppConfig, envy::Error> {
45 envy::prefixed("STEID_")
46 .from_iter(pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())))
45+ envy::prefixed("STEID_").from_iter(
46+ pairs
47+ .iter()
48+ .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
49+ )
4750 }
4851
4952 #[test]
src/domain/actor.rs+50 −0View file
@@ -0,0 +1,50 @@
1+use super::UserId;
2+
3+/// Who is performing an operation.
4+///
5+/// Every use case takes one. Anonymous is a variant rather than an absent value, so a
6+/// caller cannot forget to model it — the previous attempt used a placeholder
7+/// `UserId("ssh-anonymous")` for this and it became a security hole.
8+#[derive(Debug, Clone, PartialEq, Eq)]
9+pub enum Actor {
10+ /// Nobody is signed in. Public content is still readable.
11+ Anonymous,
12+ /// An authenticated user.
13+ User(UserId),
14+}
15+
16+impl Actor {
17+ /// The acting user, or `None` when anonymous.
18+ pub fn user_id(&self) -> Option<&UserId> {
19+ match self {
20+ Self::Anonymous => None,
21+ Self::User(id) => Some(id),
22+ }
23+ }
24+
25+ pub fn is_authenticated(&self) -> bool {
26+ matches!(self, Self::User(_))
27+ }
28+}
29+
30+#[cfg(test)]
31+mod tests {
32+ use super::*;
33+
34+ #[test]
35+ fn anonymous_actors_carry_no_user() {
36+ let actor = Actor::Anonymous;
37+
38+ assert_eq!(actor.user_id(), None);
39+ assert!(!actor.is_authenticated());
40+ }
41+
42+ #[test]
43+ fn authenticated_actors_expose_their_user() {
44+ let id = UserId::generate();
45+ let actor = Actor::User(id.clone());
46+
47+ assert_eq!(actor.user_id(), Some(&id));
48+ assert!(actor.is_authenticated());
49+ }
50+}
src/domain/email.rs+108 −0View file
@@ -0,0 +1,108 @@
1+use std::fmt;
2+
3+use super::DomainError;
4+
5+/// A validated email address, normalised to lowercase.
6+///
7+/// Validation is deliberately shallow — a local part, an `@`, and a dotted domain.
8+/// Anything stricter rejects addresses that are legal in practice; the only real proof
9+/// an address works is sending to it.
10+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11+pub struct Email(String);
12+
13+impl Email {
14+ /// Validates and normalises user-supplied input.
15+ pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
16+ let value = value.into();
17+ let trimmed = value.trim();
18+
19+ let invalid = |reason: &str| DomainError::validation("email", reason);
20+
21+ let (local, domain) = trimmed
22+ .split_once('@')
23+ .ok_or_else(|| invalid("must contain '@'"))?;
24+
25+ if local.is_empty() {
26+ return Err(invalid("missing local part"));
27+ }
28+ if domain.is_empty() {
29+ return Err(invalid("missing domain"));
30+ }
31+ if domain.contains('@') {
32+ return Err(invalid("must contain exactly one '@'"));
33+ }
34+ if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
35+ return Err(invalid("domain must be dotted"));
36+ }
37+ if trimmed.contains(char::is_whitespace) {
38+ return Err(invalid("must not contain whitespace"));
39+ }
40+
41+ Ok(Self(trimmed.to_lowercase()))
42+ }
43+
44+ /// Wraps a value already validated on the way into the database.
45+ ///
46+ /// Re-validating stored rows means a change to the rules above makes old rows
47+ /// unreadable, so persistence adapters must use this.
48+ pub fn from_trusted(value: impl Into<String>) -> Self {
49+ Self(value.into())
50+ }
51+
52+ pub fn as_str(&self) -> &str {
53+ &self.0
54+ }
55+}
56+
57+impl fmt::Display for Email {
58+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59+ f.write_str(&self.0)
60+ }
61+}
62+
63+#[cfg(test)]
64+mod tests {
65+ use super::*;
66+
67+ #[test]
68+ fn accepts_an_ordinary_address() {
69+ let email = Email::new("dev@example.com").expect("should be valid");
70+
71+ assert_eq!(email.as_str(), "dev@example.com");
72+ }
73+
74+ #[test]
75+ fn normalises_case_and_surrounding_whitespace() {
76+ let email = Email::new(" Dev@Example.COM ").expect("should be valid");
77+
78+ assert_eq!(email.as_str(), "dev@example.com");
79+ }
80+
81+ #[test]
82+ fn rejects_malformed_addresses() {
83+ for input in [
84+ "",
85+ "no-at-sign",
86+ "@example.com",
87+ "dev@",
88+ "dev@localhost",
89+ "dev@@example.com",
90+ "dev@.com",
91+ "dev@example.",
92+ "two words@example.com",
93+ ] {
94+ assert!(
95+ Email::new(input).is_err(),
96+ "expected {input:?} to be rejected"
97+ );
98+ }
99+ }
100+
101+ #[test]
102+ fn trusted_values_skip_validation() {
103+ // A row written under older rules must still load.
104+ let email = Email::from_trusted("legacy@localhost");
105+
106+ assert_eq!(email.as_str(), "legacy@localhost");
107+ }
108+}
src/domain/error.rs+39 −0View file
@@ -0,0 +1,39 @@
1+use std::fmt;
2+
3+/// Every way a domain rule can be violated.
4+///
5+/// Deliberately coarse: a variant earns its place when a caller needs to react to it
6+/// differently, not merely to describe a failure more precisely.
7+#[derive(Debug, Clone, PartialEq, Eq)]
8+pub enum DomainError {
9+ /// A value failed validation. Carries the field and the reason.
10+ Validation { field: String, reason: String },
11+ /// The requested entity does not exist.
12+ NotFound { entity: &'static str },
13+ /// The entity already exists and the operation requires it not to.
14+ AlreadyExists { entity: &'static str },
15+ /// Credentials did not match. Deliberately says nothing about which part failed.
16+ InvalidCredentials,
17+}
18+
19+impl DomainError {
20+ pub fn validation(field: impl Into<String>, reason: impl Into<String>) -> Self {
21+ Self::Validation {
22+ field: field.into(),
23+ reason: reason.into(),
24+ }
25+ }
26+}
27+
28+impl fmt::Display for DomainError {
29+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30+ match self {
31+ Self::Validation { field, reason } => write!(f, "invalid {field}: {reason}"),
32+ Self::NotFound { entity } => write!(f, "{entity} not found"),
33+ Self::AlreadyExists { entity } => write!(f, "{entity} already exists"),
34+ Self::InvalidCredentials => f.write_str("invalid credentials"),
35+ }
36+ }
37+}
38+
39+impl std::error::Error for DomainError {}
src/domain/id.rs+73 −0View file
@@ -0,0 +1,73 @@
1+//! Typed identifiers.
2+//!
3+//! Never pass a raw `String` where an entity reference is meant: `fn get(org: &OrgId,
4+//! user: &UserId)` catches a swapped argument at compile time, `fn get(org: &str,
5+//! user: &str)` fails at runtime instead.
6+
7+use std::fmt;
8+
9+use uuid::Uuid;
10+
11+/// Declares a newtype over `String` used as an entity identifier.
12+macro_rules! typed_id {
13+ ($(#[$meta:meta])* $name:ident) => {
14+ $(#[$meta])*
15+ #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16+ pub struct $name(String);
17+
18+ impl $name {
19+ /// Generates a fresh random identifier.
20+ pub fn generate() -> Self {
21+ Self(Uuid::new_v4().to_string())
22+ }
23+
24+ /// Wraps an existing identifier, typically one loaded from the database.
25+ pub fn from_trusted(value: impl Into<String>) -> Self {
26+ Self(value.into())
27+ }
28+
29+ pub fn as_str(&self) -> &str {
30+ &self.0
31+ }
32+ }
33+
34+ impl fmt::Display for $name {
35+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36+ f.write_str(&self.0)
37+ }
38+ }
39+ };
40+}
41+
42+typed_id!(
43+ /// Identifies a [`User`](crate::domain::User).
44+ UserId
45+);
46+
47+typed_id!(
48+ /// Identifies an [`Organization`](crate::domain::Organization).
49+ OrgId
50+);
51+
52+typed_id!(
53+ /// Identifies a [`Membership`](crate::domain::Membership).
54+ MembershipId
55+);
56+
57+#[cfg(test)]
58+mod tests {
59+ use super::*;
60+
61+ #[test]
62+ fn generated_ids_are_unique() {
63+ assert_ne!(UserId::generate(), UserId::generate());
64+ }
65+
66+ #[test]
67+ fn round_trips_through_str() {
68+ let id = UserId::from_trusted("user-1");
69+
70+ assert_eq!(id.as_str(), "user-1");
71+ assert_eq!(id.to_string(), "user-1");
72+ }
73+}
src/domain/membership.rs+110 −0View file
@@ -0,0 +1,110 @@
1+use std::{fmt, str::FromStr};
2+
3+use super::{DomainError, MembershipId, OrgId, UserId};
4+
5+/// What a member may do within an organisation.
6+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
7+pub enum Role {
8+ /// Full control: write access, settings, membership.
9+ Owner,
10+ /// Read access to private content, no write access.
11+ Member,
12+}
13+
14+impl Role {
15+ pub fn as_str(self) -> &'static str {
16+ match self {
17+ Self::Owner => "owner",
18+ Self::Member => "member",
19+ }
20+ }
21+}
22+
23+impl fmt::Display for Role {
24+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25+ f.write_str(self.as_str())
26+ }
27+}
28+
29+/// Parsing returns `Result`, never `Option`.
30+///
31+/// A silently-defaulted role is a bug that surfaces days later as the wrong
32+/// permissions, so an unrecognised value must be impossible to ignore.
33+impl FromStr for Role {
34+ type Err = DomainError;
35+
36+ fn from_str(value: &str) -> Result<Self, Self::Err> {
37+ match value {
38+ "owner" => Ok(Self::Owner),
39+ "member" => Ok(Self::Member),
40+ other => Err(DomainError::validation(
41+ "role",
42+ format!("unknown role {other:?}"),
43+ )),
44+ }
45+ }
46+}
47+
48+/// Links a user to an organisation with a role.
49+#[derive(Debug, Clone, PartialEq, Eq)]
50+pub struct Membership {
51+ pub id: MembershipId,
52+ pub org_id: OrgId,
53+ pub user_id: UserId,
54+ pub role: Role,
55+}
56+
57+impl Membership {
58+ pub fn new(id: MembershipId, org_id: OrgId, user_id: UserId, role: Role) -> Self {
59+ Self {
60+ id,
61+ org_id,
62+ user_id,
63+ role,
64+ }
65+ }
66+
67+ /// Whether this membership permits writes to the organisation.
68+ pub fn can_write(&self) -> bool {
69+ self.role == Role::Owner
70+ }
71+}
72+
73+#[cfg(test)]
74+mod tests {
75+ use super::*;
76+
77+ #[test]
78+ fn roles_round_trip_through_strings() {
79+ for role in [Role::Owner, Role::Member] {
80+ assert_eq!(role.as_str().parse::<Role>().expect("round trip"), role);
81+ }
82+ }
83+
84+ #[test]
85+ fn unknown_roles_are_an_error_not_a_default() {
86+ let error = "admin".parse::<Role>().expect_err("should not parse");
87+
88+ assert!(matches!(error, DomainError::Validation { .. }));
89+ }
90+
91+ #[test]
92+ fn parsing_is_case_sensitive() {
93+ assert!("Owner".parse::<Role>().is_err());
94+ }
95+
96+ #[test]
97+ fn only_owners_may_write() {
98+ let membership = |role| {
99+ Membership::new(
100+ MembershipId::generate(),
101+ OrgId::generate(),
102+ UserId::generate(),
103+ role,
104+ )
105+ };
106+
107+ assert!(membership(Role::Owner).can_write());
108+ assert!(!membership(Role::Member).can_write());
109+ }
110+}
src/domain/mod.rs+22 −0View file
@@ -0,0 +1,22 @@
1+//! The domain layer: entities, value objects, and the errors they raise.
2+//!
3+//! Knows nothing about HTTP, SQL, git, or Topcoat. Nothing in here may import from
4+//! `application` or `infrastructure`.
5+
6+pub mod actor;
7+pub mod email;
8+pub mod error;
9+pub mod id;
10+pub mod membership;
11+pub mod org;
12+pub mod password;
13+pub mod user;
14+
15+pub use actor::Actor;
16+pub use email::Email;
17+pub use error::DomainError;
18+pub use id::{MembershipId, OrgId, UserId};
19+pub use membership::{Membership, Role};
20+pub use org::{OrgName, Organization};
21+pub use password::PasswordHash;
22+pub use user::User;
src/domain/org.rs+169 −0View file
@@ -0,0 +1,169 @@
1+use std::fmt;
2+
3+use super::{DomainError, OrgId};
4+
5+/// An organisation handle — the `{owner}` segment of every URL, and unique across the
6+/// installation.
7+///
8+/// Rules follow GitHub's: 1–39 characters, ASCII alphanumeric or hyphen, no leading or
9+/// trailing hyphen. Stored lowercase so lookups and URLs agree.
10+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11+pub struct OrgName(String);
12+
13+impl OrgName {
14+ pub const MAX_LEN: usize = 39;
15+
16+ /// Validates and normalises user-supplied input.
17+ pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
18+ let value = value.into();
19+ let trimmed = value.trim();
20+
21+ let invalid = |reason: &str| DomainError::validation("name", reason);
22+
23+ if trimmed.is_empty() {
24+ return Err(invalid("must not be empty"));
25+ }
26+ if trimmed.chars().count() > Self::MAX_LEN {
27+ return Err(invalid("must be at most 39 characters"));
28+ }
29+ if !trimmed
30+ .chars()
31+ .all(|c| c.is_ascii_alphanumeric() || c == '-')
32+ {
33+ return Err(invalid("may only contain letters, digits, and hyphens"));
34+ }
35+ if trimmed.starts_with('-') || trimmed.ends_with('-') {
36+ return Err(invalid("must not start or end with a hyphen"));
37+ }
38+
39+ Ok(Self(trimmed.to_lowercase()))
40+ }
41+
42+ /// Wraps a value already validated on the way into the database.
43+ pub fn from_trusted(value: impl Into<String>) -> Self {
44+ Self(value.into())
45+ }
46+
47+ pub fn as_str(&self) -> &str {
48+ &self.0
49+ }
50+}
51+
52+impl fmt::Display for OrgName {
53+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54+ f.write_str(&self.0)
55+ }
56+}
57+
58+/// An organisation. Every user gets a personal one at registration, and repositories
59+/// and content hang off organisations rather than users.
60+#[derive(Debug, Clone, PartialEq, Eq)]
61+pub struct Organization {
62+ pub id: OrgId,
63+ pub name: OrgName,
64+ /// Free-form label shown in the UI. Falls back to `name` when unset.
65+ pub display_name: Option<String>,
66+}
67+
68+impl Organization {
69+ /// Creates an organisation from user-supplied input.
70+ pub fn new(
71+ id: OrgId,
72+ name: impl Into<String>,
73+ display_name: Option<String>,
74+ ) -> Result<Self, DomainError> {
75+ Ok(Self {
76+ id,
77+ name: OrgName::new(name)?,
78+ display_name: display_name.filter(|value| !value.trim().is_empty()),
79+ })
80+ }
81+
82+ /// Reassembles an organisation from storage, skipping validation.
83+ pub fn from_trusted(id: OrgId, name: OrgName, display_name: Option<String>) -> Self {
84+ Self {
85+ id,
86+ name,
87+ display_name,
88+ }
89+ }
90+
91+ /// The label to show in the UI.
92+ pub fn label(&self) -> &str {
93+ self.display_name
94+ .as_deref()
95+ .unwrap_or_else(|| self.name.as_str())
96+ }
97+}
98+
99+#[cfg(test)]
100+mod tests {
101+ use super::*;
102+
103+ #[test]
104+ fn accepts_ordinary_handles() {
105+ for input in ["james", "steid", "a", "my-org", "org2026", "a-b-c"] {
106+ assert!(
107+ OrgName::new(input).is_ok(),
108+ "expected {input:?} to be accepted"
109+ );
110+ }
111+ }
112+
113+ #[test]
114+ fn normalises_case_and_whitespace() {
115+ let name = OrgName::new(" JamesGill ").expect("should be valid");
116+
117+ assert_eq!(name.as_str(), "jamesgill");
118+ }
119+
120+ #[test]
121+ fn rejects_malformed_handles() {
122+ for input in [
123+ "",
124+ " ",
125+ "-leading",
126+ "trailing-",
127+ "has space",
128+ "under_score",
129+ "dot.dot",
130+ "slash/slash",
131+ "emoji🦀",
132+ &"a".repeat(40),
133+ ] {
134+ assert!(
135+ OrgName::new(input).is_err(),
136+ "expected {input:?} to be rejected"
137+ );
138+ }
139+ }
140+
141+ #[test]
142+ fn accepts_a_handle_at_the_length_limit() {
143+ assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
144+ }
145+
146+ #[test]
147+ fn label_falls_back_to_the_handle() {
148+ let org = Organization::new(OrgId::generate(), "steid", None).expect("valid");
149+
150+ assert_eq!(org.label(), "steid");
151+ }
152+
153+ #[test]
154+ fn label_prefers_the_display_name() {
155+ let org =
156+ Organization::new(OrgId::generate(), "steid", Some("Steid".to_owned())).expect("valid");
157+
158+ assert_eq!(org.label(), "Steid");
159+ }
160+
161+ #[test]
162+ fn blank_display_names_are_treated_as_unset() {
163+ let org =
164+ Organization::new(OrgId::generate(), "steid", Some(" ".to_owned())).expect("valid");
165+
166+ assert_eq!(org.display_name, None);
167+ assert_eq!(org.label(), "steid");
168+ }
169+}
src/domain/password.rs+52 −0View file
@@ -0,0 +1,52 @@
1+use std::fmt;
2+
3+/// An opaque password hash.
4+///
5+/// `Debug` is implemented by hand to redact the digest: the whole point of this type
6+/// is that a hash can never reach a log line by accident. There is deliberately no
7+/// `Display`.
8+#[derive(Clone, PartialEq, Eq)]
9+pub struct PasswordHash(String);
10+
11+impl PasswordHash {
12+ /// Wraps a hash produced by a [`PasswordHasher`](crate::application::port::PasswordHasher).
13+ ///
14+ /// Takes a hash, never a plaintext password. Hashing belongs to the adapter.
15+ pub fn from_trusted(value: impl Into<String>) -> Self {
16+ Self(value.into())
17+ }
18+
19+ /// Exposes the encoded hash, for storage or verification only.
20+ pub fn as_str(&self) -> &str {
21+ &self.0
22+ }
23+}
24+
25+impl fmt::Debug for PasswordHash {
26+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27+ f.write_str("PasswordHash(redacted)")
28+ }
29+}
30+
31+#[cfg(test)]
32+mod tests {
33+ use super::*;
34+
35+ #[test]
36+ fn debug_output_redacts_the_hash() {
37+ let hash = PasswordHash::from_trusted("$argon2id$v=19$m=19456,t=2,p=1$abc$def");
38+
39+ let rendered = format!("{hash:?}");
40+
41+ assert_eq!(rendered, "PasswordHash(redacted)");
42+ assert!(!rendered.contains("argon2"));
43+ assert!(!rendered.contains("def"));
44+ }
45+
46+ #[test]
47+ fn the_hash_is_still_reachable_for_storage() {
48+ let hash = PasswordHash::from_trusted("$argon2id$stored");
49+
50+ assert_eq!(hash.as_str(), "$argon2id$stored");
51+ }
52+}
src/domain/user.rs+30 −0View file
@@ -0,0 +1,30 @@
1+use super::{Email, OrgId, PasswordHash, UserId};
2+
3+/// An account that can authenticate.
4+///
5+/// A user's identity in URLs comes from their personal organisation, not from a
6+/// separate username — see `personal_org_id`.
7+#[derive(Debug, Clone, PartialEq, Eq)]
8+pub struct User {
9+ pub id: UserId,
10+ pub email: Email,
11+ pub password_hash: PasswordHash,
12+ /// The organisation created alongside this user, whose name is their public handle.
13+ pub personal_org_id: OrgId,
14+}
15+
16+impl User {
17+ pub fn new(
18+ id: UserId,
19+ email: Email,
20+ password_hash: PasswordHash,
21+ personal_org_id: OrgId,
22+ ) -> Self {
23+ Self {
24+ id,
25+ email,
26+ password_hash,
27+ personal_org_id,
28+ }
29+ }
30+}
src/lib.rs+9 −0View file
@@ -0,0 +1,9 @@
1+//! Steid — a personal-first gitforge.
2+//!
3+//! Layered per `plans/architecture.md`: [`domain`] knows nothing about the outside
4+//! world, [`application`] holds use cases and the ports they need, and
5+//! [`infrastructure`] implements those ports and exposes the web surface.
6+
7+pub mod application;
8+pub mod domain;
9+pub mod infrastructure;
src/main.rs+1 −4View file
@@ -1,7 +1,4 @@
1mod application;
2mod infrastructure;
3
4use application::AppConfig;
1+use steid::{application::AppConfig, infrastructure};
52 use topcoat::router::{Router, RouterBuilderDiscoverExt};
63
74 #[tokio::main]