Skip to main content

didbot_serve/
dashboard_accounts.rs

1//! The operations dashboard's account view: every account this deployment
2//! holds, and one account in full.
3//!
4//! Part of [`crate::dashboard`], behind the same operator session as every
5//! other control there, and answered whatever the `[disclosure]` table says:
6//! closing `bot.did.listAccounts` to the public does not close it to the
7//! operator. `didbot account list` and `didbot account show` call these
8//! same routes.
9//!
10//! Every field is read from state the deployment already keeps. The last
11//! commit is the time in the repository head's revision, which is a TID.
12//! The record count is the record store's key index. Whether an account
13//! wrote is [`Wrote`]'s reading of the same two sources.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use axum::extract::{Path, Query, State};
18use axum::http::StatusCode;
19use axum::response::{IntoResponse, Response};
20use axum::routing::get;
21use axum::{Json, Router};
22use didbot_pds::{AccountKind, HostedAccount, LedgerEntry};
23use serde::Serialize;
24use time::format_description::well_known::Rfc3339;
25use time::OffsetDateTime;
26
27use crate::routes::AppState;
28use crate::surface::{refusal, signed_in, PanelState};
29use crate::wire::AccountSummary;
30
31/// Builds the account routes, merged into the dashboard's own router.
32pub fn routes() -> Router<AppState> {
33    Router::new()
34        .route("/dashboard/api/accounts", get(list))
35        .route("/dashboard/api/accounts/{did}", get(show))
36}
37
38/// How many of an account's most recent ledger entries the detail carries.
39const RECENT_LEDGER: usize = 20;
40
41/// What the listing narrows on. All empty lists everything.
42#[derive(Debug, Clone, Default, serde::Deserialize)]
43#[serde(default, rename_all = "camelCase")]
44pub struct AccountFilter {
45    /// Only accounts of this kind.
46    kind: Option<AccountKind>,
47    /// Only accounts beneath this one, by handle or `did:`, at any depth.
48    under: Option<String>,
49    /// Only accounts known never to have written a record.
50    never_wrote: bool,
51    /// Only accounts whose last commit, or creation when there is none, is
52    /// at least this long ago. A span such as `7d` or `12h`.
53    idle_for: Option<String>,
54}
55
56/// Whether an account ever wrote a record of its own.
57///
58/// The server writes some records into every repository itself: the
59/// collections [`didbot_pds::records::SERVER_AUTHORED`] names, and the
60/// profile at provisioning. An account wrote when it holds a record in any
61/// other collection, or when the write log holds a write from it.
62///
63/// The write log keeps rows for a retention window, and holds nothing from
64/// before it was kept. An account created before the log's oldest row could
65/// have written, then deleted everything, with no row left to say so. So an
66/// account whose records hold nothing of its own reads [`Wrote::Never`] only
67/// when it was created at or after that row, and [`Wrote::Unknown`]
68/// otherwise, or when the log cannot be read.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum Wrote {
71    /// It holds a record of its own, or the write log has a write from it.
72    Yes,
73    /// Neither, and the write log reaches back to its creation.
74    Never,
75    /// Its repository is gone, the write log cannot be read, or the log
76    /// does not reach back to its creation, and nothing else says it wrote.
77    Unknown,
78}
79
80impl Wrote {
81    fn as_json(self) -> Option<bool> {
82        match self {
83            Self::Yes => Some(true),
84            Self::Never => Some(false),
85            Self::Unknown => None,
86        }
87    }
88}
89
90/// The shortest `idleFor` a `neverWrote` delete takes, unless
91/// `[operator] never_wrote_min_idle_secs` sets another. An agent's account
92/// is created before its session makes its first write, and an hour is
93/// well past that.
94pub const NEVER_WROTE_MIN_IDLE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
95
96/// What the write log says about who wrote.
97pub(crate) struct Writers {
98    /// Every account the log holds a write from.
99    accounts: BTreeSet<String>,
100    /// When its oldest row landed. Every write since is in the log. `None`
101    /// when it holds no row.
102    since: Option<OffsetDateTime>,
103}
104
105/// What the write log says, or `None` when it cannot be read.
106fn writers(state: &AppState) -> Option<Writers> {
107    let Some(rows) = state.registry.write_rows() else {
108        tracing::warn!("the write log cannot be listed; no account reads as never having written");
109        return None;
110    };
111    let since = rows.iter().map(|row| row.at).min();
112    Some(Writers {
113        accounts: rows.into_iter().map(|row| row.account).collect(),
114        since,
115    })
116}
117
118/// [`Wrote`] for `account`, given its record counts and [`writers`].
119fn wrote_from(
120    account: &HostedAccount,
121    counts: Option<&BTreeMap<String, usize>>,
122    writers: Option<&Writers>,
123) -> Wrote {
124    let did = account.did.as_str();
125    if writers.is_some_and(|writers| writers.accounts.contains(did)) {
126        return Wrote::Yes;
127    }
128    let covered = writers
129        .and_then(|writers| writers.since)
130        .is_some_and(|since| since <= account.created_at);
131    match counts.map(authored) {
132        Some(0) if covered => Wrote::Never,
133        Some(0) | None => Wrote::Unknown,
134        Some(_) => Wrote::Yes,
135    }
136}
137
138/// How many records sit in collections the server does not write itself.
139fn authored(counts: &BTreeMap<String, usize>) -> usize {
140    counts
141        .iter()
142        .filter(|(collection, _)| {
143            !didbot_pds::records::server_authored(collection)
144                && collection.as_str() != didbot_pds::bsky::ACTOR_PROFILE
145        })
146        .map(|(_, count)| count)
147        .sum()
148}
149
150/// One account, as the listing shows it.
151#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub(crate) struct AccountRow {
154    /// What `bot.did.listAccounts` says about the account.
155    #[serde(flatten)]
156    summary: AccountSummary,
157    /// Every hosted account above this one, the server's own first and the
158    /// parent last.
159    lineage: Vec<String>,
160    /// How many accounts hang directly beneath this one.
161    children: usize,
162    /// When the repository's last commit was made, by the server's clock.
163    /// Absent when the account has no repository.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    last_commit: Option<String>,
166    /// How many records the repository holds, the server's own included.
167    /// Absent when the account has no repository.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    records: Option<usize>,
170    /// See [`Wrote`]. `null` when it cannot be told.
171    wrote: Option<bool>,
172    /// The last commit, or the creation when there is none.
173    #[serde(skip)]
174    idle_since: OffsetDateTime,
175    #[serde(skip)]
176    wrote_reading: Wrote,
177    #[serde(skip)]
178    counts: Option<BTreeMap<String, usize>>,
179}
180
181/// Everything the listing needs to know about the whole tree, read once.
182struct Roster {
183    accounts: Vec<HostedAccount>,
184    parents: BTreeMap<String, String>,
185    children: BTreeMap<String, usize>,
186    operator: String,
187    writers: Option<Writers>,
188}
189
190impl Roster {
191    fn read(state: &AppState) -> Self {
192        let accounts = state.registry.accounts();
193        let mut parents = BTreeMap::new();
194        let mut children = BTreeMap::new();
195        for account in &accounts {
196            if let Some(parent) = &account.parent {
197                parents.insert(account.did.as_str().to_owned(), parent.as_str().to_owned());
198                *children.entry(parent.as_str().to_owned()).or_insert(0) += 1;
199            }
200        }
201        Self {
202            accounts,
203            parents,
204            children,
205            operator: state.registry.operator_did(),
206            writers: writers(state),
207        }
208    }
209
210    /// The hosted accounts above `did`, the root first.
211    fn lineage(&self, did: &str) -> Vec<String> {
212        let mut chain = Vec::new();
213        let mut at = did;
214        while let Some(parent) = self.parents.get(at) {
215            // A cycle would be a corrupt store; stop rather than spin.
216            if chain.contains(parent) || chain.len() > self.parents.len() {
217                break;
218            }
219            chain.push(parent.clone());
220            at = parent;
221        }
222        chain.reverse();
223        chain
224    }
225
226    fn row(&self, state: &AppState, account: &HostedAccount) -> AccountRow {
227        let did = account.did.as_str();
228        let credentials = state.registry.credentials(did);
229        let counts = state.registry.record_counts(did).ok();
230        let last_commit = last_commit(state, did);
231        let wrote = wrote_from(account, counts.as_ref(), self.writers.as_ref());
232        AccountRow {
233            summary: AccountSummary::from_account(account, &self.operator, &credentials),
234            lineage: self.lineage(did),
235            children: self.children.get(did).copied().unwrap_or(0),
236            last_commit: last_commit.and_then(|at| at.format(&Rfc3339).ok()),
237            records: counts.as_ref().map(|counts| counts.values().sum()),
238            wrote: wrote.as_json(),
239            idle_since: last_commit.unwrap_or(account.created_at),
240            wrote_reading: wrote,
241            counts,
242        }
243    }
244}
245
246/// When `did`'s last commit was made: the head's revision is a TID, and a
247/// TID carries the microseconds its minter's clock read.
248fn last_commit(state: &AppState, did: &str) -> Option<OffsetDateTime> {
249    let rev = state.registry.latest_commit(did).ok()?.rev;
250    let micros = didbot_schema::tid::parse(&rev).ok()?.micros();
251    OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000).ok()
252}
253
254/// The moment `span`, such as `7d`, before now, or the refusal to answer
255/// with.
256pub(crate) fn span_before_now(field: &str, span: &str) -> Result<OffsetDateTime, Box<Response>> {
257    let parsed = humantime::parse_duration(span).map_err(|error| {
258        Box::new(refusal(
259            StatusCode::BAD_REQUEST,
260            "InvalidRequest",
261            format!("{field} `{span}` is not a span of time such as 7d: {error}"),
262        ))
263    })?;
264    Ok(OffsetDateTime::now_utc() - parsed)
265}
266
267/// The filter, with its names resolved and its span parsed, or the refusal
268/// to answer with.
269struct Narrowing {
270    kind: Option<AccountKind>,
271    under: Option<String>,
272    never_wrote: bool,
273    idle_before: Option<OffsetDateTime>,
274}
275
276impl Narrowing {
277    fn of(state: &AppState, filter: AccountFilter) -> Result<Self, Box<Response>> {
278        let under = match filter.under.as_deref() {
279            Some(named) => Some(
280                crate::routes::resolve_repo(state, named)
281                    .map_err(|error| Box::new(error.into_response()))?,
282            ),
283            None => None,
284        };
285        let idle_before = filter
286            .idle_for
287            .as_deref()
288            .map(|span| span_before_now("idleFor", span))
289            .transpose()?;
290        Ok(Self {
291            kind: filter.kind,
292            under,
293            never_wrote: filter.never_wrote,
294            idle_before,
295        })
296    }
297
298    fn admits(&self, row: &AccountRow) -> bool {
299        if self.kind.is_some_and(|kind| kind != row.summary.kind) {
300            return false;
301        }
302        if let Some(under) = &self.under {
303            if !row.lineage.contains(under) {
304                return false;
305            }
306        }
307        if self.never_wrote && row.wrote_reading != Wrote::Never {
308            return false;
309        }
310        self.idle_before
311            .is_none_or(|before| row.idle_since <= before)
312    }
313}
314
315/// What a conditional hard delete checks each account against: before it
316/// takes anything, and again for each account with that account's writes
317/// held off. See `Registry::hard_delete_if`.
318pub(crate) struct Keeping {
319    /// Only accounts of this kind go.
320    kind: Option<AccountKind>,
321    /// Only accounts known never to have written go, and no host or service
322    /// account unless `kind` names it.
323    never_wrote: bool,
324    /// Only accounts whose last commit, or creation when there is none, is
325    /// no later than this go.
326    idle_before: Option<OffsetDateTime>,
327    /// [`writers`], read once before the delete. A write the log gains after
328    /// this moves the account's last commit, which `idle_before` refuses.
329    writers: Option<Writers>,
330}
331
332impl Keeping {
333    /// The checks a delete's body asks for, or the refusal to answer with.
334    /// A never-wrote delete has to name `idleFor`, at least
335    /// [`AuthState::never_wrote_min_idle`](crate::AuthState::never_wrote_min_idle).
336    pub(crate) fn of(
337        state: &AppState,
338        kind: Option<AccountKind>,
339        never_wrote: bool,
340        idle_for: Option<&str>,
341    ) -> Result<Self, Box<Response>> {
342        let idle_before = idle_for
343            .map(|span| span_before_now("idleFor", span))
344            .transpose()?;
345        let idle = idle_for.and_then(|span| humantime::parse_duration(span).ok());
346        let floor = state.never_wrote_min_idle;
347        if never_wrote && idle.is_none_or(|span| span.is_zero() || span < floor) {
348            return Err(Box::new(refusal(
349                StatusCode::BAD_REQUEST,
350                "InvalidRequest",
351                format!(
352                    "a neverWrote delete names idleFor, a span of at least {} such as 24h: an \
353                     account created more recently may be about to write",
354                    humantime::format_duration(floor)
355                ),
356            )));
357        }
358        Ok(Self {
359            kind,
360            never_wrote,
361            idle_before,
362            writers: if never_wrote { writers(state) } else { None },
363        })
364    }
365
366    /// Why `account` stays, or nothing when it may go.
367    pub(crate) fn check(&self, state: &AppState, account: &HostedAccount) -> Result<(), String> {
368        let did = account.did.as_str();
369        if let Some(kind) = self.kind.filter(|kind| *kind != account.kind) {
370            return Err(format!(
371                "it is {}, not {}",
372                account.kind.with_article(),
373                kind.with_article()
374            ));
375        }
376        if self.never_wrote {
377            if self.kind.is_none()
378                && matches!(account.kind, AccountKind::Host | AccountKind::Service)
379            {
380                return Err(format!(
381                    "it is {}; a neverWrote delete takes one only when kind names it",
382                    account.kind.with_article()
383                ));
384            }
385            let counts = state.registry.record_counts(did).ok();
386            if wrote_from(account, counts.as_ref(), self.writers.as_ref()) != Wrote::Never {
387                return Err("it wrote a record, or whether it did cannot be told".to_owned());
388            }
389        }
390        if let Some(before) = self.idle_before {
391            let since = last_commit(state, did).unwrap_or(account.created_at);
392            if since > before {
393                return Err(format!(
394                    "its last commit or creation, at {}, is within idleFor",
395                    since.format(&Rfc3339).unwrap_or_default()
396                ));
397            }
398        }
399        Ok(())
400    }
401}
402
403/// `GET /dashboard/api/accounts`
404///
405/// Every account this deployment holds, in DID order, narrowed by
406/// [`AccountFilter`].
407async fn list(
408    State(state): State<AppState>,
409    headers: axum::http::HeaderMap,
410    Query(filter): Query<AccountFilter>,
411) -> Response {
412    if let Err(refusal) = signed_in(&state, &headers, false) {
413        return *refusal;
414    }
415    let narrowing = match Narrowing::of(&state, filter) {
416        Ok(narrowing) => narrowing,
417        Err(refusal) => return *refusal,
418    };
419    let roster = Roster::read(&state);
420    let rows: Vec<AccountRow> = roster
421        .accounts
422        .iter()
423        .map(|account| roster.row(&state, account))
424        .filter(|row| narrowing.admits(row))
425        .collect();
426    tracing::debug!(accounts = rows.len(), "listed accounts for the operator");
427    Json(PanelState::Live { data: rows }).into_response()
428}
429
430/// One account in full, as the detail shows it.
431#[derive(Debug, Clone, Serialize)]
432#[serde(rename_all = "camelCase")]
433struct AccountDetail {
434    #[serde(flatten)]
435    row: AccountRow,
436    /// How many records each collection holds.
437    collections: BTreeMap<String, usize>,
438    /// The account's ledger, shortened.
439    ledger: LedgerSummary,
440    /// What logs in as the account. Every credential record holds a public
441    /// half only.
442    credentials: Vec<CredentialView>,
443}
444
445/// An account's ledger: how long it is, and its most recent entries.
446#[derive(Debug, Clone, Serialize)]
447#[serde(rename_all = "camelCase")]
448struct LedgerSummary {
449    /// How many entries the whole ledger holds.
450    entries: usize,
451    /// The most recent entries, oldest first.
452    recent: Vec<LedgerEntry>,
453}
454
455/// One `bot.did.credential` record.
456#[derive(Debug, Clone, Serialize)]
457#[serde(rename_all = "camelCase")]
458pub(crate) struct CredentialView {
459    /// The record key, which `didbot account credentials --remove` names.
460    rkey: String,
461    /// The key's fingerprint, as `didbot register` prints it.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    fingerprint: Option<String>,
464    /// The credential, as its record holds it.
465    credential: serde_json::Value,
466}
467
468/// What logs in as `did`: its `bot.did.credential` records.
469pub(crate) fn credentials(state: &AppState, did: &str) -> Vec<CredentialView> {
470    state
471        .registry
472        .credential_records(did)
473        .into_iter()
474        .map(|(rkey, credential)| CredentialView {
475            rkey,
476            fingerprint: credential
477                .public_key_multibase()
478                .map(didbot_claim_check::key_fingerprint),
479            credential: credential.to_json(),
480        })
481        .collect()
482}
483
484/// `GET /dashboard/api/accounts/{did}`
485///
486/// One account: its listing row, its collections, its ledger and its
487/// credentials. The path segment is a handle or a `did:`.
488async fn show(
489    State(state): State<AppState>,
490    Path(named): Path<String>,
491    headers: axum::http::HeaderMap,
492) -> Response {
493    if let Err(refusal) = signed_in(&state, &headers, false) {
494        return *refusal;
495    }
496    let did = match crate::routes::resolve_repo(&state, &named) {
497        Ok(did) => did,
498        Err(error) => return error.into_response(),
499    };
500    let Some(account) = state.registry.account(&did) else {
501        return crate::error::ApiError::repo_not_found(&named).into_response();
502    };
503    let roster = Roster::read(&state);
504    let mut row = roster.row(&state, &account);
505    let collections = row.counts.take().unwrap_or_default();
506    let entries = state
507        .registry
508        .ledger(&did)
509        .map(|ledger| ledger.entries)
510        .unwrap_or_default();
511    let recent = entries[entries.len().saturating_sub(RECENT_LEDGER)..].to_vec();
512    let credentials = credentials(&state, &did);
513    Json(PanelState::Live {
514        data: AccountDetail {
515            row,
516            collections,
517            ledger: LedgerSummary {
518                entries: entries.len(),
519                recent,
520            },
521            credentials,
522        },
523    })
524    .into_response()
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    /// A write log holding no row from `accounts`, whose oldest row landed
532    /// at `since`.
533    fn log(accounts: &[&str], since: Option<OffsetDateTime>) -> Writers {
534        Writers {
535            accounts: accounts.iter().map(|did| (*did).to_owned()).collect(),
536            since,
537        }
538    }
539
540    /// The server's own writes are not the account writing: a fresh account
541    /// holds a profile, a registration and a credential, and never wrote.
542    #[test]
543    fn only_records_outside_the_servers_own_collections_count_as_writing() {
544        let (account, _, _) = crate::tests::fixture("a3169b88");
545        let did = account.did.as_str();
546        let before = Some(account.created_at - time::Duration::days(1));
547        let fresh = BTreeMap::from([
548            ("app.bsky.actor.profile".to_owned(), 1),
549            ("bot.did.credential".to_owned(), 1),
550            ("bot.did.operator".to_owned(), 3),
551            ("bot.did.registration".to_owned(), 1),
552        ]);
553        let none = log(&[], before);
554        assert_eq!(
555            wrote_from(&account, Some(&fresh), Some(&none)),
556            Wrote::Never
557        );
558
559        let mut written = fresh.clone();
560        written.insert("com.example.post".to_owned(), 2);
561        assert_eq!(
562            wrote_from(&account, Some(&written), Some(&none)),
563            Wrote::Yes
564        );
565
566        // A profile edit leaves nothing in a collection of its own; the
567        // write log is what remembers it.
568        let logged = log(&[did], before);
569        assert_eq!(
570            wrote_from(&account, Some(&fresh), Some(&logged)),
571            Wrote::Yes
572        );
573
574        // No repository and no log row is not "never", and neither is an
575        // empty repository whose write log cannot be read.
576        assert_eq!(wrote_from(&account, None, Some(&none)), Wrote::Unknown);
577        assert_eq!(wrote_from(&account, Some(&fresh), None), Wrote::Unknown);
578        assert_eq!(wrote_from(&account, Some(&written), None), Wrote::Yes);
579    }
580
581    /// A write log whose oldest row is younger than the account could have
582    /// lost the account's writes to its retention, or never held them. So
583    /// could a log with no row at all. Neither reads "never".
584    #[test]
585    fn a_write_log_that_does_not_reach_back_to_the_account_cannot_say_never() {
586        let (account, _, _) = crate::tests::fixture("a3169b88");
587        let fresh = BTreeMap::from([("app.bsky.actor.profile".to_owned(), 1)]);
588        for since in [Some(account.created_at + time::Duration::days(31)), None] {
589            assert_eq!(
590                wrote_from(&account, Some(&fresh), Some(&log(&[], since))),
591                Wrote::Unknown,
592                "oldest row {since:?}"
593            );
594        }
595        assert_eq!(
596            wrote_from(
597                &account,
598                Some(&fresh),
599                Some(&log(&[], Some(account.created_at)))
600            ),
601            Wrote::Never,
602            "a log reaching back to the account's creation holds every write since"
603        );
604    }
605}