1use std::{collections::HashSet, sync::Arc};
2
3use axum::{
4 Json, Router,
5 extract::{Extension, Path, State},
6 http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
7 middleware,
8 response::{IntoResponse, Response},
9 routing::{get, put},
10};
11use kcode_k1_groups::{ALL_MODELS, GroupId, GroupRole, K1Groups, ModelId, TxId, UserId};
12use kcode_k1_http::Principal;
13use serde::Serialize;
14use tokio::task::spawn_blocking;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct LocalModel {
18 id: ModelId,
19 name: String,
20}
21
22impl LocalModel {
23 pub fn new(id: ModelId, name: String) -> Result<Self, String> {
24 validate_name(&name)?;
25 Ok(Self { id, name })
26 }
27
28 pub fn id(&self) -> ModelId {
29 self.id
30 }
31
32 pub fn name(&self) -> &str {
33 &self.name
34 }
35}
36
37#[derive(Clone)]
38struct AppState {
39 groups: Arc<K1Groups>,
40 models: Arc<[LocalModel]>,
41}
42
43pub fn authenticated_routes(
44 groups: Arc<K1Groups>,
45 models: Arc<[LocalModel]>,
46) -> Result<Router<()>, String> {
47 let mut ids = HashSet::with_capacity(models.len());
48 for model in models.iter() {
49 if !ids.insert(model.id()) {
50 return Err("duplicate local model id".to_owned());
51 }
52 }
53
54 Ok(Router::new()
55 .route("/people/models", get(catalog))
56 .route(
57 "/people/groups/{group_id}/models/{model_id}",
58 put(add).delete(remove),
59 )
60 .with_state(AppState { groups, models })
61 .layer(middleware::map_response(no_store)))
62}
63
64async fn catalog(
65 State(state): State<AppState>,
66 Extension(_principal): Extension<Principal>,
67) -> Json<CatalogDto> {
68 Json(catalog_body(&state.models))
69}
70
71async fn add(
72 State(state): State<AppState>,
73 Extension(principal): Extension<Principal>,
74 Path((group_id, model_id)): Path<(String, String)>,
75) -> Response {
76 let group = match parse_group(&group_id) {
77 Ok(group) => group,
78 Err(()) => return ApiError::invalid_group_id().into_response(),
79 };
80 let model = match parse_model(&model_id) {
81 Ok(model) => model,
82 Err(()) => return ApiError::invalid_model_id().into_response(),
83 };
84 if !catalog_contains(&state.models, model) {
85 return ApiError::model_not_found().into_response();
86 }
87
88 change(state, actor(&principal), group, model, true).await
89}
90
91async fn remove(
92 State(state): State<AppState>,
93 Extension(principal): Extension<Principal>,
94 Path((group_id, model_id)): Path<(String, String)>,
95) -> Response {
96 let group = match parse_group(&group_id) {
97 Ok(group) => group,
98 Err(()) => return ApiError::invalid_group_id().into_response(),
99 };
100 let model = match parse_model(&model_id) {
101 Ok(model) => model,
102 Err(()) => return ApiError::invalid_model_id().into_response(),
103 };
104
105 change(state, actor(&principal), group, model, false).await
106}
107
108async fn change(
109 state: AppState,
110 actor: UserId,
111 group: GroupId,
112 model: ModelId,
113 present: bool,
114) -> Response {
115 let fetched = match blocking_get(state.groups.clone(), group).await {
116 Ok(Some(group)) => group,
117 Ok(None) => return ApiError::group_not_found().into_response(),
118 Err(message) => {
119 return ApiError::groups_unavailable("group lookup", message).into_response();
120 }
121 };
122
123 let is_owner = fetched
124 .users()
125 .iter()
126 .any(|user| user.user_id() == actor && user.role() == GroupRole::Owner);
127 if !is_owner {
128 return ApiError::forbidden().into_response();
129 }
130
131 match blocking_set(state.groups, actor, group, model, present).await {
132 Ok(revision) => Json(MutationDto {
133 group_id: group_hex(revision.group_id()),
134 revision: tx_hex(revision.txid()),
135 })
136 .into_response(),
137 Err(message) => ApiError::groups_unavailable("group mutation", message).into_response(),
138 }
139}
140
141async fn blocking_get(
142 groups: Arc<K1Groups>,
143 group: GroupId,
144) -> Result<Option<kcode_k1_groups::Group>, String> {
145 spawn_blocking(move || groups.get(group))
146 .await
147 .map_err(|_| "blocking task failed".to_owned())?
148}
149
150async fn blocking_set(
151 groups: Arc<K1Groups>,
152 actor: UserId,
153 group: GroupId,
154 model: ModelId,
155 present: bool,
156) -> Result<kcode_k1_groups::GroupRevision, String> {
157 spawn_blocking(move || groups.set_model_membership(actor, group, model, present))
158 .await
159 .map_err(|_| "blocking task failed".to_owned())?
160}
161
162#[derive(Serialize)]
163struct CatalogDto {
164 all_models: AllModelsDto,
165 models: Vec<ModelDto>,
166}
167
168#[derive(Serialize)]
169struct AllModelsDto {
170 group_id: String,
171 name: &'static str,
172}
173
174#[derive(Serialize)]
175struct ModelDto {
176 model_id: String,
177 name: String,
178}
179
180#[derive(Serialize)]
181struct MutationDto {
182 group_id: String,
183 revision: String,
184}
185
186fn catalog_body(models: &[LocalModel]) -> CatalogDto {
187 CatalogDto {
188 all_models: AllModelsDto {
189 group_id: group_hex(ALL_MODELS),
190 name: "All models",
191 },
192 models: models.iter().map(ModelDto::from).collect(),
193 }
194}
195
196impl From<&LocalModel> for ModelDto {
197 fn from(model: &LocalModel) -> Self {
198 Self {
199 model_id: model_hex(model.id()),
200 name: model.name().to_owned(),
201 }
202 }
203}
204
205fn catalog_contains(models: &[LocalModel], model: ModelId) -> bool {
206 models.iter().any(|known| known.id() == model)
207}
208
209fn actor(principal: &Principal) -> UserId {
210 UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
211}
212
213fn parse_group(value: &str) -> Result<GroupId, ()> {
214 bytes(value, 12).map(|bytes| GroupId::new(TxId::from_bytes(bytes.try_into().unwrap())))
215}
216
217fn parse_model(value: &str) -> Result<ModelId, ()> {
218 bytes(value, 32).map(|bytes| ModelId::from_bytes(bytes.try_into().unwrap()))
219}
220
221fn bytes(value: &str, expected: usize) -> Result<Vec<u8>, ()> {
222 if value.len() != expected * 2
223 || !value
224 .bytes()
225 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
226 {
227 return Err(());
228 }
229
230 (0..expected)
231 .map(|index| u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| ()))
232 .collect()
233}
234
235fn group_hex(value: GroupId) -> String {
236 tx_hex(value.txid())
237}
238
239fn model_hex(value: ModelId) -> String {
240 hex(value.as_bytes())
241}
242
243fn tx_hex(value: TxId) -> String {
244 hex(value.as_bytes())
245}
246
247fn hex(bytes: &[u8]) -> String {
248 const HEX: &[u8; 16] = b"0123456789abcdef";
249 let mut output = String::with_capacity(bytes.len() * 2);
250
251 for byte in bytes {
252 output.push(HEX[(byte >> 4) as usize] as char);
253 output.push(HEX[(byte & 15) as usize] as char);
254 }
255
256 output
257}
258
259fn validate_name(name: &str) -> Result<(), String> {
260 if !(1..=128).contains(&name.len()) {
261 return Err("model name must be 1 through 128 UTF-8 bytes".to_owned());
262 }
263 if name.chars().any(char::is_control) {
264 return Err("model name must not contain control characters".to_owned());
265 }
266 if !name.chars().any(|character| !character.is_whitespace()) {
267 return Err("model name must contain a non-whitespace character".to_owned());
268 }
269
270 Ok(())
271}
272
273struct ApiError {
274 status: StatusCode,
275 error: &'static str,
276 message: String,
277}
278
279impl ApiError {
280 fn invalid_group_id() -> Self {
281 Self::new(
282 StatusCode::BAD_REQUEST,
283 "invalid_group_id",
284 "people_models: request validation: invalid group id",
285 )
286 }
287
288 fn invalid_model_id() -> Self {
289 Self::new(
290 StatusCode::BAD_REQUEST,
291 "invalid_model_id",
292 "people_models: request validation: invalid model id",
293 )
294 }
295
296 fn group_not_found() -> Self {
297 Self::new(
298 StatusCode::NOT_FOUND,
299 "group_not_found",
300 "people_models: group lookup: group not found",
301 )
302 }
303
304 fn model_not_found() -> Self {
305 Self::new(
306 StatusCode::NOT_FOUND,
307 "model_not_found",
308 "people_models: catalog lookup: model not found",
309 )
310 }
311
312 fn forbidden() -> Self {
313 Self::new(
314 StatusCode::FORBIDDEN,
315 "forbidden",
316 "people_models: authorization: owner role required",
317 )
318 }
319
320 fn groups_unavailable(phase: &str, dependency: String) -> Self {
321 Self::new(
322 StatusCode::SERVICE_UNAVAILABLE,
323 "groups_unavailable",
324 format!("people_models: {phase}: {dependency}"),
325 )
326 }
327
328 fn new(status: StatusCode, error: &'static str, message: impl Into<String>) -> Self {
329 Self {
330 status,
331 error,
332 message: message.into(),
333 }
334 }
335}
336
337impl IntoResponse for ApiError {
338 fn into_response(self) -> Response {
339 (
340 self.status,
341 Json(ErrorDto {
342 error: self.error,
343 message: self.message,
344 }),
345 )
346 .into_response()
347 }
348}
349
350#[derive(Serialize)]
351struct ErrorDto {
352 error: &'static str,
353 message: String,
354}
355
356async fn no_store(mut response: Response) -> Response {
357 response
358 .headers_mut()
359 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
360 response
361}
362
363#[cfg(test)]
364mod tests {
365 use std::{path::Path, sync::Arc};
366
367 use axum::body::to_bytes;
368 use kcode_k1_groups::GroupName;
369 use kcode_k1_peering::K1Peering;
370 use kcode_k1_txn_ordering::K1TxnOrdering;
371 use serde_json::json;
372
373 use super::*;
374
375 fn user(value: u8) -> UserId {
376 UserId::from_tx_id(TxId::from_bytes([value; 12]))
377 }
378
379 fn name(value: &str) -> GroupName {
380 GroupName::new(value.to_owned()).unwrap()
381 }
382
383 fn groups(root: &Path) -> (Arc<K1TxnOrdering>, Arc<K1Peering>, Arc<K1Groups>) {
384 let ordering = Arc::new(K1TxnOrdering::open(&root.join("ordering")).unwrap());
385 let peering = Arc::new(K1Peering::open(&root.join("peering"), ordering.clone()).unwrap());
386 let groups = Arc::new(
387 K1Groups::open(&root.join("groups"), ordering.clone(), peering.clone()).unwrap(),
388 );
389 (ordering, peering, groups)
390 }
391
392 #[test]
393 fn catalog_dto_preserves_order_and_all_models() {
394 let first = LocalModel::new(ModelId::from_bytes([0x11; 32]), "First".to_owned()).unwrap();
395 let second = LocalModel::new(ModelId::from_bytes([0x22; 32]), "Second".to_owned()).unwrap();
396
397 assert_eq!(
398 serde_json::to_value(catalog_body(&[first, second])).unwrap(),
399 json!({
400 "all_models": {
401 "group_id": "ff4b31475250000000000002",
402 "name": "All models",
403 },
404 "models": [
405 {"model_id": "11".repeat(32), "name": "First"},
406 {"model_id": "22".repeat(32), "name": "Second"},
407 ],
408 })
409 );
410 assert_eq!(group_hex(ALL_MODELS), "ff4b31475250000000000002");
411 }
412
413 #[test]
414 fn names_ids_and_duplicate_catalogs_are_rejected() {
415 let model = LocalModel::new(ModelId::from_bytes([0xab; 32]), " Name ".to_owned()).unwrap();
416 assert_eq!(model.id(), ModelId::from_bytes([0xab; 32]));
417 assert_eq!(model.name(), " Name ");
418 assert!(LocalModel::new(model.id(), " \n".to_owned()).is_err());
419 assert!(LocalModel::new(model.id(), "\0valid".to_owned()).is_err());
420 assert!(parse_group(&"01".repeat(12)).is_ok());
421 assert!(parse_group(&"AB".repeat(12)).is_err());
422
423 let root = tempfile::tempdir().unwrap();
424 let (_ordering, _peering, groups) = groups(root.path());
425 assert!(authenticated_routes(groups, Arc::from(vec![model.clone(), model])).is_err());
426 }
427
428 #[tokio::test]
429 async fn errors_have_exact_error_body_keys_and_chain() {
430 let response = ApiError::invalid_model_id().into_response();
431 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
432
433 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
434 assert_eq!(
435 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
436 json!({
437 "error": "invalid_model_id",
438 "message": "people_models: request validation: invalid model id",
439 })
440 );
441 }
442
443 #[test]
444 fn put_rejects_unknown_catalog_models() {
445 let catalogued = LocalModel::new(ModelId::from_bytes([1; 32]), "Known".to_owned()).unwrap();
446 assert!(catalog_contains(
447 &[catalogued],
448 ModelId::from_bytes([1; 32])
449 ));
450 assert!(!catalog_contains(&[], ModelId::from_bytes([2; 32])));
451
452 let error = ApiError::model_not_found();
453 assert_eq!(error.status, StatusCode::NOT_FOUND);
454 assert_eq!(error.error, "model_not_found");
455 }
456
457 #[tokio::test]
458 async fn owner_can_mutate_and_non_owner_cannot_delete_unregistered_model() {
459 let root = tempfile::tempdir().unwrap();
460 let (_ordering, _peering, groups) = groups(root.path());
461 let group = groups.create(user(1), name("owners")).unwrap().group_id();
462 let unregistered = ModelId::from_bytes([7; 32]);
463 let state = AppState {
464 groups: groups.clone(),
465 models: Arc::from([]),
466 };
467
468 assert_eq!(
469 change(state.clone(), user(1), group, unregistered, true)
470 .await
471 .status(),
472 StatusCode::OK
473 );
474 assert_eq!(
475 change(state.clone(), user(1), group, unregistered, false)
476 .await
477 .status(),
478 StatusCode::OK
479 );
480 assert_eq!(
481 change(state, user(2), group, unregistered, false)
482 .await
483 .status(),
484 StatusCode::FORBIDDEN
485 );
486 }
487}