wavekat_platform_client/me.rs
1//! `/api/me` — the typed shape of the signed-in user.
2//!
3//! Public because every consumer needs it: the CLI prints it after
4//! `wk login`/`wk me`, and the desktop daemon shows the same fields in
5//! its Platform settings page. Keeping the struct here (and re-exported
6//! from the crate root) means consumers don't redefine it.
7
8use serde::{Deserialize, Serialize};
9
10use crate::client::Client;
11use crate::error::Result;
12
13/// The signed-in user, as returned by `GET /api/me`.
14#[derive(Debug, Clone, Deserialize)]
15pub struct Me {
16 /// Opaque platform user id. A UUID string since wavekat-platform
17 /// switched `users.id` off integer surrogate keys (which leaked the
18 /// signup count). Treat as an opaque token — never parse or compare
19 /// numerically.
20 pub id: String,
21 pub login: String,
22 pub name: Option<String>,
23 pub email: Option<String>,
24 pub role: String,
25}
26
27/// Counts of what deleting the signed-in account would take, as
28/// returned by `GET /api/me/deletion-preview`.
29///
30/// Public for the same reason [`Me`] is: every consumer shows these
31/// before asking for confirmation, so nobody confirms an irreversible
32/// action without seeing what is in it. Counts are of the account's
33/// *own* content — anything it authored inside someone else's project
34/// is not counted, because the platform's purge will not take it.
35///
36/// `Serialize` as well as `Deserialize` because a consumer is rarely
37/// the thing that displays these: the desktop daemon fetches them and
38/// forwards them to its own renderer, and without a way to write them
39/// back out every consumer would redefine the struct to do it.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct DeletionPreview {
43 /// Phone lines whose settings were backed up to the account. Named
44 /// `accounts` on the wire because the platform calls them voice
45 /// accounts; to the person confirming, they are their saved lines.
46 pub accounts: u32,
47 pub calls: u32,
48 pub recordings: u32,
49 pub transcripts: u32,
50 /// Share links that still resolve. Already-revoked ones are not
51 /// counted — they are gone as far as the holder of the link is
52 /// concerned.
53 pub shares: u32,
54 pub flows: u32,
55 pub contacts: u32,
56 pub prompts: u32,
57 pub projects: u32,
58 /// Label sets that nothing else still references. One another
59 /// project's labelling still uses survives, so this is not simply
60 /// "sets this account created".
61 pub label_sets: u32,
62 pub files: u32,
63 pub annotations: u32,
64 pub exports: u32,
65 pub models: u32,
66}
67
68impl Client {
69 /// Fetch the signed-in user from `/api/me`. The canonical way to
70 /// verify a freshly-minted token is reachable.
71 pub async fn whoami(&self) -> Result<Me> {
72 self.get_json("/api/me").await
73 }
74
75 /// Revoke the bearer token this client is using. After this returns
76 /// successfully, the same token will start producing 401s — drop the
77 /// `Client` (and clear whatever storage held the token).
78 pub async fn revoke_current_token(&self) -> Result<()> {
79 self.post_empty("/api/auth/cli/tokens/revoke-current").await
80 }
81
82 /// Fetch what deleting this account would take, for the
83 /// confirmation a consumer shows before calling
84 /// [`Client::delete_account`].
85 pub async fn deletion_preview(&self) -> Result<DeletionPreview> {
86 self.get_json("/api/me/deletion-preview").await
87 }
88
89 /// Delete the signed-in account.
90 ///
91 /// Credentials, live share links and the profile go immediately and
92 /// the account stops working at once; the remaining content and
93 /// stored audio are purged by the platform within 30 days. There is
94 /// no undo and no cancel, and signing in again with the same
95 /// provider identity is refused until that purge completes.
96 ///
97 /// Requires a credential minted in the last few minutes — otherwise
98 /// this returns [`Error::ReauthRequired`], and the caller should
99 /// sign in again and retry rather than treat the token as dead.
100 /// Once it succeeds, the token this client holds is gone: drop the
101 /// `Client` and clear whatever storage held it.
102 ///
103 /// [`Error::ReauthRequired`]: crate::Error::ReauthRequired
104 pub async fn delete_account(&self) -> Result<()> {
105 self.delete("/api/me").await
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn deletion_preview_reads_the_platform_wire_shape() {
115 // Verbatim from `GET /api/me/deletion-preview`. The desktop
116 // dialog renders these counts before an irreversible action, so
117 // a field renamed on the platform has to fail here rather than
118 // silently show a zero.
119 let json = r#"{
120 "accounts": 2, "calls": 6, "recordings": 1, "transcripts": 4,
121 "shares": 0, "flows": 3, "contacts": 9, "prompts": 2,
122 "projects": 1, "labelSets": 0, "files": 5, "annotations": 90,
123 "exports": 1, "models": 1
124 }"#;
125 let p: DeletionPreview = serde_json::from_str(json).unwrap();
126 assert_eq!(p.accounts, 2);
127 assert_eq!(p.calls, 6);
128 assert_eq!(p.label_sets, 0);
129 assert_eq!(p.recordings, 1);
130 assert_eq!(p.annotations, 90);
131 assert_eq!(p.models, 1);
132 }
133
134 #[test]
135 fn deletion_preview_round_trips_for_a_consumer_to_forward() {
136 // The daemon deserializes the platform's answer and serializes
137 // it again for its renderer; losing a count in the middle would
138 // understate what a delete takes.
139 let p: DeletionPreview = serde_json::from_str(
140 r#"{"accounts":2,"calls":6,"recordings":1,"transcripts":4,"shares":0,
141 "flows":3,"contacts":9,"prompts":2,"projects":1,"labelSets":0,
142 "files":5,"annotations":90,"exports":1,"models":1}"#,
143 )
144 .unwrap();
145 let back: DeletionPreview =
146 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
147 assert_eq!(back.calls, 6);
148 assert_eq!(back.annotations, 90);
149 }
150
151 #[test]
152 fn deletion_preview_needs_every_count() {
153 // Missing fields must be an error, not a default zero: "nothing
154 // will be deleted" is the one wrong answer this dialog can give.
155 let err = serde_json::from_str::<DeletionPreview>(r#"{"calls": 6}"#);
156 assert!(err.is_err(), "expected a decode error, got {err:?}");
157 }
158}