steid

@jamesgill /

8.3 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///
7148ad4feat: expand the reserved-handle list to twenty1mo
23/// The test for adding one: could this ever be a *top-level* route? Under grouping,
24/// almost nothing is — a health check is `/api/health`, sign-up is `/auth/register` —
25/// so words that would live under a prefix do not belong here. GitHub reserves 590+
26/// because its namespace is flat; copying that list would import the cost without the
27/// cause.
28///
29/// Paths containing characters a handle cannot hold need no entry: `/_topcoat/`,
30/// `/.well-known/`, `robots.txt`, `favicon.ico` are all excluded by the rules above.
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
31const RESERVED: &[&str] = &[
7148ad4feat: expand the reserved-handle list to twenty1mo
32 "about",
33 "admin",
34 "api",
35 "assets",
36 "auth",
37 "dashboard",
38 "docs",
39 "explore",
40 "help",
41 "legal",
42 "notifications",
43 "privacy",
44 "search",
45 "security",
46 "settings",
47 "static",
48 "status",
49 // The project's own name, so nobody can hold it and impersonate the software.
50 "steid",
51 "support",
52 "terms",
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
53];
54
1e1ae56feat: identity domain model1mo
55impl OrgName {
56 pub const MAX_LEN: usize = 39;
57
58 /// Validates and normalises user-supplied input.
59 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
60 let value = value.into();
61 let trimmed = value.trim();
62
63 let invalid = |reason: &str| DomainError::validation("name", reason);
64
65 if trimmed.is_empty() {
66 return Err(invalid("must not be empty"));
67 }
68 if trimmed.chars().count() > Self::MAX_LEN {
69 return Err(invalid("must be at most 39 characters"));
70 }
71 if !trimmed
72 .chars()
73 .all(|c| c.is_ascii_alphanumeric() || c == '-')
74 {
75 return Err(invalid("may only contain letters, digits, and hyphens"));
76 }
77 if trimmed.starts_with('-') || trimmed.ends_with('-') {
78 return Err(invalid("must not start or end with a hyphen"));
79 }
80
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
81 let normalised = trimmed.to_lowercase();
82
83 // Checked against the normalised form: `API` and `api` are the same handle.
84 if RESERVED.contains(&normalised.as_str()) {
85 return Err(invalid("is reserved"));
86 }
87
88 Ok(Self(normalised))
1e1ae56feat: identity domain model1mo
89 }
90
91 /// Wraps a value already validated on the way into the database.
92 pub fn from_trusted(value: impl Into<String>) -> Self {
93 Self(value.into())
94 }
95
96 pub fn as_str(&self) -> &str {
97 &self.0
98 }
99}
100
101impl fmt::Display for OrgName {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(&self.0)
104 }
105}
106
107/// An organisation. Every user gets a personal one at registration, and repositories
108/// and content hang off organisations rather than users.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Organization {
111 pub id: OrgId,
112 pub name: OrgName,
113 /// Free-form label shown in the UI. Falls back to `name` when unset.
114 pub display_name: Option<String>,
115}
116
117impl Organization {
118 /// Creates an organisation from user-supplied input.
119 pub fn new(
120 id: OrgId,
121 name: impl Into<String>,
122 display_name: Option<String>,
123 ) -> Result<Self, DomainError> {
124 Ok(Self {
125 id,
126 name: OrgName::new(name)?,
127 display_name: display_name.filter(|value| !value.trim().is_empty()),
128 })
129 }
130
131 /// Reassembles an organisation from storage, skipping validation.
132 pub fn from_trusted(id: OrgId, name: OrgName, display_name: Option<String>) -> Self {
133 Self {
134 id,
135 name,
136 display_name,
137 }
138 }
139
140 /// The label to show in the UI.
141 pub fn label(&self) -> &str {
142 self.display_name
143 .as_deref()
144 .unwrap_or_else(|| self.name.as_str())
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn accepts_ordinary_handles() {
7148ad4feat: expand the reserved-handle list to twenty1mo
154 for input in ["james", "acme", "a", "my-org", "org2026", "a-b-c"] {
1e1ae56feat: identity domain model1mo
155 assert!(
156 OrgName::new(input).is_ok(),
157 "expected {input:?} to be accepted"
158 );
159 }
160 }
161
162 #[test]
163 fn normalises_case_and_whitespace() {
164 let name = OrgName::new(" JamesGill ").expect("should be valid");
165
166 assert_eq!(name.as_str(), "jamesgill");
167 }
168
169 #[test]
170 fn rejects_malformed_handles() {
171 for input in [
172 "",
173 " ",
174 "-leading",
175 "trailing-",
176 "has space",
177 "under_score",
178 "dot.dot",
179 "slash/slash",
180 "emoji🦀",
181 &"a".repeat(40),
182 ] {
183 assert!(
184 OrgName::new(input).is_err(),
185 "expected {input:?} to be rejected"
186 );
187 }
188 }
189
190 #[test]
191 fn accepts_a_handle_at_the_length_limit() {
192 assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
193 }
194
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
195 #[test]
196 fn rejects_reserved_handles() {
197 for reserved in RESERVED {
198 assert!(
199 OrgName::new(*reserved).is_err(),
200 "expected {reserved:?} to be reserved"
201 );
202 }
203 }
204
205 #[test]
206 fn reservation_ignores_case() {
207 // Handles normalise to lowercase, so a differently-cased reserved word is the
208 // same handle and must be refused too.
209 assert!(OrgName::new("API").is_err());
210 assert!(OrgName::new("Auth").is_err());
211 }
212
213 #[test]
214 fn reservation_does_not_leak_into_substrings() {
215 // Only whole handles are reserved. `apidocs` shadows nothing.
216 for allowed in [
217 "apidocs",
218 "authors",
219 "administrator",
220 "helpful",
221 "settings-app",
222 ] {
223 assert!(
224 OrgName::new(allowed).is_ok(),
225 "expected {allowed:?} to be allowed"
226 );
227 }
228 }
229
230 #[test]
231 fn the_reserved_list_is_sorted_and_unique() {
232 // Sorted so additions are easy to review; unique so a duplicate doesn't hide a
233 // typo'd entry that reserves nothing.
234 let mut sorted = RESERVED.to_vec();
235 sorted.sort_unstable();
236 sorted.dedup();
237
238 assert_eq!(
239 sorted.as_slice(),
240 RESERVED,
241 "keep RESERVED sorted and unique"
242 );
243 }
244
245 #[test]
246 fn reserved_entries_are_themselves_valid_handle_shapes() {
247 // A reserved word that could never be a handle anyway is dead weight and
248 // suggests a misunderstanding of what the list is for.
249 for reserved in RESERVED {
250 assert!(
251 !reserved.is_empty()
252 && reserved.len() <= OrgName::MAX_LEN
253 && reserved.chars().all(|c| c.is_ascii_lowercase())
254 && !reserved.starts_with('-')
255 && !reserved.ends_with('-'),
256 "{reserved:?} could never be a handle, so reserving it is pointless"
257 );
258 }
259 }
260
1e1ae56feat: identity domain model1mo
261 #[test]
262 fn label_falls_back_to_the_handle() {
7148ad4feat: expand the reserved-handle list to twenty1mo
263 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
1e1ae56feat: identity domain model1mo
264
7148ad4feat: expand the reserved-handle list to twenty1mo
265 assert_eq!(org.label(), "acme");
1e1ae56feat: identity domain model1mo
266 }
267
268 #[test]
269 fn label_prefers_the_display_name() {
270 let org =
7148ad4feat: expand the reserved-handle list to twenty1mo
271 Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned())).expect("valid");
1e1ae56feat: identity domain model1mo
272
7148ad4feat: expand the reserved-handle list to twenty1mo
273 assert_eq!(org.label(), "Acme");
1e1ae56feat: identity domain model1mo
274 }
275
276 #[test]
277 fn blank_display_names_are_treated_as_unset() {
278 let org =
7148ad4feat: expand the reserved-handle list to twenty1mo
279 Organization::new(OrgId::generate(), "acme", Some(" ".to_owned())).expect("valid");
1e1ae56feat: identity domain model1mo
280
281 assert_eq!(org.display_name, None);
7148ad4feat: expand the reserved-handle list to twenty1mo
282 assert_eq!(org.label(), "acme");
1e1ae56feat: identity domain model1mo
283 }
284}