1use 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
31pub fn routes() -> Router<AppState> {
33 Router::new()
34 .route("/dashboard/api/accounts", get(list))
35 .route("/dashboard/api/accounts/{did}", get(show))
36}
37
38const RECENT_LEDGER: usize = 20;
40
41#[derive(Debug, Clone, Default, serde::Deserialize)]
43#[serde(default, rename_all = "camelCase")]
44pub struct AccountFilter {
45 kind: Option<AccountKind>,
47 under: Option<String>,
49 never_wrote: bool,
51 idle_for: Option<String>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum Wrote {
71 Yes,
73 Never,
75 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
90pub const NEVER_WROTE_MIN_IDLE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
95
96pub(crate) struct Writers {
98 accounts: BTreeSet<String>,
100 since: Option<OffsetDateTime>,
103}
104
105fn 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
118fn 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
138fn 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#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub(crate) struct AccountRow {
154 #[serde(flatten)]
156 summary: AccountSummary,
157 lineage: Vec<String>,
160 children: usize,
162 #[serde(skip_serializing_if = "Option::is_none")]
165 last_commit: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
169 records: Option<usize>,
170 wrote: Option<bool>,
172 #[serde(skip)]
174 idle_since: OffsetDateTime,
175 #[serde(skip)]
176 wrote_reading: Wrote,
177 #[serde(skip)]
178 counts: Option<BTreeMap<String, usize>>,
179}
180
181struct 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 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 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
246fn 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
254pub(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
267struct 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
315pub(crate) struct Keeping {
319 kind: Option<AccountKind>,
321 never_wrote: bool,
324 idle_before: Option<OffsetDateTime>,
327 writers: Option<Writers>,
330}
331
332impl Keeping {
333 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 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
403async 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#[derive(Debug, Clone, Serialize)]
432#[serde(rename_all = "camelCase")]
433struct AccountDetail {
434 #[serde(flatten)]
435 row: AccountRow,
436 collections: BTreeMap<String, usize>,
438 ledger: LedgerSummary,
440 credentials: Vec<CredentialView>,
443}
444
445#[derive(Debug, Clone, Serialize)]
447#[serde(rename_all = "camelCase")]
448struct LedgerSummary {
449 entries: usize,
451 recent: Vec<LedgerEntry>,
453}
454
455#[derive(Debug, Clone, Serialize)]
457#[serde(rename_all = "camelCase")]
458pub(crate) struct CredentialView {
459 rkey: String,
461 #[serde(skip_serializing_if = "Option::is_none")]
463 fingerprint: Option<String>,
464 credential: serde_json::Value,
466}
467
468pub(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
484async 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 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 #[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 let logged = log(&[did], before);
569 assert_eq!(
570 wrote_from(&account, Some(&fresh), Some(&logged)),
571 Wrote::Yes
572 );
573
574 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 #[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}