1use sqlx::PgPool;
15use uuid::Uuid;
16
17use crate::{
18 auth::{ADMIN_TOKEN_PREFIX, generate_admin_token, generate_token, hash_token, token_prefix},
19 error::{BusError, BusResult},
20};
21
22#[derive(Clone, Copy, Debug)]
24pub enum Actor {
25 Cli,
27 Admin(Uuid),
29}
30
31impl Actor {
32 fn columns(self) -> (&'static str, Option<Uuid>) {
33 match self {
34 Actor::Cli => ("cli", None),
35 Actor::Admin(id) => ("http", Some(id)),
36 }
37 }
38}
39
40#[derive(Clone, Debug)]
43pub struct AdminCtx {
44 pub id: Uuid,
45 pub team_id: Option<Uuid>,
47 pub team_slug: Option<String>,
48}
49
50impl AdminCtx {
51 pub fn is_global(&self) -> bool {
52 self.team_id.is_none()
53 }
54}
55
56#[derive(Clone, Debug, serde::Serialize)]
57pub struct TeamRow {
58 pub id: Uuid,
59 pub slug: String,
60 pub name: String,
61 pub agents: i64,
62}
63
64#[derive(Clone, Debug, serde::Serialize)]
65pub struct AgentRow {
66 pub id: Uuid,
67 pub name: String,
68 pub display_name: Option<String>,
69 pub disabled: bool,
70 pub active_tokens: i64,
71}
72
73#[derive(Clone, Debug, serde::Serialize)]
76pub struct IssuedToken {
77 pub id: Uuid,
78 pub token: String,
79 pub prefix: String,
80 pub agent: String,
81 pub team: String,
82 pub label: Option<String>,
83}
84
85#[derive(Clone, Debug, serde::Serialize)]
86pub struct TokenRow {
87 pub id: Uuid,
88 pub agent: String,
89 pub prefix: String,
90 pub label: Option<String>,
91 pub created_at: chrono::DateTime<chrono::Utc>,
92 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
93 pub revoked: bool,
94}
95
96#[derive(Clone, Debug, serde::Serialize)]
98pub struct IssuedAdmin {
99 pub id: Uuid,
100 pub token: String,
101 pub prefix: String,
102 pub team: Option<String>,
104 pub label: Option<String>,
105}
106
107#[derive(Clone, Debug, serde::Serialize)]
108pub struct AdminRow {
109 pub id: Uuid,
110 pub team: Option<String>,
112 pub prefix: String,
113 pub label: Option<String>,
114 pub created_at: chrono::DateTime<chrono::Utc>,
115 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
116 pub revoked: bool,
117}
118
119pub const MAX_ACTIVE_TOKENS_PER_AGENT: i64 = 100;
124
125pub const MAX_NAME_BYTES: usize = 64;
128pub const MAX_LABEL_BYTES: usize = 128;
129
130fn check_name(field: &str, raw: &str) -> BusResult<String> {
131 let value = raw.trim().to_lowercase();
132 if value.is_empty() {
133 return Err(BusError::invalid(format!("{field} cannot be empty")));
134 }
135 if value.len() > MAX_NAME_BYTES {
136 return Err(BusError::invalid(format!(
137 "{field} is {} bytes; the limit is {MAX_NAME_BYTES}",
138 value.len()
139 )));
140 }
141 if !value
142 .chars()
143 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
144 {
145 return Err(BusError::invalid(format!(
146 "{field} may only contain ASCII letters, digits, '-', '_' and '.'"
147 )));
148 }
149 Ok(value)
150}
151
152fn check_display(field: &str, value: Option<String>) -> BusResult<Option<String>> {
156 let Some(value) = value else {
157 return Ok(None);
158 };
159 let value = value.trim().to_owned();
160 if value.is_empty() {
161 return Ok(None);
162 }
163 if value.len() > MAX_LABEL_BYTES {
164 return Err(BusError::invalid(format!(
165 "{field} is {} bytes; the limit is {MAX_LABEL_BYTES}",
166 value.len()
167 )));
168 }
169 if value.chars().any(char::is_control) {
170 return Err(BusError::invalid(format!(
171 "{field} must not contain control characters"
172 )));
173 }
174 Ok(Some(value))
175}
176
177fn check_label(label: Option<String>) -> BusResult<Option<String>> {
178 check_display("label", label)
179}
180
181async fn audit(
185 conn: &mut sqlx::PgConnection,
186 actor: Actor,
187 action: &str,
188 team_id: Option<Uuid>,
189 subject_id: Option<Uuid>,
190 detail: serde_json::Value,
191) -> BusResult<()> {
192 let (source, admin_id) = actor.columns();
193 sqlx::query(
194 "INSERT INTO admin_audit (actor_source, actor_admin_id, action, team_id, subject_id, detail)
195 VALUES ($1, $2, $3, $4, $5, $6)",
196 )
197 .bind(source)
198 .bind(admin_id)
199 .bind(action)
200 .bind(team_id)
201 .bind(subject_id)
202 .bind(detail)
203 .execute(conn)
204 .await?;
205 Ok(())
206}
207
208pub async fn team_id_by_slug(pool: &PgPool, slug: &str) -> BusResult<Uuid> {
211 let slug = slug.trim().to_lowercase();
212 let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
213 .bind(&slug)
214 .fetch_optional(pool)
215 .await?;
216 row.map(|r| r.0)
217 .ok_or_else(|| BusError::not_found(format!("no team with slug '{slug}'")))
218}
219
220pub async fn create_team(
222 pool: &PgPool,
223 actor: Actor,
224 slug: &str,
225 name: Option<String>,
226) -> BusResult<TeamRow> {
227 let slug = check_name("team slug", slug)?;
228 let name = check_display("team name", name)?.unwrap_or_else(|| slug.clone());
229 let mut tx = pool.begin().await?;
230 let created: Option<(Uuid,)> = sqlx::query_as(
231 "INSERT INTO teams (slug, name) VALUES ($1, $2)
232 ON CONFLICT (slug) DO NOTHING RETURNING id",
233 )
234 .bind(&slug)
235 .bind(&name)
236 .fetch_optional(&mut *tx)
237 .await?;
238 if let Some((id,)) = created {
239 audit(
240 &mut tx,
241 actor,
242 "team.create",
243 Some(id),
244 Some(id),
245 serde_json::json!({ "slug": slug, "name": name }),
246 )
247 .await?;
248 }
249 tx.commit().await?;
250 let id = team_id_by_slug(pool, &slug).await?;
251 team_by_id(pool, id).await
252}
253
254pub async fn team_by_id(pool: &PgPool, id: Uuid) -> BusResult<TeamRow> {
256 let row: Option<(String, String, i64)> = sqlx::query_as(
257 "SELECT t.slug, t.name, (SELECT count(*) FROM agents a WHERE a.team_id = t.id)
258 FROM teams t WHERE t.id = $1",
259 )
260 .bind(id)
261 .fetch_optional(pool)
262 .await?;
263 let Some((slug, name, agents)) = row else {
264 return Err(BusError::not_found("no such team"));
265 };
266 Ok(TeamRow {
267 id,
268 slug,
269 name,
270 agents,
271 })
272}
273
274pub async fn set_conversations(
277 pool: &PgPool,
278 actor: Actor,
279 team_id: Uuid,
280 enabled: bool,
281) -> BusResult<()> {
282 let mut tx = pool.begin().await?;
283 let changed: Option<(String,)> = sqlx::query_as(
284 "UPDATE teams SET conversations_enabled = $2
285 WHERE id = $1 AND conversations_enabled <> $2 RETURNING slug",
286 )
287 .bind(team_id)
288 .bind(enabled)
289 .fetch_optional(&mut *tx)
290 .await?;
291 if let Some((slug,)) = changed {
292 audit(
293 &mut tx,
294 actor,
295 "team.capability",
296 Some(team_id),
297 Some(team_id),
298 serde_json::json!({ "slug": slug, "conversations_enabled": enabled }),
299 )
300 .await?;
301 }
302 tx.commit().await?;
303 Ok(())
304}
305
306pub async fn set_default_backend(
314 pool: &PgPool,
315 actor: Actor,
316 team_id: Uuid,
317 backend: &str,
318) -> BusResult<()> {
319 if !matches!(backend, "postgres" | "jetstream") {
320 return Err(crate::error::BusError::invalid(
321 "backend must be 'postgres' or 'jetstream'",
322 ));
323 }
324 let (inflight,): (i64,) = sqlx::query_as(
325 "SELECT count(*) FROM conversation_outbox WHERE team_id = $1 AND state <> 'failed'",
326 )
327 .bind(team_id)
328 .fetch_one(pool)
329 .await?;
330 if inflight > 0 {
331 return Err(crate::error::BusError::conflict(format!(
332 "{inflight} message(s) of this team are still awaiting publication. Let them \
333 settle before changing the route; `team usage` shows when the queue is empty."
334 )));
335 }
336 let mut tx = pool.begin().await?;
337 let changed: Option<(String,)> = sqlx::query_as(
338 "UPDATE teams SET default_backend = $2
339 WHERE id = $1 AND default_backend <> $2 RETURNING slug",
340 )
341 .bind(team_id)
342 .bind(backend)
343 .fetch_optional(&mut *tx)
344 .await?;
345 if let Some((slug,)) = changed {
346 audit(
347 &mut tx,
348 actor,
349 "team.backend",
350 Some(team_id),
351 Some(team_id),
352 serde_json::json!({ "slug": slug, "default_backend": backend }),
353 )
354 .await?;
355 }
356 tx.commit().await?;
357 Ok(())
358}
359
360pub async fn list_teams(pool: &PgPool) -> BusResult<Vec<TeamRow>> {
361 let rows: Vec<(Uuid, String, String, i64)> = sqlx::query_as(
362 "SELECT t.id, t.slug, t.name, (SELECT count(*) FROM agents a WHERE a.team_id = t.id)
363 FROM teams t ORDER BY t.slug",
364 )
365 .fetch_all(pool)
366 .await?;
367 Ok(rows
368 .into_iter()
369 .map(|(id, slug, name, agents)| TeamRow {
370 id,
371 slug,
372 name,
373 agents,
374 })
375 .collect())
376}
377
378pub async fn create_agent(
386 pool: &PgPool,
387 actor: Actor,
388 team_id: Uuid,
389 name: &str,
390 display_name: Option<String>,
391) -> BusResult<AgentRow> {
392 let name = check_name("agent name", name)?;
393 let display_name = check_display("display name", display_name)?;
394 let mut tx = pool.begin().await?;
395 let existing: Option<(Uuid, bool)> = sqlx::query_as(
398 "SELECT id, (disabled_at IS NOT NULL) FROM agents
399 WHERE team_id = $1 AND name = $2 FOR UPDATE",
400 )
401 .bind(team_id)
402 .bind(&name)
403 .fetch_optional(&mut *tx)
404 .await?;
405 let id = match existing {
406 None => {
407 let (id,): (Uuid,) = sqlx::query_as(
408 "INSERT INTO agents (team_id, name, display_name) VALUES ($1, $2, $3)
409 RETURNING id",
410 )
411 .bind(team_id)
412 .bind(&name)
413 .bind(&display_name)
414 .fetch_one(&mut *tx)
415 .await?;
416 audit(
417 &mut tx,
418 actor,
419 "agent.create",
420 Some(team_id),
421 Some(id),
422 serde_json::json!({ "name": name, "display_name": display_name }),
423 )
424 .await?;
425 id
426 }
427 Some((id, disabled)) => {
428 sqlx::query(
429 "UPDATE agents SET display_name = COALESCE($2, display_name), disabled_at = NULL
430 WHERE id = $1",
431 )
432 .bind(id)
433 .bind(&display_name)
434 .execute(&mut *tx)
435 .await?;
436 if disabled {
437 audit(
438 &mut tx,
439 actor,
440 "agent.enable",
441 Some(team_id),
442 Some(id),
443 serde_json::json!({ "name": name }),
444 )
445 .await?;
446 }
447 id
448 }
449 };
450 tx.commit().await?;
451 let rows = list_agents(pool, team_id).await?;
452 rows.into_iter()
453 .find(|a| a.id == id)
454 .ok_or_else(|| BusError::not_found("agent vanished after creation"))
455}
456
457pub async fn list_agents(pool: &PgPool, team_id: Uuid) -> BusResult<Vec<AgentRow>> {
458 let rows: Vec<(Uuid, String, Option<String>, bool, i64)> = sqlx::query_as(
459 r#"
460 SELECT a.id, a.name, a.display_name,
461 (a.disabled_at IS NOT NULL) AS disabled,
462 (SELECT count(*) FROM api_tokens t
463 WHERE t.agent_id = a.id AND t.revoked_at IS NULL)
464 FROM agents a WHERE a.team_id = $1 ORDER BY a.name
465 "#,
466 )
467 .bind(team_id)
468 .fetch_all(pool)
469 .await?;
470 Ok(rows
471 .into_iter()
472 .map(
473 |(id, name, display_name, disabled, active_tokens)| AgentRow {
474 id,
475 name,
476 display_name,
477 disabled,
478 active_tokens,
479 },
480 )
481 .collect())
482}
483
484pub async fn disable_agent(
485 pool: &PgPool,
486 actor: Actor,
487 team_id: Uuid,
488 name: &str,
489) -> BusResult<()> {
490 let name = name.trim().to_lowercase();
491 let mut tx = pool.begin().await?;
492 let row: Option<(Uuid,)> = sqlx::query_as(
494 "UPDATE agents SET disabled_at = now()
495 WHERE team_id = $1 AND name = $2 AND disabled_at IS NULL RETURNING id",
496 )
497 .bind(team_id)
498 .bind(&name)
499 .fetch_optional(&mut *tx)
500 .await?;
501 match row {
502 Some((id,)) => {
503 audit(
504 &mut tx,
505 actor,
506 "agent.disable",
507 Some(team_id),
508 Some(id),
509 serde_json::json!({ "name": name }),
510 )
511 .await?;
512 }
513 None => {
514 let exists: Option<(Uuid,)> =
515 sqlx::query_as("SELECT id FROM agents WHERE team_id = $1 AND name = $2")
516 .bind(team_id)
517 .bind(&name)
518 .fetch_optional(&mut *tx)
519 .await?;
520 if exists.is_none() {
521 return Err(BusError::not_found(format!(
522 "no agent '{name}' in this team"
523 )));
524 }
525 }
526 }
527 tx.commit().await?;
528 Ok(())
529}
530
531pub async fn issue_token(
536 pool: &PgPool,
537 actor: Actor,
538 team_id: Uuid,
539 agent: &str,
540 label: Option<String>,
541) -> BusResult<IssuedToken> {
542 let agent = agent.trim().to_lowercase();
543 let label = check_label(label)?;
544 let mut tx = pool.begin().await?;
545 let row: Option<(Uuid, String, bool)> = sqlx::query_as(
549 "SELECT a.id, t.slug, (a.disabled_at IS NOT NULL)
550 FROM agents a JOIN teams t ON t.id = a.team_id
551 WHERE a.team_id = $1 AND a.name = $2 FOR UPDATE OF a",
552 )
553 .bind(team_id)
554 .bind(&agent)
555 .fetch_optional(&mut *tx)
556 .await?;
557 let Some((agent_id, team_slug, disabled)) = row else {
558 return Err(BusError::not_found(format!(
559 "no agent '{agent}' in this team — create it first"
560 )));
561 };
562 if disabled {
563 return Err(BusError::conflict(format!(
564 "agent '{agent}' is disabled; re-create it to enable it before issuing a token"
565 )));
566 }
567 let (active,): (i64,) = sqlx::query_as(
568 "SELECT count(*) FROM api_tokens WHERE agent_id = $1 AND revoked_at IS NULL",
569 )
570 .bind(agent_id)
571 .fetch_one(&mut *tx)
572 .await?;
573 if active >= MAX_ACTIVE_TOKENS_PER_AGENT {
574 return Err(BusError::conflict(format!(
575 "agent '{agent}' already has {active} active tokens; the limit is \
576 {MAX_ACTIVE_TOKENS_PER_AGENT}. Revoke the ones no longer in use first"
577 )));
578 }
579
580 let (_, issued_by) = actor.columns();
581 let raw = generate_token();
582 let prefix = token_prefix(&raw);
583 let (id,): (Uuid,) = sqlx::query_as(
584 "INSERT INTO api_tokens (agent_id, token_hash, prefix, label, issued_by_admin)
585 VALUES ($1, $2, $3, $4, $5) RETURNING id",
586 )
587 .bind(agent_id)
588 .bind(hash_token(&raw))
589 .bind(&prefix)
590 .bind(&label)
591 .bind(issued_by)
592 .fetch_one(&mut *tx)
593 .await?;
594 audit(
595 &mut tx,
596 actor,
597 "token.issue",
598 Some(team_id),
599 Some(id),
600 serde_json::json!({ "agent": agent, "label": label, "prefix": prefix }),
601 )
602 .await?;
603 tx.commit().await?;
604 Ok(IssuedToken {
605 id,
606 token: raw,
607 prefix,
608 agent,
609 team: team_slug,
610 label,
611 })
612}
613
614pub async fn list_tokens(pool: &PgPool, team_id: Uuid) -> BusResult<Vec<TokenRow>> {
615 let rows: Vec<(
616 Uuid,
617 String,
618 String,
619 Option<String>,
620 chrono::DateTime<chrono::Utc>,
621 Option<chrono::DateTime<chrono::Utc>>,
622 bool,
623 )> = sqlx::query_as(
624 r#"
625 SELECT t.id, a.name, t.prefix, t.label, t.created_at, t.last_used_at,
626 (t.revoked_at IS NOT NULL) AS revoked
627 FROM api_tokens t
628 JOIN agents a ON a.id = t.agent_id
629 WHERE a.team_id = $1
630 ORDER BY a.name, t.created_at
631 "#,
632 )
633 .bind(team_id)
634 .fetch_all(pool)
635 .await?;
636 Ok(rows
637 .into_iter()
638 .map(
639 |(id, agent, prefix, label, created_at, last_used_at, revoked)| TokenRow {
640 id,
641 agent,
642 prefix,
643 label,
644 created_at,
645 last_used_at,
646 revoked,
647 },
648 )
649 .collect())
650}
651
652pub async fn revoke_token(
658 pool: &PgPool,
659 actor: Actor,
660 team_id: Option<Uuid>,
661 id: Uuid,
662) -> BusResult<()> {
663 let mut tx = pool.begin().await?;
664 sqlx::query(
676 "SELECT a.id FROM agents a JOIN api_tokens t ON t.agent_id = a.id
677 WHERE t.id = $1 AND ($2::uuid IS NULL OR a.team_id = $2)
678 FOR NO KEY UPDATE OF a",
679 )
680 .bind(id)
681 .bind(team_id)
682 .execute(&mut *tx)
683 .await?;
684 let revoked: Option<(Uuid, String, String)> = sqlx::query_as(
685 "UPDATE api_tokens t SET revoked_at = now()
686 FROM agents a
687 WHERE t.id = $1 AND t.agent_id = a.id AND t.revoked_at IS NULL
688 AND ($2::uuid IS NULL OR a.team_id = $2)
689 RETURNING a.team_id, a.name, t.prefix",
690 )
691 .bind(id)
692 .bind(team_id)
693 .fetch_optional(&mut *tx)
694 .await?;
695 match revoked {
696 Some((owner_team, agent, prefix)) => {
697 let sessions: Vec<(String,)> = sqlx::query_as(
703 "UPDATE agent_sessions SET revoked_at = now()
704 WHERE parent_token = $1 AND revoked_at IS NULL
705 RETURNING label",
706 )
707 .bind(id)
708 .fetch_all(&mut *tx)
709 .await?;
710 audit(
711 &mut tx,
712 actor,
713 "token.revoke",
714 Some(owner_team),
715 Some(id),
716 serde_json::json!({
717 "agent": agent,
718 "prefix": prefix,
719 "sessions_revoked": sessions.iter().map(|s| &s.0).collect::<Vec<_>>(),
720 }),
721 )
722 .await?;
723 }
724 None => {
725 let exists: Option<(Uuid,)> = sqlx::query_as(
726 "SELECT t.id FROM api_tokens t JOIN agents a ON a.id = t.agent_id
727 WHERE t.id = $1 AND ($2::uuid IS NULL OR a.team_id = $2)",
728 )
729 .bind(id)
730 .bind(team_id)
731 .fetch_optional(&mut *tx)
732 .await?;
733 if exists.is_none() {
734 return Err(BusError::not_found(format!("no token with id {id}")));
735 }
736 }
737 }
738 tx.commit().await?;
739 Ok(())
740}
741
742pub async fn grant_admin(
748 pool: &PgPool,
749 actor: Actor,
750 team_id: Option<Uuid>,
751 label: Option<String>,
752) -> BusResult<IssuedAdmin> {
753 let label = check_label(label)?;
754 let team_slug = match team_id {
755 Some(tid) => {
756 let row: Option<(String,)> = sqlx::query_as("SELECT slug FROM teams WHERE id = $1")
757 .bind(tid)
758 .fetch_optional(pool)
759 .await?;
760 let Some((slug,)) = row else {
761 return Err(BusError::not_found("no such team"));
762 };
763 Some(slug)
764 }
765 None => None,
766 };
767 let (_, issued_by) = actor.columns();
768 let raw = generate_admin_token();
769 let prefix = token_prefix(&raw);
770 let mut tx = pool.begin().await?;
771 let (id,): (Uuid,) = sqlx::query_as(
772 "INSERT INTO admin_tokens (team_id, token_hash, prefix, label, issued_by)
773 VALUES ($1, $2, $3, $4, $5) RETURNING id",
774 )
775 .bind(team_id)
776 .bind(hash_token(&raw))
777 .bind(&prefix)
778 .bind(&label)
779 .bind(issued_by)
780 .fetch_one(&mut *tx)
781 .await?;
782 audit(
783 &mut tx,
784 actor,
785 "admin.grant",
786 team_id,
787 Some(id),
788 serde_json::json!({
789 "scope": if team_id.is_some() { "team" } else { "global" },
790 "label": label,
791 "prefix": prefix,
792 }),
793 )
794 .await?;
795 tx.commit().await?;
796 Ok(IssuedAdmin {
797 id,
798 token: raw,
799 prefix,
800 team: team_slug,
801 label,
802 })
803}
804
805pub async fn list_admins(pool: &PgPool, team_id: Option<Uuid>) -> BusResult<Vec<AdminRow>> {
808 let rows: Vec<(
809 Uuid,
810 Option<String>,
811 String,
812 Option<String>,
813 chrono::DateTime<chrono::Utc>,
814 Option<chrono::DateTime<chrono::Utc>>,
815 bool,
816 )> = sqlx::query_as(
817 r#"
818 SELECT c.id, t.slug, c.prefix, c.label, c.created_at, c.last_used_at,
819 (c.revoked_at IS NOT NULL) AS revoked
820 FROM admin_tokens c
821 LEFT JOIN teams t ON t.id = c.team_id
822 WHERE ($1::uuid IS NULL OR c.team_id = $1)
823 ORDER BY t.slug NULLS FIRST, c.created_at
824 "#,
825 )
826 .bind(team_id)
827 .fetch_all(pool)
828 .await?;
829 Ok(rows
830 .into_iter()
831 .map(
832 |(id, team, prefix, label, created_at, last_used_at, revoked)| AdminRow {
833 id,
834 team,
835 prefix,
836 label,
837 created_at,
838 last_used_at,
839 revoked,
840 },
841 )
842 .collect())
843}
844
845pub async fn revoke_admin(
849 pool: &PgPool,
850 actor: Actor,
851 scope: Option<Uuid>,
852 id: Uuid,
853) -> BusResult<()> {
854 let mut tx = pool.begin().await?;
855 let revoked: Option<(Option<Uuid>, String)> = sqlx::query_as(
856 "UPDATE admin_tokens SET revoked_at = now()
857 WHERE id = $1 AND revoked_at IS NULL AND ($2::uuid IS NULL OR team_id = $2)
858 RETURNING team_id, prefix",
859 )
860 .bind(id)
861 .bind(scope)
862 .fetch_optional(&mut *tx)
863 .await?;
864 match revoked {
865 Some((team_id, prefix)) => {
866 audit(
867 &mut tx,
868 actor,
869 "admin.revoke",
870 team_id,
871 Some(id),
872 serde_json::json!({ "prefix": prefix }),
873 )
874 .await?;
875 }
876 None => {
877 let exists: Option<(Uuid,)> = sqlx::query_as(
878 "SELECT id FROM admin_tokens
879 WHERE id = $1 AND ($2::uuid IS NULL OR team_id = $2)",
880 )
881 .bind(id)
882 .bind(scope)
883 .fetch_optional(&mut *tx)
884 .await?;
885 if exists.is_none() {
886 return Err(BusError::not_found(format!(
887 "no administrative credential with id {id}"
888 )));
889 }
890 }
891 }
892 tx.commit().await?;
893 Ok(())
894}
895
896pub async fn resolve_admin(pool: &PgPool, raw: &str) -> BusResult<Option<AdminCtx>> {
899 let raw = raw.trim();
900 if !raw.starts_with(ADMIN_TOKEN_PREFIX) {
901 return Ok(None);
902 }
903 let row: Option<(Uuid, Option<Uuid>, Option<String>)> = sqlx::query_as(
904 "SELECT c.id, c.team_id, t.slug
905 FROM admin_tokens c LEFT JOIN teams t ON t.id = c.team_id
906 WHERE c.token_hash = $1 AND c.revoked_at IS NULL",
907 )
908 .bind(hash_token(raw))
909 .fetch_optional(pool)
910 .await?;
911 let Some((id, team_id, team_slug)) = row else {
912 return Ok(None);
913 };
914 let _ = sqlx::query("UPDATE admin_tokens SET last_used_at = now() WHERE id = $1")
916 .bind(id)
917 .execute(pool)
918 .await;
919 Ok(Some(AdminCtx {
920 id,
921 team_id,
922 team_slug,
923 }))
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929
930 #[test]
931 fn names_are_normalised_and_bounded() {
932 assert_eq!(check_name("agent name", " Backend ").unwrap(), "backend");
933 assert!(check_name("agent name", "").is_err());
934 assert!(check_name("agent name", "with space").is_err());
935 assert!(check_name("agent name", "a/b").is_err());
936 assert!(check_name("agent name", &"x".repeat(MAX_NAME_BYTES + 1)).is_err());
937 }
938
939 #[test]
940 fn labels_are_optional_and_bounded() {
941 assert_eq!(check_label(None).unwrap(), None);
942 assert_eq!(check_label(Some(" ".into())).unwrap(), None);
943 assert_eq!(
944 check_label(Some(" sesion backend ".into()))
945 .unwrap()
946 .as_deref(),
947 Some("sesion backend")
948 );
949 assert!(check_label(Some("x".repeat(MAX_LABEL_BYTES + 1))).is_err());
950 assert!(check_label(Some("a\nb".into())).is_err());
951 assert!(check_display("team name", Some("Acme\x1b[31m".into())).is_err());
952 }
953}