Skip to main content

boxxkite_client/
account.rs

1//! Account/usage introspection, dashboard auth-flow helpers, and the
2//! per-account command allowlist. Mirrors `sdk-python`'s `account`/`usage`/
3//! `request_password_reset`/`confirm_password_reset`/`verify_email`/
4//! `resend_verification`/`refresh_token`/`logout`/`get_allowed_commands`/
5//! `set_allowed_commands`/`clear_allowed_commands` and `sdk-go`'s
6//! same-named methods.
7
8use reqwest::Method;
9use serde::de::{self, Deserializer};
10use serde::{Deserialize, Serialize};
11
12use crate::client::Client;
13use crate::error::BoxxkiteError;
14use crate::sandboxes::UsageSummary;
15
16/// The account identity for the API key in use (`GET /v1/account`).
17#[derive(Debug, Clone, Deserialize)]
18pub struct Account {
19    pub id: String,
20    pub email: String,
21    pub created_at: String,
22}
23
24/// A generic ack body (e.g. a password-reset request, which returns the same
25/// message whether or not the email is registered).
26#[derive(Debug, Clone, Deserialize)]
27pub struct MessageResponse {
28    pub message: String,
29}
30
31/// A fresh `access_token` + `refresh_token` pair plus the account identity,
32/// returned by [`Client::refresh_token`].
33#[derive(Debug, Clone, Deserialize)]
34pub struct TokenPair {
35    pub access_token: String,
36    pub token_type: String,
37    pub expires_in: i64,
38    pub refresh_token: Option<String>,
39    pub account: Account,
40}
41
42/// One account-level command-allowlist rule -- either a bare command name
43/// (`args_allow`/`args_deny` both empty) or a command name plus argument
44/// allow/deny regex lists.
45///
46/// The control-plane accepts and returns rules in either shape (a bare JSON
47/// string or a `{command, args_allow?, args_deny?}` object); this type's
48/// `Deserialize` decodes both, while its `Serialize` always emits the object
49/// form (which the control-plane accepts identically). Mirrors `sdk-go`'s
50/// `AllowedCommandRule` custom marshaling.
51#[derive(Debug, Clone, Serialize)]
52pub struct AllowedCommandRule {
53    pub command: String,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub args_allow: Vec<String>,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub args_deny: Vec<String>,
58}
59
60impl AllowedCommandRule {
61    /// A bare rule: just a command name, no argument constraints.
62    pub fn new(command: impl Into<String>) -> Self {
63        Self {
64            command: command.into(),
65            args_allow: Vec::new(),
66            args_deny: Vec::new(),
67        }
68    }
69
70    pub fn args_allow<I, S>(mut self, patterns: I) -> Self
71    where
72        I: IntoIterator<Item = S>,
73        S: Into<String>,
74    {
75        self.args_allow = patterns.into_iter().map(Into::into).collect();
76        self
77    }
78
79    pub fn args_deny<I, S>(mut self, patterns: I) -> Self
80    where
81        I: IntoIterator<Item = S>,
82        S: Into<String>,
83    {
84        self.args_deny = patterns.into_iter().map(Into::into).collect();
85        self
86    }
87}
88
89impl<'de> Deserialize<'de> for AllowedCommandRule {
90    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91    where
92        D: Deserializer<'de>,
93    {
94        #[derive(Deserialize)]
95        #[serde(untagged)]
96        enum Raw {
97            Bare(String),
98            Full {
99                command: String,
100                #[serde(default)]
101                args_allow: Vec<String>,
102                #[serde(default)]
103                args_deny: Vec<String>,
104            },
105        }
106        match Raw::deserialize(deserializer).map_err(de::Error::custom)? {
107            Raw::Bare(command) => Ok(Self::new(command)),
108            Raw::Full {
109                command,
110                args_allow,
111                args_deny,
112            } => Ok(Self {
113                command,
114                args_allow,
115                args_deny,
116            }),
117        }
118    }
119}
120
121/// Body shape shared by [`Client::get_allowed_commands`]/
122/// [`Client::set_allowed_commands`]. An empty `rules` means unrestricted --
123/// the default for every account.
124#[derive(Debug, Clone, Default, Deserialize, Serialize)]
125pub struct AllowedCommandsResponse {
126    #[serde(default)]
127    pub rules: Vec<AllowedCommandRule>,
128}
129
130impl Client {
131    /// `GET /v1/account` -- the account identity for the API key in use.
132    pub async fn account(&self) -> Result<Account, BoxxkiteError> {
133        let builder = self.request(Method::GET, "/v1/account");
134        self.send(builder).await
135    }
136
137    /// `GET /v1/usage` -- current usage against this account's fair-use
138    /// limits (the same shape returned inline on [`Client::create_sandbox`]).
139    pub async fn usage(&self) -> Result<UsageSummary, BoxxkiteError> {
140        let builder = self.request(Method::GET, "/v1/usage");
141        self.send(builder).await
142    }
143
144    /// `POST /v1/auth/password-reset/request` -- request a password-reset
145    /// email. Opt-in server-side (`BOXXKITE_PASSWORD_RESET_ENABLED`); 404s
146    /// with code `feature_disabled` if the deployment hasn't enabled it.
147    /// Always returns the same message whether or not the email is
148    /// registered, so it can't be used to enumerate accounts.
149    pub async fn request_password_reset(
150        &self,
151        email: &str,
152    ) -> Result<MessageResponse, BoxxkiteError> {
153        #[derive(Serialize)]
154        struct Body<'a> {
155            email: &'a str,
156        }
157        let builder = self
158            .request(Method::POST, "/v1/auth/password-reset/request")
159            .json(&Body { email });
160        self.send(builder).await
161    }
162
163    /// `POST /v1/auth/password-reset/confirm` -- consume a single-use token
164    /// from [`Client::request_password_reset`] and set a new password. Also
165    /// revokes every outstanding refresh token for the account, if refresh
166    /// tokens are enabled server-side.
167    pub async fn confirm_password_reset(
168        &self,
169        token: &str,
170        new_password: &str,
171    ) -> Result<MessageResponse, BoxxkiteError> {
172        #[derive(Serialize)]
173        struct Body<'a> {
174            token: &'a str,
175            new_password: &'a str,
176        }
177        let builder = self
178            .request(Method::POST, "/v1/auth/password-reset/confirm")
179            .json(&Body {
180                token,
181                new_password,
182            });
183        self.send(builder).await
184    }
185
186    /// `POST /v1/auth/verify-email` -- consume a single-use email-verification
187    /// token. Opt-in server-side (`BOXXKITE_EMAIL_VERIFICATION_ENABLED`).
188    pub async fn verify_email(&self, token: &str) -> Result<MessageResponse, BoxxkiteError> {
189        #[derive(Serialize)]
190        struct Body<'a> {
191            token: &'a str,
192        }
193        let builder = self
194            .request(Method::POST, "/v1/auth/verify-email")
195            .json(&Body { token });
196        self.send(builder).await
197    }
198
199    /// `POST /v1/auth/resend-verification` -- re-send the verification email
200    /// for the dashboard-JWT-authenticated account. `access_token` is a
201    /// dashboard session token (the JWT returned by `/v1/auth/login` or
202    /// `/v1/auth/signup`) -- a different, non-interchangeable credential type
203    /// from this client's own `api_key`, so it overrides this call's
204    /// `Authorization` header rather than using the client's key.
205    pub async fn resend_verification(
206        &self,
207        access_token: &str,
208    ) -> Result<MessageResponse, BoxxkiteError> {
209        let builder =
210            self.request_with_auth(Method::POST, "/v1/auth/resend-verification", access_token);
211        self.send(builder).await
212    }
213
214    /// `POST /v1/auth/refresh` -- exchange a still-valid refresh token for a
215    /// brand new `access_token` + `refresh_token` pair. Opt-in server-side
216    /// (`BOXXKITE_REFRESH_TOKENS_ENABLED`). Revokes the presented token in the
217    /// same request (rotation, not reuse) -- store the new `refresh_token`
218    /// from the response and discard the one presented here.
219    pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenPair, BoxxkiteError> {
220        #[derive(Serialize)]
221        struct Body<'a> {
222            refresh_token: &'a str,
223        }
224        let builder = self
225            .request(Method::POST, "/v1/auth/refresh")
226            .json(&Body { refresh_token });
227        self.send(builder).await
228    }
229
230    /// `POST /v1/auth/logout` -- revoke one refresh token immediately. Opt-in
231    /// server-side (`BOXXKITE_REFRESH_TOKENS_ENABLED`). Always succeeds (204)
232    /// whether or not the token was valid -- never leaks which.
233    pub async fn logout(&self, refresh_token: &str) -> Result<(), BoxxkiteError> {
234        #[derive(Serialize)]
235        struct Body<'a> {
236            refresh_token: &'a str,
237        }
238        let builder = self
239            .request(Method::POST, "/v1/auth/logout")
240            .json(&Body { refresh_token });
241        self.send_no_content(builder).await
242    }
243
244    /// `GET /v1/account/allowed-commands` -- the current per-account command
245    /// allowlist. An empty `rules` means unrestricted (the default). This
246    /// allowlist is an opt-in guardrail, not a sandbox-escape boundary.
247    pub async fn get_allowed_commands(&self) -> Result<AllowedCommandsResponse, BoxxkiteError> {
248        let builder = self.request(Method::GET, "/v1/account/allowed-commands");
249        self.send(builder).await
250    }
251
252    /// `PUT /v1/account/allowed-commands` -- replace the per-account command
253    /// allowlist wholesale. `rules` must be non-empty; use
254    /// [`Client::clear_allowed_commands`] to reset to unrestricted.
255    pub async fn set_allowed_commands(
256        &self,
257        rules: Vec<AllowedCommandRule>,
258    ) -> Result<AllowedCommandsResponse, BoxxkiteError> {
259        let body = AllowedCommandsResponse { rules };
260        let builder = self
261            .request(Method::PUT, "/v1/account/allowed-commands")
262            .json(&body);
263        self.send(builder).await
264    }
265
266    /// `DELETE /v1/account/allowed-commands` -- remove the per-account
267    /// command allowlist, back to the unrestricted default.
268    pub async fn clear_allowed_commands(&self) -> Result<(), BoxxkiteError> {
269        let builder = self.request(Method::DELETE, "/v1/account/allowed-commands");
270        self.send_no_content(builder).await
271    }
272}