Skip to main content

didbot_serve/routes/
accounts.rs

1use super::*;
2
3/// `POST /xrpc/bot.did.deleteAccount`
4///
5/// `Credential::AccountSelf`: a harness client calls this directly to erase an
6/// account at the end of a session, carrying that account's own write
7/// credential — the self-service case [`auth::require_self`] exists for,
8/// and the only case, since this server holds no credential that could
9/// authenticate somebody acting on another account. The repository, its
10/// blobs and its credentials go and the name is burned; the DID document,
11/// its key and the account's row stay, `decommissioned`, so every signature
12/// the agent made stays checkable. Refused with 403 `AccountLocked` while
13/// any lock hangs and 403 `AccountHeld` under an operator's hold.
14pub(crate) async fn delete_account(
15    State(state): State<AppState>,
16    headers: HeaderMap,
17    XrpcBody(body): XrpcBody,
18) -> Response {
19    // `docs/write-pipeline.md`'s stage 1, ahead of everything else this
20    // route does. The stop refuses outstanding work, and an agent acting on
21    // its own account with a credential issued before the stop is
22    // outstanding work: an operator who has pulled the brake has not
23    // consented to a repository being emptied, a name being burned or a
24    // freeze being lifted while it is pulled. `bot.did.freezeAccount` is
25    // deliberately not gated this way; see its own doc comment.
26    if let Err(halted) = state.estop.check_use(EstopRefusal::Operation) {
27        return ApiError::from(halted).into_response();
28    }
29
30    let request: DidRequest = match parse_body(&body) {
31        Ok(request) => request,
32        Err(err) => return err.into_response(),
33    };
34    if let Err(err) = auth::require_self(state.registry.as_ref(), &headers, &request.did) {
35        return err.into_response();
36    }
37    // Off the async worker for the same reason as `create_account`:
38    // `delete` takes the write lock and tears the repository down under it.
39    let registry = state.registry.clone();
40    let did = request.did.clone();
41    let outcome = tokio::task::spawn_blocking(move || registry.delete(&did)).await;
42    match outcome {
43        Ok(Ok(())) => Json(json!({})).into_response(),
44        Ok(Err(err)) => ApiError::from(&err).into_response(),
45        Err(join_err) => blocking_task_failed("bot.did.deleteAccount", join_err).into_response(),
46    }
47}
48
49/// `POST /xrpc/bot.did.freezeAccount`
50///
51/// `Credential::AccountSelf`, the same posture as [`delete_account`]: an agent
52/// hangs [`Lock::Frozen`] on itself as [`Actor::Account`], and nothing else
53/// hangs a lock over HTTP. The repository stays readable; only writes are
54/// refused, with 403 `AccountNotWritable`. Reversible with
55/// [`unfreeze_account`], and only by the same party: an operator's own frozen
56/// tag is not the account's to lift.
57///
58/// The one route in this group that the e-stop does **not** gate. Every
59/// other one either destroys something or widens what the account may do,
60/// and the stop refuses those; freezing can only ever narrow, so refusing
61/// it under a stop would leave a caller less able to restrain itself than
62/// before the operator pulled the brake. A gate that can only deny is safe
63/// to skip for an operation that can only deny.
64pub(crate) async fn freeze_account(
65    State(state): State<AppState>,
66    headers: HeaderMap,
67    XrpcBody(body): XrpcBody,
68) -> Response {
69    let request: DidRequest = match parse_body(&body) {
70        Ok(request) => request,
71        Err(err) => return err.into_response(),
72    };
73    if let Err(err) = auth::require_self(state.registry.as_ref(), &headers, &request.did) {
74        return err.into_response();
75    }
76    match state
77        .registry
78        .lock(&request.did, Lock::Frozen, Actor::Account)
79    {
80        Ok(()) => Json(json!({})).into_response(),
81        Err(err) => ApiError::from(&err).into_response(),
82    }
83}
84
85/// `POST /xrpc/bot.did.unfreezeAccount`
86///
87/// Reverses [`freeze_account`]. Refused with 409 `AccountNotLocked` for an
88/// account the caller's party has not frozen.
89pub(crate) async fn unfreeze_account(
90    State(state): State<AppState>,
91    headers: HeaderMap,
92    XrpcBody(body): XrpcBody,
93) -> Response {
94    // `docs/write-pipeline.md`'s stage 1, ahead of everything else this
95    // route does. The stop refuses outstanding work, and an agent acting on
96    // its own account with a credential issued before the stop is
97    // outstanding work: an operator who has pulled the brake has not
98    // consented to a repository being emptied, a name being burned or a
99    // freeze being lifted while it is pulled. `bot.did.freezeAccount` is
100    // deliberately not gated this way; see its own doc comment.
101    if let Err(halted) = state.estop.check_use(EstopRefusal::Operation) {
102        return ApiError::from(halted).into_response();
103    }
104
105    let request: DidRequest = match parse_body(&body) {
106        Ok(request) => request,
107        Err(err) => return err.into_response(),
108    };
109    if let Err(err) = auth::require_self(state.registry.as_ref(), &headers, &request.did) {
110        return err.into_response();
111    }
112    match state
113        .registry
114        .unlock(&request.did, Lock::Frozen, Actor::Account)
115    {
116        Ok(()) => Json(json!({})).into_response(),
117        Err(err) => ApiError::from(&err).into_response(),
118    }
119}
120
121/// `POST /xrpc/bot.did.deactivateAccount`
122///
123/// `Credential::AccountSelf`: an agent hangs [`Lock::Deactivated`] on itself.
124/// Reads and writes are both refused while it hangs — a relay is told
125/// `active: false, status: deactivated` — and the document, the key and the
126/// repository all stay, so [`activate_account`] puts the account back exactly
127/// as it was. Not gated by the e-stop, for the reason [`freeze_account`] is
128/// not: it can only narrow.
129pub(crate) async fn deactivate_account(
130    State(state): State<AppState>,
131    headers: HeaderMap,
132    XrpcBody(body): XrpcBody,
133) -> Response {
134    let request: DidRequest = match parse_body(&body) {
135        Ok(request) => request,
136        Err(err) => return err.into_response(),
137    };
138    if let Err(err) = auth::require_self(state.registry.as_ref(), &headers, &request.did) {
139        return err.into_response();
140    }
141    match state
142        .registry
143        .lock(&request.did, Lock::Deactivated, Actor::Account)
144    {
145        Ok(()) => Json(json!({})).into_response(),
146        Err(err) => ApiError::from(&err).into_response(),
147    }
148}
149
150/// `POST /xrpc/bot.did.activateAccount`
151///
152/// Reverses [`deactivate_account`]. Widens what the account may do, so the
153/// e-stop gates it the way it gates [`unfreeze_account`].
154pub(crate) async fn activate_account(
155    State(state): State<AppState>,
156    headers: HeaderMap,
157    XrpcBody(body): XrpcBody,
158) -> Response {
159    if let Err(halted) = state.estop.check_use(EstopRefusal::Operation) {
160        return ApiError::from(halted).into_response();
161    }
162
163    let request: DidRequest = match parse_body(&body) {
164        Ok(request) => request,
165        Err(err) => return err.into_response(),
166    };
167    if let Err(err) = auth::require_self(state.registry.as_ref(), &headers, &request.did) {
168        return err.into_response();
169    }
170    match state
171        .registry
172        .unlock(&request.did, Lock::Deactivated, Actor::Account)
173    {
174        Ok(()) => Json(json!({})).into_response(),
175        Err(err) => ApiError::from(&err).into_response(),
176    }
177}
178
179/// `GET /xrpc/bot.did.listAccounts`
180///
181/// `Credential::Disclosure`: open by default, this deployment's own roster
182/// of agents — see `auth::Disclosure` for why the default is public. Also
183/// what vibescrobble.com's index surveys across *arbitrary* personal data
184/// servers it does not administer, which is why closing it is this
185/// deployment's own call rather than one this crate can make for every
186/// deployment.
187pub(crate) async fn list_accounts(State(state): State<AppState>) -> Response {
188    if let Err(err) = require_lifecycle(&state, |p| p.serves_reads, "bot.did.listAccounts") {
189        return err.into_response();
190    }
191    if let Err(err) =
192        auth::require_disclosure(state.disclosure.list_accounts, "bot.did.listAccounts")
193    {
194        return err.into_response();
195    }
196    let operator = state.registry.operator_did();
197    let accounts: Vec<AccountSummary> = state
198        .registry
199        .accounts()
200        .iter()
201        .map(|account| {
202            let credentials = state.registry.credentials(account.did.as_str());
203            AccountSummary::from_account(account, &operator, &credentials)
204        })
205        .collect();
206    Json(json!({ "accounts": accounts })).into_response()
207}
208
209/// Resolves the `repo` parameter, which is an at-identifier.
210///
211/// A DID or a handle, because that is what every `com.atproto.repo.*` lexicon
212/// says the field is. Resolving it here rather than inside the registry means
213/// the handle-to-DID step happens once, in the layer that already owns
214/// `/.well-known/atproto-did`, and every route below works in DIDs.
215///
216/// The returned DID is the *stored* one, never the string the caller sent, so
217/// an `at://` URI built from it names the repository the same way twice
218/// however the caller spelled it.
219///
220/// A DID goes through [`canonical_did`] first, for the local-only trap
221/// leaves behind: a development DID carries a percent-encoded port, a query
222/// value is percent-decoded once before a handler sees it, and a person who
223/// copied `did:web:kestrel.agents.localhost%3A4211` out of a log and pasted
224/// it into a URL would otherwise get a 404 for a repository that is plainly
225/// there. The query service performs the same repair, from the same function.
226pub(crate) fn resolve_repo(state: &AppState, repo: &str) -> Result<String, ApiError> {
227    let did = if repo.starts_with("did:") {
228        let canonical = canonical_did(repo);
229        state
230            .registry
231            .account(&canonical)
232            .map(|a| a.did.as_str().to_owned())
233    } else {
234        state
235            .registry
236            .handle_did(repo)
237            .filter(|did| state.registry.account(did).is_some())
238    };
239    did.ok_or_else(|| ApiError::repo_not_found(repo))
240}
241
242/// `GET /xrpc/bot.did.getAccountLedger`
243///
244/// The bookkeeping for one agent, which is the one read here that deliberately
245/// answers about accounts that no longer exist. A 404 means this deployment
246/// never minted that DID at all — not that the agent has been deprovisioned,
247/// which is an answer with a body.
248///
249/// `Credential::Disclosure`: open by default, this deployment's own audit
250/// trail for one account — see `auth::Disclosure`.
251pub(crate) async fn get_account_ledger(
252    State(state): State<AppState>,
253    query: Result<Query<LedgerQuery>, QueryRejection>,
254) -> Response {
255    if let Err(err) = require_lifecycle(&state, |p| p.serves_reads, "bot.did.getAccountLedger") {
256        return err.into_response();
257    }
258    if let Err(err) = auth::require_disclosure(
259        state.disclosure.get_account_ledger,
260        "bot.did.getAccountLedger",
261    ) {
262        return err.into_response();
263    }
264    let Query(query) = match query {
265        Ok(query) => query,
266        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
267    };
268    match state.registry.ledger(&query.did) {
269        Some(ledger) => Json(ledger).into_response(),
270        None => ApiError::new(
271            StatusCode::NOT_FOUND,
272            "UnknownAccount",
273            format!("this deployment has no bookkeeping for {}", query.did),
274        )
275        .into_response(),
276    }
277}
278
279/// `GET /xrpc/bot.did.listAccountLedgers`
280///
281/// Every agent this deployment has ever provisioned, which is longer than
282/// `listAccounts` by exactly the ones it has taken away again.
283///
284/// `Credential::Disclosure`, the same as [`get_account_ledger`]: open by
285/// default, the audit trail for every account this deployment has ever
286/// held.
287pub(crate) async fn list_ledgers(State(state): State<AppState>) -> Response {
288    if let Err(err) = require_lifecycle(&state, |p| p.serves_reads, "bot.did.listAccountLedgers") {
289        return err.into_response();
290    }
291    if let Err(err) = auth::require_disclosure(
292        state.disclosure.list_account_ledgers,
293        "bot.did.listAccountLedgers",
294    ) {
295        return err.into_response();
296    }
297    Json(json!({ "ledgers": state.registry.ledgers() })).into_response()
298}