1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use backbone_core::http::{ApiResponse, BackboneCrudHandler};
16
17use backbone_auth::middleware::AuthContext;
19#[cfg(feature = "auth")]
20use backbone_auth::AuthMiddleware;
21
22use crate::domain::entity::*;
24use crate::application::service::{BucketService, ServiceError};
25
26use crate::presentation::dto::{CreateBucketDto, UpdateBucketDto, PatchBucketDto, BucketResponseDto};
28
29use crate::domain::state_machine::{BucketState, BucketStateMachine, BucketTransition};
30
31#[derive(Debug, thiserror::Error)]
33pub enum BucketError {
34 #[error("Not found: {0}")]
35 NotFound(String),
36 #[error("Validation error: {0}")]
37 Validation(String),
38 #[error("Database error: {0}")]
39 Database(String),
40 #[error("Internal error: {0}")]
41 Internal(String),
42 #[error("Bucket name must be 1-255 characters: {0}")]
44 InvalidNameLength(String),
45 #[error("Bucket name must contain only alphanumeric characters, underscores, and hyphens: {0}")]
46 InvalidNameChars(String),
47 #[error("Bucket slug must be 1-255 characters: {0}")]
48 InvalidSlugLength(String),
49 #[error("Bucket slug must be lowercase with hyphens only: {0}")]
50 InvalidSlugChars(String),
51 #[error("Root path too long: {0}")]
52 PathTooLong(String),
53 #[error("Path traversal not allowed in root path: {0}")]
54 PathTraversal(String),
55 #[error("Cannot modify deleted buckets: {0}")]
56 BucketDeleted(String),
57 #[error("Bucket slug is already taken: {0}")]
58 SlugExists(String),
59}
60
61impl From<ServiceError> for BucketError {
62 fn from(err: ServiceError) -> Self {
63 match err {
64 ServiceError::NotFound => Self::NotFound(err.to_string()),
65 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
66 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
67 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
68 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
69 ServiceError::Violations(_) => Self::Validation(err.to_string()),
70 }
71 }
72}
73
74impl axum::response::IntoResponse for BucketError {
75 fn into_response(self) -> axum::response::Response {
76 use axum::http::StatusCode;
77 use axum::Json;
78
79 let (status, code) = match &self {
80 Self::NotFound(_) => (StatusCode::NOT_FOUND, "BUCKET_NOT_FOUND"),
81 Self::Validation(_) => (StatusCode::BAD_REQUEST, "BUCKET_VALIDATION_ERROR"),
82 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "BUCKET_DATABASE_ERROR"),
83 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "BUCKET_INTERNAL_ERROR"),
84 Self::InvalidNameLength(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_NAME_LENGTH"),
85 Self::InvalidNameChars(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_NAME_CHARS"),
86 Self::InvalidSlugLength(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_SLUG_LENGTH"),
87 Self::InvalidSlugChars(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_SLUG_CHARS"),
88 Self::PathTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_PATH_TOO_LONG"),
89 Self::PathTraversal(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_PATH_TRAVERSAL"),
90 Self::BucketDeleted(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_BUCKET_DELETED"),
91 Self::SlugExists(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_SLUG_EXISTS"),
92 };
93
94 let body = serde_json::json!({
95 "success": false,
96 "error": code,
97 "message": self.to_string(),
98 });
99
100 (status, Json(body)).into_response()
101 }
102}
103
104pub mod bucket_errors {
106 pub const INVALID_NAME_LENGTH: &str = "BUCKET_INVALID_NAME_LENGTH";
107 pub const INVALID_NAME_CHARS: &str = "BUCKET_INVALID_NAME_CHARS";
108 pub const INVALID_SLUG_LENGTH: &str = "BUCKET_INVALID_SLUG_LENGTH";
109 pub const INVALID_SLUG_CHARS: &str = "BUCKET_INVALID_SLUG_CHARS";
110 pub const PATH_TOO_LONG: &str = "BUCKET_PATH_TOO_LONG";
111 pub const PATH_TRAVERSAL: &str = "BUCKET_PATH_TRAVERSAL";
112 pub const BUCKET_DELETED: &str = "BUCKET_BUCKET_DELETED";
113 pub const SLUG_EXISTS: &str = "BUCKET_SLUG_EXISTS";
114}
115
116pub fn create_bucket_routes(service: Arc<BucketService>) -> Router {
149 BackboneCrudHandler::<BucketService, Bucket, CreateBucketDto, UpdateBucketDto, BucketResponseDto>::routes(
150 service,
151 "/buckets",
152 )
153}
154
155pub fn create_bucket_read_routes(service: Arc<BucketService>) -> Router {
161 BackboneCrudHandler::<BucketService, Bucket, CreateBucketDto, UpdateBucketDto, BucketResponseDto>::read_routes(
162 service,
163 "/buckets",
164 )
165}
166
167pub fn create_bucket_write_routes(service: Arc<BucketService>) -> Router {
179 BackboneCrudHandler::<BucketService, Bucket, CreateBucketDto, UpdateBucketDto, BucketResponseDto>::write_routes(
180 service,
181 "/buckets",
182 )
183}
184
185#[cfg(feature = "auth")]
191pub fn create_protected_bucket_routes<A: AuthMiddleware + Send + Sync + 'static>(
192 service: Arc<BucketService>,
193 auth: Arc<A>,
194) -> Router {
195 use axum::middleware;
196 use axum::response::IntoResponse;
197
198 let auth_layer = auth.clone();
199 create_bucket_routes(service)
200 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
201 let auth = auth_layer.clone();
202 async move {
203 let token = req.headers()
204 .get(axum::http::header::AUTHORIZATION)
205 .and_then(|h| h.to_str().ok())
206 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
207 .unwrap_or("");
208 match auth.authenticate(token).await {
209 Ok(ctx) => {
210 req.extensions_mut().insert(ctx);
211 next.run(req).await
212 }
213 Err(_) => {
214 (axum::http::StatusCode::UNAUTHORIZED,
215 axum::Json(serde_json::json!({
216 "success": false,
217 "error": "unauthorized",
218 "message": "Authentication required"
219 }))
220 ).into_response()
221 }
222 }
223 }
224 }))
225}
226
227pub async fn lock_transition(
235 axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
236 axum::extract::Path(id): axum::extract::Path<String>,
237 auth: Option<axum::Extension<AuthContext>>,
238) -> impl axum::response::IntoResponse {
239 use axum::{http::StatusCode, Json};
240
241 let entity = match service.get_by_id(&id).await {
243 Ok(Some(e)) => e,
244 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
245 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
246 };
247
248 let Some(axum::Extension(auth)) = auth else {
250 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
251 };
252 {
253 let allowed_roles = BucketTransition::Lock.allowed_roles();
254 let granted = |required: &str| auth.permissions.iter().any(|p| {
255 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
256 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
257 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
258 });
259 let has_specific_perm = granted("bucket:transition:lock");
260 let has_update_perm = granted("bucket:update");
261 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
262 if !has_specific_perm && !has_role {
263 if !has_update_perm {
264 return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for lock transition")));
265 }
266 tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "lock", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:lock or one of the roles admin, owner");
267 }
268 }
269
270 let current_state: BucketState = match entity.status.to_string().parse() {
272 Ok(s) => s,
273 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
274 };
275 let sm = BucketStateMachine::from_state(current_state);
276 if !sm.can_transition(BucketTransition::Lock) {
277 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
278 }
279
280 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
283 fields.insert("status".to_string(), serde_json::Value::String("readonly".to_string()));
284
285 match backbone_core::write_guard::as_transition("lock", service.partial_update(&id, fields)).await {
286 Ok(Some(updated)) => {
287 let response: BucketResponseDto = updated.into();
288 (StatusCode::OK, Json(ApiResponse::ok(response)))
289 }
290 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
291 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
292 }
293}
294
295pub async fn unlock_transition(
299 axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
300 axum::extract::Path(id): axum::extract::Path<String>,
301 auth: Option<axum::Extension<AuthContext>>,
302) -> impl axum::response::IntoResponse {
303 use axum::{http::StatusCode, Json};
304
305 let entity = match service.get_by_id(&id).await {
307 Ok(Some(e)) => e,
308 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
309 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
310 };
311
312 let Some(axum::Extension(auth)) = auth else {
314 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
315 };
316 {
317 let allowed_roles = BucketTransition::Unlock.allowed_roles();
318 let granted = |required: &str| auth.permissions.iter().any(|p| {
319 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
320 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
321 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
322 });
323 let has_specific_perm = granted("bucket:transition:unlock");
324 let has_update_perm = granted("bucket:update");
325 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
326 if !has_specific_perm && !has_role {
327 if !has_update_perm {
328 return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for unlock transition")));
329 }
330 tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "unlock", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:unlock or one of the roles admin, owner");
331 }
332 }
333
334 let current_state: BucketState = match entity.status.to_string().parse() {
336 Ok(s) => s,
337 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
338 };
339 let sm = BucketStateMachine::from_state(current_state);
340 if !sm.can_transition(BucketTransition::Unlock) {
341 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
342 }
343
344 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
347 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
348
349 match backbone_core::write_guard::as_transition("unlock", service.partial_update(&id, fields)).await {
350 Ok(Some(updated)) => {
351 let response: BucketResponseDto = updated.into();
352 (StatusCode::OK, Json(ApiResponse::ok(response)))
353 }
354 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
355 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
356 }
357}
358
359pub async fn archive_transition(
363 axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
364 axum::extract::Path(id): axum::extract::Path<String>,
365 auth: Option<axum::Extension<AuthContext>>,
366) -> impl axum::response::IntoResponse {
367 use axum::{http::StatusCode, Json};
368
369 let entity = match service.get_by_id(&id).await {
371 Ok(Some(e)) => e,
372 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
373 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
374 };
375
376 let Some(axum::Extension(auth)) = auth else {
378 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
379 };
380 {
381 let allowed_roles = BucketTransition::Archive.allowed_roles();
382 let granted = |required: &str| auth.permissions.iter().any(|p| {
383 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
384 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
385 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
386 });
387 let has_specific_perm = granted("bucket:transition:archive");
388 let has_update_perm = granted("bucket:update");
389 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
390 if !has_specific_perm && !has_role {
391 if !has_update_perm {
392 return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for archive transition")));
393 }
394 tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "archive", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:archive or one of the roles admin, owner");
395 }
396 }
397
398 let current_state: BucketState = match entity.status.to_string().parse() {
400 Ok(s) => s,
401 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
402 };
403 let sm = BucketStateMachine::from_state(current_state);
404 if !sm.can_transition(BucketTransition::Archive) {
405 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
406 }
407
408 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
411 fields.insert("status".to_string(), serde_json::Value::String("archived".to_string()));
412
413 match backbone_core::write_guard::as_transition("archive", service.partial_update(&id, fields)).await {
414 Ok(Some(updated)) => {
415 let response: BucketResponseDto = updated.into();
416 (StatusCode::OK, Json(ApiResponse::ok(response)))
417 }
418 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
419 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
420 }
421}
422
423pub async fn restore_transition(
427 axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
428 axum::extract::Path(id): axum::extract::Path<String>,
429 auth: Option<axum::Extension<AuthContext>>,
430) -> impl axum::response::IntoResponse {
431 use axum::{http::StatusCode, Json};
432
433 let entity = match service.get_by_id(&id).await {
435 Ok(Some(e)) => e,
436 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
437 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
438 };
439
440 let Some(axum::Extension(auth)) = auth else {
442 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
443 };
444 {
445 let allowed_roles = BucketTransition::Restore.allowed_roles();
446 let granted = |required: &str| auth.permissions.iter().any(|p| {
447 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
448 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
449 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
450 });
451 let has_specific_perm = granted("bucket:transition:restore");
452 let has_update_perm = granted("bucket:update");
453 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
454 if !has_specific_perm && !has_role {
455 if !has_update_perm {
456 return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for restore transition")));
457 }
458 tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "restore", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:restore or one of the roles admin, owner");
459 }
460 }
461
462 let current_state: BucketState = match entity.status.to_string().parse() {
464 Ok(s) => s,
465 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
466 };
467 let sm = BucketStateMachine::from_state(current_state);
468 if !sm.can_transition(BucketTransition::Restore) {
469 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
470 }
471
472 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
475 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
476
477 match backbone_core::write_guard::as_transition("restore", service.partial_update(&id, fields)).await {
478 Ok(Some(updated)) => {
479 let response: BucketResponseDto = updated.into();
480 (StatusCode::OK, Json(ApiResponse::ok(response)))
481 }
482 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
483 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
484 }
485}
486
487pub async fn delete_transition(
491 axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
492 axum::extract::Path(id): axum::extract::Path<String>,
493 auth: Option<axum::Extension<AuthContext>>,
494) -> impl axum::response::IntoResponse {
495 use axum::{http::StatusCode, Json};
496
497 let entity = match service.get_by_id(&id).await {
499 Ok(Some(e)) => e,
500 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
501 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
502 };
503
504 let Some(axum::Extension(auth)) = auth else {
506 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
507 };
508 {
509 let allowed_roles = BucketTransition::Delete.allowed_roles();
510 let granted = |required: &str| auth.permissions.iter().any(|p| {
511 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
512 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
513 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
514 });
515 let has_specific_perm = granted("bucket:transition:delete");
516 let has_update_perm = granted("bucket:update");
517 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
518 if !has_specific_perm && !has_role {
519 if !has_update_perm {
520 return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for delete transition")));
521 }
522 tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "delete", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:delete or one of the roles admin, owner");
523 }
524 }
525
526 let current_state: BucketState = match entity.status.to_string().parse() {
528 Ok(s) => s,
529 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
530 };
531 let sm = BucketStateMachine::from_state(current_state);
532 if !sm.can_transition(BucketTransition::Delete) {
533 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
534 }
535
536 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
539 fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
540
541 match backbone_core::write_guard::as_transition("delete", service.partial_update(&id, fields)).await {
542 Ok(Some(updated)) => {
543 let response: BucketResponseDto = updated.into();
544 (StatusCode::OK, Json(ApiResponse::ok(response)))
545 }
546 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
547 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
548 }
549}
550
551pub fn create_bucket_transition_routes(service: Arc<BucketService>) -> Router {
553 use axum::routing::post;
554
555 Router::new()
556 .route("/buckets/:id/transitions/lock", post(lock_transition))
557 .route("/buckets/:id/transitions/unlock", post(unlock_transition))
558 .route("/buckets/:id/transitions/archive", post(archive_transition))
559 .route("/buckets/:id/transitions/restore", post(restore_transition))
560 .route("/buckets/:id/transitions/delete", post(delete_transition))
561 .with_state(service)
562}
563