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}