steid

@jamesgill /

7.6 KBCode·Blame·Raw
1e1ae56feat: identity domain model1mo
1use std::fmt;
2
3use 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)]
11pub struct OrgName(String);
12
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
13/// Handles that would collide with an application route.
14///
15/// Handles live at the root (`/{handle}`), so anything here would shadow a real route
16/// or be shadowed by one. Kept deliberately lean: routes are grouped under functional
17/// prefixes (`/auth/login`, not `/login`), so this grows per *area*, not per route.
18///
19/// **Reserve generously and early.** Adding an entry later is a breaking change for
20/// whoever already holds that handle — their account has to be renamed and their links
21/// break. Entries here cost nothing while unclaimed.
22///
23/// Topcoat's own assets live under `/_topcoat/`, which the character rules already
24/// exclude, so it needs no entry.
25const RESERVED: &[&str] = &[
26 "about", "admin", "api", "assets", "auth", "explore", "help", "search", "settings", "static",
27];
28
1e1ae56feat: identity domain model1mo
29impl OrgName {
30 pub const MAX_LEN: usize = 39;
31
32 /// Validates and normalises user-supplied input.
33 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
34 let value = value.into();
35 let trimmed = value.trim();
36
37 let invalid = |reason: &str| DomainError::validation("name", reason);
38
39 if trimmed.is_empty() {
40 return Err(invalid("must not be empty"));
41 }
42 if trimmed.chars().count() > Self::MAX_LEN {
43 return Err(invalid("must be at most 39 characters"));
44 }
45 if !trimmed
46 .chars()
47 .all(|c| c.is_ascii_alphanumeric() || c == '-')
48 {
49 return Err(invalid("may only contain letters, digits, and hyphens"));
50 }
51 if trimmed.starts_with('-') || trimmed.ends_with('-') {
52 return Err(invalid("must not start or end with a hyphen"));
53 }
54
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
55 let normalised = trimmed.to_lowercase();
56
57 // Checked against the normalised form: `API` and `api` are the same handle.
58 if RESERVED.contains(&normalised.as_str()) {
59 return Err(invalid("is reserved"));
60 }
61
62 Ok(Self(normalised))
1e1ae56feat: identity domain model1mo
63 }
64
65 /// Wraps a value already validated on the way into the database.
66 pub fn from_trusted(value: impl Into<String>) -> Self {
67 Self(value.into())
68 }
69
70 pub fn as_str(&self) -> &str {
71 &self.0
72 }
73}
74
75impl fmt::Display for OrgName {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 f.write_str(&self.0)
78 }
79}
80
81/// An organisation. Every user gets a personal one at registration, and repositories
82/// and content hang off organisations rather than users.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Organization {
85 pub id: OrgId,
86 pub name: OrgName,
87 /// Free-form label shown in the UI. Falls back to `name` when unset.
88 pub display_name: Option<String>,
89}
90
91impl Organization {
92 /// Creates an organisation from user-supplied input.
93 pub fn new(
94 id: OrgId,
95 name: impl Into<String>,
96 display_name: Option<String>,
97 ) -> Result<Self, DomainError> {
98 Ok(Self {
99 id,
100 name: OrgName::new(name)?,
101 display_name: display_name.filter(|value| !value.trim().is_empty()),
102 })
103 }
104
105 /// Reassembles an organisation from storage, skipping validation.
106 pub fn from_trusted(id: OrgId, name: OrgName, display_name: Option<String>) -> Self {
107 Self {
108 id,
109 name,
110 display_name,
111 }
112 }
113
114 /// The label to show in the UI.
115 pub fn label(&self) -> &str {
116 self.display_name
117 .as_deref()
118 .unwrap_or_else(|| self.name.as_str())
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn accepts_ordinary_handles() {
128 for input in ["james", "steid", "a", "my-org", "org2026", "a-b-c"] {
129 assert!(
130 OrgName::new(input).is_ok(),
131 "expected {input:?} to be accepted"
132 );
133 }
134 }
135
136 #[test]
137 fn normalises_case_and_whitespace() {
138 let name = OrgName::new(" JamesGill ").expect("should be valid");
139
140 assert_eq!(name.as_str(), "jamesgill");
141 }
142
143 #[test]
144 fn rejects_malformed_handles() {
145 for input in [
146 "",
147 " ",
148 "-leading",
149 "trailing-",
150 "has space",
151 "under_score",
152 "dot.dot",
153 "slash/slash",
154 "emoji🦀",
155 &"a".repeat(40),
156 ] {
157 assert!(
158 OrgName::new(input).is_err(),
159 "expected {input:?} to be rejected"
160 );
161 }
162 }
163
164 #[test]
165 fn accepts_a_handle_at_the_length_limit() {
166 assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
167 }
168
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
169 #[test]
170 fn rejects_reserved_handles() {
171 for reserved in RESERVED {
172 assert!(
173 OrgName::new(*reserved).is_err(),
174 "expected {reserved:?} to be reserved"
175 );
176 }
177 }
178
179 #[test]
180 fn reservation_ignores_case() {
181 // Handles normalise to lowercase, so a differently-cased reserved word is the
182 // same handle and must be refused too.
183 assert!(OrgName::new("API").is_err());
184 assert!(OrgName::new("Auth").is_err());
185 }
186
187 #[test]
188 fn reservation_does_not_leak_into_substrings() {
189 // Only whole handles are reserved. `apidocs` shadows nothing.
190 for allowed in [
191 "apidocs",
192 "authors",
193 "administrator",
194 "helpful",
195 "settings-app",
196 ] {
197 assert!(
198 OrgName::new(allowed).is_ok(),
199 "expected {allowed:?} to be allowed"
200 );
201 }
202 }
203
204 #[test]
205 fn the_reserved_list_is_sorted_and_unique() {
206 // Sorted so additions are easy to review; unique so a duplicate doesn't hide a
207 // typo'd entry that reserves nothing.
208 let mut sorted = RESERVED.to_vec();
209 sorted.sort_unstable();
210 sorted.dedup();
211
212 assert_eq!(
213 sorted.as_slice(),
214 RESERVED,
215 "keep RESERVED sorted and unique"
216 );
217 }
218
219 #[test]
220 fn reserved_entries_are_themselves_valid_handle_shapes() {
221 // A reserved word that could never be a handle anyway is dead weight and
222 // suggests a misunderstanding of what the list is for.
223 for reserved in RESERVED {
224 assert!(
225 !reserved.is_empty()
226 && reserved.len() <= OrgName::MAX_LEN
227 && reserved.chars().all(|c| c.is_ascii_lowercase())
228 && !reserved.starts_with('-')
229 && !reserved.ends_with('-'),
230 "{reserved:?} could never be a handle, so reserving it is pointless"
231 );
232 }
233 }
234
1e1ae56feat: identity domain model1mo
235 #[test]
236 fn label_falls_back_to_the_handle() {
237 let org = Organization::new(OrgId::generate(), "steid", None).expect("valid");
238
239 assert_eq!(org.label(), "steid");
240 }
241
242 #[test]
243 fn label_prefers_the_display_name() {
244 let org =
245 Organization::new(OrgId::generate(), "steid", Some("Steid".to_owned())).expect("valid");
246
247 assert_eq!(org.label(), "Steid");
248 }
249
250 #[test]
251 fn blank_display_names_are_treated_as_unset() {
252 let org =
253 Organization::new(OrgId::generate(), "steid", Some(" ".to_owned())).expect("valid");
254
255 assert_eq!(org.display_name, None);
256 assert_eq!(org.label(), "steid");
257 }
258}