Skip to main content

brep_app/plm/
client.rs

1//! Sign-in, the refusals, the change feed and the version check (plan S1).
2//!
3//! [`PlmClient`] speaks the PLM's API over any [`PlmTransport`]. It owns the
4//! credential, so a transport only carries bytes:
5//!
6//! - **A token** (`Authorization: Bearer plm_…`) for the native app, the bake
7//!   worker and tooling. A user pastes one, or [`PlmClient::sign_in_for_token`]
8//!   trades a username and password for one: it signs in, mints a token through
9//!   that session, and signs the session out again, so the machine keeps only
10//!   the token (D5).
11//! - **The session cookie** when the page is served by the PLM itself (D6). The
12//!   browser holds the cookie; the client sends `X-CSRF-Token` with every change,
13//!   read from `/api/me`'s `csrf`.
14//!
15//! Every refusal carries the sentence the server sent ([`PlmError`]). `204` is
16//! not one: [`PlmClient::get_document`] answers `None` for a revision with no
17//! document yet.
18//!
19//! **The version check** runs on every sign-in, before the client counts as
20//! connected. On a mismatch the sign-in fails with [`PlmError::Version`], which
21//! names both versions, and the credential is dropped. The file stores are
22//! untouched either way; nothing here knows they exist.
23use super::{PlmRequest, PlmResponse, PlmTransport};
24use serde::Deserialize;
25use serde_json::{json, Value};
26use std::cell::RefCell;
27use std::rc::Rc;
28
29/// Sign in with a configured token and compose the session's store backend
30/// (`plm::backend`): the native boot's one call. (The web boot signs in with
31/// the browser's session instead: `plm::backend::open_web_session`.)
32#[cfg(not(target_arch = "wasm32"))]
33pub(crate) use super::backend::open_session;
34
35/// This app's version, as the PLM's `min_client_version` is compared against.
36pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
37
38/// Why a call to the PLM did not do what was asked. Each variant keeps the
39/// server's own sentence; `Display` shows it as the user should read it.
40#[derive(Debug, Clone, PartialEq)]
41pub enum PlmError {
42    /// No answer at all: the server is down, the address is wrong, the network
43    /// dropped.
44    Unreachable(String),
45    /// `401`: the credential is missing, expired or revoked. Sign in again.
46    SignIn(String),
47    /// `403`: signed in, but the account or the token's scope may not do this.
48    Forbidden(String),
49    /// `404`: the part, revision or route does not exist.
50    NotFound(String),
51    /// `409`: locked by someone else, not checked out, or not editable. For a
52    /// document write it arrives after `write()` returned, through the store's
53    /// write-behind error lane.
54    Conflict(String),
55    /// `413`: over the server's attachment limit.
56    TooLarge(String),
57    /// `429`: the sign-in throttle.
58    Throttled(String),
59    /// Any other refusal (`400`, `5xx`), with its status.
60    Refused { status: u16, message: String },
61    /// The answer was not what the contract says (unparseable JSON, a missing
62    /// field).
63    Malformed(String),
64    /// Signed in, but this app and this server do not serve each other. Both
65    /// versions are named. The file stores stay usable; the boot that falls
66    /// back to them says so after this sentence.
67    Version { server: String, min_client: String, client: String, reason: String },
68}
69
70impl PlmError {
71    /// The HTTP status behind a refusal, if there was one.
72    pub fn status(&self) -> Option<u16> {
73        match self {
74            PlmError::SignIn(_) => Some(401),
75            PlmError::Forbidden(_) => Some(403),
76            PlmError::NotFound(_) => Some(404),
77            PlmError::Conflict(_) => Some(409),
78            PlmError::TooLarge(_) => Some(413),
79            PlmError::Throttled(_) => Some(429),
80            PlmError::Refused { status, .. } => Some(*status),
81            PlmError::Unreachable(_) | PlmError::Malformed(_) | PlmError::Version { .. } => None,
82        }
83    }
84
85    /// Map a non-success answer to its refusal, keeping the server's sentence
86    /// (`{"error": "..."}`, else the body text, else a sentence of our own).
87    pub fn from_response(response: &PlmResponse) -> Self {
88        let sentence = server_sentence(response);
89        let or = |fallback: &str| if sentence.is_empty() { fallback.to_string() } else { sentence.clone() };
90        match response.status {
91            401 => PlmError::SignIn(or("sign in again")),
92            403 => PlmError::Forbidden(or("you may not do that")),
93            404 => PlmError::NotFound(or("not found")),
94            409 => PlmError::Conflict(or("refused: the revision is locked or not editable")),
95            413 => PlmError::TooLarge(or("too large for the server's limit")),
96            429 => PlmError::Throttled(or("too many sign-in attempts; wait and try again")),
97            status => PlmError::Refused { status, message: or(&format!("the server answered {status}")) },
98        }
99    }
100}
101
102impl std::fmt::Display for PlmError {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            PlmError::Unreachable(why) => write!(f, "the PLM server did not answer: {why}"),
106            PlmError::SignIn(s) => write!(f, "sign in again: {s}"),
107            PlmError::Forbidden(s)
108            | PlmError::NotFound(s)
109            | PlmError::Conflict(s)
110            | PlmError::TooLarge(s)
111            | PlmError::Throttled(s) => f.write_str(s),
112            PlmError::Refused { message, .. } => f.write_str(message),
113            PlmError::Malformed(why) => write!(f, "the PLM server's answer was not understood: {why}"),
114            PlmError::Version { server, min_client, client, reason } => write!(
115                f,
116                "this CAD app ({client}) cannot use this PLM server ({server}, which serves CAD {min_client} and later): {reason}."
117            ),
118        }
119    }
120}
121
122/// `Accept` and the credential's headers: the bearer token, or the session
123/// cookie with the CSRF token on anything but a `GET`.
124fn credential_headers(credential: &Credential, method: &str) -> Vec<(String, String)> {
125    let mut headers = vec![("Accept".to_string(), "application/json".to_string())];
126    match credential {
127        Credential::Anonymous => {}
128        Credential::Token(token) => headers.push(("Authorization".into(), format!("Bearer {token}"))),
129        Credential::Session { cookie, csrf } => {
130            if let Some(cookie) = cookie {
131                headers.push(("Cookie".into(), cookie.clone()));
132            }
133            if method != "GET" {
134                if let Some(csrf) = csrf {
135                    headers.push(("X-CSRF-Token".into(), csrf.clone()));
136                }
137            }
138        }
139    }
140    headers
141}
142
143fn server_sentence(response: &PlmResponse) -> String {
144    if let Ok(v) = serde_json::from_slice::<Value>(&response.body) {
145        if let Some(s) = v.get("error").and_then(Value::as_str) {
146            return s.to_string();
147        }
148    }
149    String::from_utf8_lossy(&response.body).trim().chars().take(500).collect()
150}
151
152/// The PLM's `/api/me`, read tolerantly: fields this client does not know are
153/// ignored and a missing one takes its default.
154#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
155#[serde(default)]
156pub struct Me {
157    pub id: String,
158    pub username: String,
159    pub display_name: String,
160    pub groups: Vec<String>,
161    /// `"token"` or `"session"`.
162    pub via: Option<String>,
163    /// A session's CSRF token (session sign-ins only).
164    pub csrf: Option<String>,
165    /// P1: the server's version (`BREP_plm`'s own).
166    pub server_version: Option<String>,
167    /// P1: the oldest CAD app (`BREP_app` version) the server serves.
168    pub min_client_version: Option<String>,
169    /// What the server has beyond the base contract (`workspaces`,
170    /// `attribute-filters`, `external-ref`, …). Absent on a server that
171    /// predates the list: then it has none of them. Names are only added.
172    #[serde(default)]
173    pub features: Vec<String>,
174    /// Whether the server lets this account write (the author group, as the
175    /// server resolves it); absent from a server that does not say.
176    pub can_author: Option<bool>,
177}
178
179impl Me {
180    /// Whether the server says it has `feature`.
181    pub fn has(&self, feature: &str) -> bool {
182        self.features.iter().any(|f| f == feature)
183    }
184}
185
186/// How the client proves who it is.
187#[derive(Debug, Clone, PartialEq)]
188pub enum Credential {
189    Anonymous,
190    /// `Authorization: Bearer <token>`.
191    Token(String),
192    /// A session: the browser holds the cookie on the web (`cookie: None`); a
193    /// native password sign-in carries it itself, only long enough to mint a
194    /// token. `csrf` goes on every change.
195    Session { cookie: Option<String>, csrf: Option<String> },
196}
197
198/// `POST /api/desktop/link`'s answer. `link_token` goes in the URL the
199/// browser opens (`path`); `poll_secret` never leaves the app.
200#[derive(Debug, Clone, PartialEq, Deserialize)]
201pub struct DesktopLink {
202    pub link_token: String,
203    pub poll_secret: String,
204    /// The short code the app shows and the browser page repeats.
205    pub code: String,
206    /// The link page, relative to the server (`/desktop-link?token=…`).
207    pub path: String,
208    pub expires_in: u64,
209    pub interval: u64,
210}
211
212/// What a poll of a desktop link found.
213#[derive(Debug, Clone, PartialEq)]
214pub enum LinkPoll {
215    Pending,
216    Denied,
217    /// Approved: an API token of this machine's own.
218    Approved(String),
219    /// Expired, already collected, or never known.
220    Gone,
221}
222
223/// `GET /api/store/index`: the store's metadata, one row per revision with a
224/// document key. This is the seam's `load_index`.
225#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
226#[serde(default)]
227pub struct StoreIndex {
228    pub seq: u64,
229    pub entries: Vec<IndexEntry>,
230}
231
232#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
233#[serde(default)]
234pub struct IndexEntry {
235    pub key: String,
236    pub part_id: String,
237    pub part_number: String,
238    pub part_name: String,
239    pub revision_id: String,
240    pub revision_label: String,
241    pub lifecycle: String,
242    pub editable: bool,
243    pub content_hash: String,
244    pub size: u64,
245    pub modified: i64,
246    pub locked_by: Option<String>,
247    pub locked_by_me: bool,
248    /// P3; absent until the server sends it.
249    pub document_class: Option<String>,
250}
251
252/// `PUT /api/store/doc/*key`'s answer.
253#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
254#[serde(default)]
255pub struct PutAck {
256    pub seq: u64,
257    pub content_hash: String,
258    pub size: u64,
259    /// A server with `derived-uses` sets the revision's uses list from the
260    /// document in the same write; this says why it did not, when it did not.
261    pub uses_refused: Option<String>,
262}
263
264/// `GET /api/store/changes?since=`.
265#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
266#[serde(default)]
267pub struct Changes {
268    pub seq: u64,
269    /// The client asked from further back than the log reaches: `keys` is NOT
270    /// complete, and the index must be re-read.
271    pub stale: bool,
272    pub keys: Vec<String>,
273}
274
275/// One PLM server, and who the app is on it.
276pub struct PlmClient {
277    transport: Rc<dyn PlmTransport>,
278    credential: RefCell<Credential>,
279    me: RefCell<Option<Me>>,
280    /// Requests sent since the client was made: what an operation costs the
281    /// server, read before and after it (`__brepPlmDoc.requestsSent`).
282    sent: std::cell::Cell<u64>,
283    /// The same, by route ([`route_of`]): which requests an operation costs.
284    by_route: RefCell<std::collections::BTreeMap<String, u64>>,
285}
286
287/// A request's route for the tally: the method and the path with each
288/// segment that holds a digit (an id, a number, a revision key) as `*`, and
289/// no query (`PUT /api/store/doc/part/*/rev/A`).
290pub fn route_of(method: &str, path: &str) -> String {
291    let path = path.split('?').next().unwrap_or(path);
292    let segments: Vec<&str> = path.split('/').map(|s| if s.chars().any(|c| c.is_ascii_digit()) { "*" } else { s }).collect();
293    format!("{method} {}", segments.join("/"))
294}
295
296impl PlmClient {
297    pub fn new(transport: Rc<dyn PlmTransport>) -> Self {
298        Self {
299            transport,
300            credential: RefCell::new(Credential::Anonymous),
301            me: RefCell::new(None),
302            sent: std::cell::Cell::new(0),
303            by_route: RefCell::new(Default::default()),
304        }
305    }
306
307    /// Requests this client has sent (see the field).
308    pub fn requests_sent(&self) -> u64 {
309        self.sent.get()
310    }
311
312
313    /// Requests sent, by route ([`route_of`]).
314    pub fn requests_by_route(&self) -> std::collections::BTreeMap<String, u64> {
315        self.by_route.borrow().clone()
316    }
317
318    fn count(&self, method: &str, path: &str) {
319        self.sent.set(self.sent.get() + 1);
320        *self.by_route.borrow_mut().entry(route_of(method, path)).or_default() += 1;
321    }
322
323    /// Who the client is signed in as, once a sign-in has passed the version
324    /// check.
325    pub fn me(&self) -> Option<Me> {
326        self.me.borrow().clone()
327    }
328
329    /// `Some(false)`: the signed-in account may read the PLM but not write to
330    /// it, so a Save to it can only be refused. `None` until the server said.
331    pub fn can_author(&self) -> Option<bool> {
332        self.me.borrow().as_ref().and_then(|me| me.can_author)
333    }
334
335    pub fn credential(&self) -> Credential {
336        self.credential.borrow().clone()
337    }
338
339    /// Take `token` as the credential with no request: one the caller has
340    /// just seen the server accept for the same user (a sign-in again after
341    /// the session's token was revoked), so the session goes on with its open
342    /// documents instead of switching stores.
343    pub fn replace_token(&self, token: &str) {
344        *self.credential.borrow_mut() = Credential::Token(token.trim().to_string());
345    }
346
347    /// Forget the credential locally. (A token stays valid on the server; the
348    /// user revokes it on the PLM's page.)
349    pub fn forget(&self) {
350        *self.credential.borrow_mut() = Credential::Anonymous;
351        *self.me.borrow_mut() = None;
352    }
353
354    /// One call with the current credential. Every status comes back `Ok`;
355    /// only a request that got no answer is an `Err`.
356    pub async fn raw(&self, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
357        let credential = self.credential.borrow().clone();
358        self.send_as(&credential, method, path, body).await
359    }
360
361    async fn send_as(
362        &self,
363        credential: &Credential,
364        method: &'static str,
365        path: &str,
366        body: Option<Vec<u8>>,
367    ) -> Result<PlmResponse, PlmError> {
368        let mut headers = credential_headers(credential, method);
369        if body.as_deref().is_some_and(|b| serde_json::from_slice::<Value>(b).is_ok()) {
370            headers.push(("Content-Type".into(), "application/json".into()));
371        }
372        self.count(method, path);
373        let request = PlmRequest { method, path: path.to_string(), headers, body: body.unwrap_or_default() };
374        self.transport.send(request).await.map_err(PlmError::Unreachable)
375    }
376
377    /// A 2xx-or-refusal call carrying `body` as `content_type`: a file
378    /// upload, whose media type the server keeps from this header (an
379    /// attachment sent without one is stored as `application/octet-stream`).
380    pub(crate) async fn call_typed(
381        &self,
382        method: &'static str,
383        path: &str,
384        body: Vec<u8>,
385        content_type: &str,
386    ) -> Result<PlmResponse, PlmError> {
387        let credential = self.credential.borrow().clone();
388        let mut headers = credential_headers(&credential, method);
389        headers.push(("Content-Type".into(), content_type.to_string()));
390        self.count(method, path);
391        let request = PlmRequest { method, path: path.to_string(), headers, body };
392        let response = self.transport.send(request).await.map_err(PlmError::Unreachable)?;
393        if (200..300).contains(&response.status) {
394            Ok(response)
395        } else {
396            Err(PlmError::from_response(&response))
397        }
398    }
399
400    /// A call whose answer must be 2xx.
401    pub(crate) async fn call(&self, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
402        let response = self.raw(method, path, body).await?;
403        if (200..300).contains(&response.status) {
404            Ok(response)
405        } else {
406            Err(PlmError::from_response(&response))
407        }
408    }
409
410    async fn call_json<T: for<'de> Deserialize<'de>>(
411        &self,
412        method: &'static str,
413        path: &str,
414        body: Option<Value>,
415    ) -> Result<T, PlmError> {
416        let response = self.call(method, path, body.map(|b| serde_json::to_vec(&b).unwrap_or_default())).await?;
417        parse(&response, path)
418    }
419
420    // ---------------------------------------------------------------- sign-in
421
422    /// Sign in with a token (pasted, or read from `plm-token`). Passes only if
423    /// the server accepts the token AND its version serves this app.
424    pub async fn sign_in_with_token(&self, token: &str) -> Result<Me, PlmError> {
425        let token = token.trim();
426        if token.is_empty() {
427            return Err(PlmError::SignIn("paste a token (it starts with plm_)".into()));
428        }
429        self.adopt(Credential::Token(token.to_string())).await
430    }
431
432    /// The page is served by the PLM and the browser already holds a session
433    /// cookie (D6): adopt it, and read the CSRF token changes must carry.
434    pub async fn sign_in_with_browser_session(&self) -> Result<Me, PlmError> {
435        self.adopt(Credential::Session { cookie: None, csrf: None }).await
436    }
437
438    /// Sign in with a username and password as a SESSION (the web lane, D6).
439    pub async fn sign_in_with_password(&self, username: &str, password: &str) -> Result<Me, PlmError> {
440        let cookie = self.log_in(username, password).await?;
441        self.adopt(Credential::Session { cookie, csrf: None }).await
442    }
443
444    /// Trade a username and password for a token (the native lane, D5): sign
445    /// in, mint a `full` token named `token_name` through that session, sign
446    /// the session out, and sign in with the token. Returns the token for the
447    /// caller to keep (`plm::config::write_token`); the password is not kept.
448    pub async fn sign_in_for_token(&self, username: &str, password: &str, token_name: &str) -> Result<(Me, String), PlmError> {
449        let cookie = self.log_in(username, password).await?;
450        let session = Credential::Session { cookie, csrf: None };
451        let me: Me = parse(&self.checked(&session, "GET", "/api/me", None).await?, "/api/me")?;
452        let session = Credential::Session { cookie: session_cookie(&session), csrf: me.csrf.clone() };
453        // Refuse BEFORE minting: a server that does not serve this app must not
454        // be left holding a fresh token nobody will use.
455        if let Err(refused) = check_version(&me) {
456            let _ = self.send_as(&session, "POST", "/api/logout", Some(b"{}".to_vec())).await;
457            return Err(refused);
458        }
459        let body = serde_json::to_vec(&json!({ "name": token_name, "scope": "full" })).unwrap_or_default();
460        let minted: Value = parse(&self.checked(&session, "POST", "/api/tokens", Some(body)).await?, "/api/tokens")?;
461        let token = minted
462            .get("token")
463            .and_then(Value::as_str)
464            .ok_or_else(|| PlmError::Malformed("/api/tokens answered without a token".into()))?
465            .to_string();
466        // The session was only a way to mint the token; do not leave it open.
467        let _ = self.send_as(&session, "POST", "/api/logout", Some(b"{}".to_vec())).await;
468        let me = self.sign_in_with_token(&token).await?;
469        Ok((me, token))
470    }
471
472    /// `POST /api/desktop/link`: ask the server for a browser sign-in link
473    /// (the native lane's frictionless sign-in). No credential is sent.
474    pub async fn register_desktop_link(&self, host: &str, os: &str) -> Result<DesktopLink, PlmError> {
475        let body = serde_json::to_vec(&json!({ "host": host, "os": os, "app_version": CLIENT_VERSION })).unwrap_or_default();
476        let path = "/api/desktop/link";
477        parse(&self.checked(&Credential::Anonymous, "POST", path, Some(body)).await?, path)
478    }
479
480    /// `POST /api/desktop/link/poll`: what became of the link `poll_secret`
481    /// belongs to. A link the server no longer knows (expired, used) is
482    /// [`LinkPoll::Gone`], not an error.
483    pub async fn poll_desktop_link(&self, poll_secret: &str) -> Result<LinkPoll, PlmError> {
484        let body = serde_json::to_vec(&json!({ "poll_secret": poll_secret })).unwrap_or_default();
485        let path = "/api/desktop/link/poll";
486        let response = self.send_as(&Credential::Anonymous, "POST", path, Some(body)).await?;
487        if response.status == 404 {
488            return Ok(LinkPoll::Gone);
489        }
490        if !(200..300).contains(&response.status) {
491            return Err(PlmError::from_response(&response));
492        }
493        let answer: Value = parse(&response, path)?;
494        match answer.get("status").and_then(Value::as_str) {
495            Some("pending") => Ok(LinkPoll::Pending),
496            Some("denied") => Ok(LinkPoll::Denied),
497            Some("approved") => match answer.get("token").and_then(Value::as_str) {
498                Some(token) => Ok(LinkPoll::Approved(token.to_string())),
499                None => Err(PlmError::Malformed(format!("{path} approved without a token"))),
500            },
501            other => Err(PlmError::Malformed(format!("{path} answered status {other:?}"))),
502        }
503    }
504
505    /// `POST /api/login`. The cookie comes back when the transport can see
506    /// `Set-Cookie` (native); in a browser it cannot, and need not — the
507    /// browser keeps it.
508    async fn log_in(&self, username: &str, password: &str) -> Result<Option<String>, PlmError> {
509        let body = serde_json::to_vec(&json!({ "username": username, "password": password })).unwrap_or_default();
510        let response = self.checked(&Credential::Anonymous, "POST", "/api/login", Some(body)).await?;
511        Ok(response
512            .headers
513            .iter()
514            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
515            .and_then(|(_, v)| v.split(';').next())
516            .map(|c| c.trim().to_string()))
517    }
518
519    /// A call with an explicit credential whose answer must be 2xx.
520    async fn checked(&self, credential: &Credential, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
521        let response = self.send_as(credential, method, path, body).await?;
522        if (200..300).contains(&response.status) {
523            Ok(response)
524        } else {
525            Err(PlmError::from_response(&response))
526        }
527    }
528
529    /// Read `/api/me` under `credential`, run the version check, and only then
530    /// keep the credential.
531    async fn adopt(&self, credential: Credential) -> Result<Me, PlmError> {
532        let me: Me = parse(&self.checked(&credential, "GET", "/api/me", None).await?, "/api/me")?;
533        check_version(&me)?;
534        let credential = match credential {
535            Credential::Session { cookie, .. } => Credential::Session { cookie, csrf: me.csrf.clone() },
536            other => other,
537        };
538        *self.credential.borrow_mut() = credential;
539        *self.me.borrow_mut() = Some(me.clone());
540        Ok(me)
541    }
542
543    // ------------------------------------------------------------ the store
544
545    /// `GET /api/store/index`.
546    pub async fn index(&self) -> Result<StoreIndex, PlmError> {
547        self.call_json("GET", "/api/store/index", None).await
548    }
549
550    /// `GET /api/store/index?keys=…`: only those revisions' rows (a key that
551    /// names no revision any more is absent from the answer).
552    pub async fn index_rows(&self, keys: &[String]) -> Result<StoreIndex, PlmError> {
553        self.call_json("GET", &format!("/api/store/index?keys={}", keys.iter().map(|k| crate::plm::identity::segment(k)).collect::<Vec<_>>().join(",")), None).await
554    }
555
556    /// `GET /api/store/doc/<key>`: the bytes, or `None` when the revision has
557    /// no document yet (`204`). A revision that does not exist is
558    /// [`PlmError::NotFound`].
559    pub async fn get_document(&self, key: &str) -> Result<Option<Vec<u8>>, PlmError> {
560        let response = self.call("GET", &doc_path(key), None).await?;
561        Ok(if response.status == 204 { None } else { Some(response.body) })
562    }
563
564    /// `PUT /api/store/doc/<key>`. Needs the lock and an editable revision;
565    /// otherwise [`PlmError::Conflict`] with the server's sentence.
566    pub async fn put_document(&self, key: &str, bytes: Vec<u8>) -> Result<PutAck, PlmError> {
567        let path = doc_path(key);
568        let response = self.call("PUT", &path, Some(bytes)).await?;
569        parse(&response, &path)
570    }
571
572    /// [`Self::put_document`] only over the document whose content hash is
573    /// `expected` (`If-Match`): a save based on that document. One the
574    /// server holds a newer document for is [`PlmError::Conflict`], naming
575    /// when it changed, and writes nothing.
576    pub async fn put_document_if(&self, key: &str, bytes: Vec<u8>, expected: &str) -> Result<PutAck, PlmError> {
577        let path = doc_path(key);
578        let credential = self.credential.borrow().clone();
579        let mut headers = credential_headers(&credential, "PUT");
580        headers.push(("Content-Type".into(), "application/json".into()));
581        headers.push(("If-Match".into(), format!("\"{expected}\"")));
582        self.count("PUT", &path);
583        let request = PlmRequest { method: "PUT", path: path.clone(), headers, body: bytes };
584        let response = self.transport.send(request).await.map_err(PlmError::Unreachable)?;
585        if !(200..300).contains(&response.status) {
586            return Err(PlmError::from_response(&response));
587        }
588        parse(&response, &path)
589    }
590
591    /// `DELETE /api/store/doc/<key>`, under the same two conditions as a write.
592    pub async fn delete_document(&self, key: &str) -> Result<(), PlmError> {
593        self.call("DELETE", &doc_path(key), None).await.map(|_| ())
594    }
595
596    // ------------------------------------------------------ preferences (P5)
597
598    /// `GET /api/me/preferences/<name>` (`@settings`, …): the value, or `None`
599    /// when the user has never set it. Unset is `204 No Content` (server B,
600    /// 2026-09-25: a hosted page's console logs every 404) and was `404`
601    /// before; both read as unset.
602    pub async fn get_preference(&self, name: &str) -> Result<Option<String>, PlmError> {
603        match self.call("GET", &preference_path(name), None).await {
604            Ok(response) if response.status == 204 => Ok(None),
605            Ok(response) => String::from_utf8(response.body)
606                .map(Some)
607                .map_err(|_| PlmError::Malformed(format!("preference {name} is not UTF-8"))),
608            Err(PlmError::NotFound(_)) => Ok(None),
609            Err(e) => Err(e),
610        }
611    }
612
613    /// `PUT /api/me/preferences/<name>` with the value, which must be JSON.
614    pub async fn put_preference(&self, name: &str, value: String) -> Result<(), PlmError> {
615        self.call("PUT", &preference_path(name), Some(value.into_bytes())).await.map(|_| ())
616    }
617
618    /// `DELETE /api/me/preferences/<name>`; deleting an unset one is fine.
619    pub async fn delete_preference(&self, name: &str) -> Result<(), PlmError> {
620        self.call("DELETE", &preference_path(name), None).await.map(|_| ())
621    }
622
623    /// `GET /api/store/changes?since=<seq>`.
624    pub async fn changes(&self, since: u64) -> Result<Changes, PlmError> {
625        self.call_json("GET", &format!("/api/store/changes?since={since}"), None).await
626    }
627}
628
629/// What one poll of the change feed found.
630#[derive(Debug, Clone, PartialEq)]
631pub enum FeedUpdate {
632    /// Nothing changed since the last poll.
633    Quiet,
634    /// These keys changed (in the server's order, each once).
635    Keys(Vec<String>),
636    /// The feed could not say what changed (the first poll, or the client
637    /// fell behind the log): here is the whole index instead.
638    Reindexed(StoreIndex),
639}
640
641/// The store's change feed: poll `?since=`, and re-read the index whenever
642/// the answer is `stale`. The first poll reads the index, which is also where
643/// the feed's position starts.
644#[derive(Debug, Clone, Default)]
645pub struct ChangeFeed {
646    since: Option<u64>,
647}
648
649impl ChangeFeed {
650    pub fn new() -> Self {
651        Self::default()
652    }
653
654    /// Start from a position the caller already holds (an index it read).
655    pub fn starting_at(seq: u64) -> Self {
656        Self { since: Some(seq) }
657    }
658
659    pub fn position(&self) -> Option<u64> {
660        self.since
661    }
662
663    pub async fn poll(&mut self, client: &PlmClient) -> Result<FeedUpdate, PlmError> {
664        let Some(since) = self.since else {
665            let index = client.index().await?;
666            self.since = Some(index.seq);
667            return Ok(FeedUpdate::Reindexed(index));
668        };
669        let changes = client.changes(since).await?;
670        if changes.stale {
671            let index = client.index().await?;
672            self.since = Some(index.seq);
673            return Ok(FeedUpdate::Reindexed(index));
674        }
675        self.since = Some(changes.seq.max(since));
676        let mut keys = Vec::new();
677        for key in changes.keys {
678            if !keys.contains(&key) {
679                keys.push(key);
680            }
681        }
682        Ok(if keys.is_empty() { FeedUpdate::Quiet } else { FeedUpdate::Keys(keys) })
683    }
684}
685
686/// P1: refuse a server that does not serve this app. The rule is the
687/// server's own (`brep_plm::version::serves`, mirrored exactly and pinned
688/// against it by a test): this app's version must parse and be at least
689/// `min_client_version`, and a version that does not parse is not served. A
690/// server that reports no version predates the handshake and is refused too:
691/// it cannot promise the contracts this client reads.
692pub fn check_version(me: &Me) -> Result<(), PlmError> {
693    check_version_of(me, CLIENT_VERSION)
694}
695
696fn check_version_of(me: &Me, client: &str) -> Result<(), PlmError> {
697    let server = me.server_version.as_deref().unwrap_or("a version it does not report");
698    let refuse = |min: &str, reason: String| PlmError::Version {
699        server: server.to_string(),
700        min_client: min.to_string(),
701        client: client.to_string(),
702        reason,
703    };
704    let Some(min) = me.min_client_version.as_deref() else {
705        return Err(refuse("?", "the server does not say which CAD versions it serves; it is older than this app".into()));
706    };
707    if !serves(min, client) {
708        return Err(refuse(min, format!("update this CAD app to {min} or later")));
709    }
710    Ok(())
711}
712
713/// `MAJOR.MINOR.PATCH` as numbers, a `-pre` / `+build` suffix ignored; exactly
714/// three fields. The same parse as `brep_plm::version::parse`.
715pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
716    let core = version.trim().split(['-', '+']).next()?;
717    let mut fields = core.split('.').map(|f| f.parse::<u64>().ok());
718    let triple = (fields.next()??, fields.next()??, fields.next()??);
719    fields.next().is_none().then_some(triple)
720}
721
722/// Whether a server whose oldest served client is `min_client_version` serves
723/// a client at `client_version` (`brep_plm::version::serves`).
724pub fn serves(min_client_version: &str, client_version: &str) -> bool {
725    match (parse_version(min_client_version), parse_version(client_version)) {
726        (Some(min), Some(client)) => client >= min,
727        _ => false,
728    }
729}
730
731use crate::plm::identity::doc_path;
732
733fn preference_path(name: &str) -> String {
734    format!("/api/me/preferences/{name}")
735}
736
737fn session_cookie(credential: &Credential) -> Option<String> {
738    match credential {
739        Credential::Session { cookie, .. } => cookie.clone(),
740        _ => None,
741    }
742}
743
744fn parse<T: for<'de> Deserialize<'de>>(response: &PlmResponse, what: &str) -> Result<T, PlmError> {
745    serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("{what}: {e}")))
746}
747