diff --git a/CLAUDE.md b/CLAUDE.md index 7065cfa..912d569 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Make "talk to the WaveKat platform from Rust" a solved problem so each consumer - Apache-2.0 licensed; matches `wavekat-cli` and `wavekat-core`. - Workspace layout: root `Cargo.toml` is `[workspace]`, real crate lives in `crates/wavekat-platform-client/`. Lets us add focused sub-crates later (e.g. `wavekat-platform-client-mock` for downstream test fixtures) without restructuring. -- `wkcli_…` is today's token prefix — historical from when only `wk` minted tokens. Don't rename the field, even when other clients start using it; the prefix is just a string. +- Bearer tokens use the `wk_…` prefix. (Was `wkcli_` while the CLI was the only consumer; renamed in the platform before any real users existed, see wavekat-platform PR #116.) The prefix is just a visual marker — cryptographic strength is in the entropy after it. ## Related repos diff --git a/Cargo.toml b/Cargo.toml index aba5de7..db28446 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,4 @@ resolver = "2" version = "0.0.1" edition = "2021" license = "Apache-2.0" +rust-version = "1.75" diff --git a/README.md b/README.md index 813dba9..7b64f66 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ This crate is the one place that knows how to talk to `platform.wavekat.com`. Ea ## Design - **Zero opinion on storage.** The crate exposes a `Client::new(base_url, token)` constructor. Consumers load the token from wherever fits — keychain, file, env var, in-memory test fixture — and hand it in. -- **Single bearer token shape**: `wkcli_…` issued by `POST /api/auth/cli/tokens`. The "cli" prefix is historical; the platform mints the same kind of token for any caller that completes the loopback OAuth flow. +- **Single bearer token shape**: `wk_…` issued by `POST /api/auth/cli/tokens`. The route path is historical (the CLI was the only consumer originally); the platform mints the same kind of token for any caller that completes the loopback OAuth flow. - **No async runtime opinion in the surface** — uses `reqwest` async with whatever runtime the consumer brings (tokio in practice). ## License diff --git a/crates/wavekat-platform-client/Cargo.toml b/crates/wavekat-platform-client/Cargo.toml index af97006..d217363 100644 --- a/crates/wavekat-platform-client/Cargo.toml +++ b/crates/wavekat-platform-client/Cargo.toml @@ -4,6 +4,7 @@ description = "Rust client for the WaveKat platform — auth, sessions, artifact version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true repository = "https://github.com/wavekat/wavekat-platform-client" homepage = "https://github.com/wavekat/wavekat-platform-client" documentation = "https://docs.rs/wavekat-platform-client" @@ -13,8 +14,26 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = ["CHANGELOG.md"] [dependencies] +# HTTP client. rustls-tls keeps us off OpenSSL across platforms; json + gzip +# match what wavekat-cli already vetted; stream is needed for get_stream_to. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +rand = "0.8" +thiserror = "1" +# `time` for the handshake timeout; `sync` for the oneshot used to hand +# the token off the blocking accept thread. Deliberately NOT enabling +# `rt-multi-thread` or `macros` — let the consumer pick the runtime. +tokio = { version = "1", default-features = false, features = ["time", "sync", "rt"] } +futures-util = "0.3" [dev-dependencies] +tokio = { version = "1", default-features = false, features = ["macros", "rt-multi-thread", "time", "sync"] } +# `examples/smoke.rs` is a small CLI-shaped binary for the manual smoke +# test (see docs/01-initial-port.md). The library itself does not depend +# on `webbrowser` — the example just uses it for convenience. +webbrowser = "1" [package.metadata.docs.rs] all-features = true diff --git a/crates/wavekat-platform-client/examples/smoke.rs b/crates/wavekat-platform-client/examples/smoke.rs new file mode 100644 index 0000000..547a26d --- /dev/null +++ b/crates/wavekat-platform-client/examples/smoke.rs @@ -0,0 +1,112 @@ +//! Manual smoke test for the platform client. Not in CI — needs a +//! reachable platform and (for `login`) a human at a browser. +//! +//! Usage: +//! +//! cargo run --example smoke -- login +//! cargo run --example smoke -- whoami --token wk_xxx +//! cargo run --example smoke -- revoke --token wk_xxx +//! +//! The base URL defaults to `https://platform.wavekat.com`; override +//! with `--base-url` or `WK_BASE_URL`. + +use std::env; +use std::process::ExitCode; + +use wavekat_platform_client::{loopback_handshake, Client, HandshakeOptions, Token}; + +const DEFAULT_BASE_URL: &str = "https://platform.wavekat.com"; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(r) => r, + Err(e) => { + eprintln!("failed to start tokio runtime: {e}"); + return ExitCode::from(1); + } + }; + match rt.block_on(run(args)) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::from(1) + } + } +} + +async fn run(args: Vec) -> Result<(), Box> { + let mut iter = args.into_iter(); + let cmd = iter + .next() + .ok_or("missing subcommand: login | whoami | revoke")?; + + let mut token: Option = None; + let mut base_url: Option = None; + while let Some(flag) = iter.next() { + match flag.as_str() { + "--token" => token = iter.next(), + "--base-url" => base_url = iter.next(), + other => return Err(format!("unknown flag: {other}").into()), + } + } + let base_url = base_url + .or_else(|| env::var("WK_BASE_URL").ok()) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + + match cmd.as_str() { + "login" => login(&base_url).await, + "whoami" => { + let t = token.ok_or("whoami requires --token")?; + whoami(&base_url, t).await + } + "revoke" => { + let t = token.ok_or("revoke requires --token")?; + revoke(&base_url, t).await + } + other => Err(format!("unknown subcommand: {other}").into()), + } +} + +async fn login(base_url: &str) -> Result<(), Box> { + let pending = loopback_handshake(base_url, HandshakeOptions::default())?; + let url = pending.url().to_string(); + println!("Opening {base_url} in your browser to sign in…"); + if let Err(e) = webbrowser::open(&url) { + eprintln!("(couldn't open the browser automatically: {e})"); + println!("Open this URL manually:\n {url}\n"); + } else { + println!("If it didn't open, use:\n {url}\n"); + } + println!("Waiting for the browser to redirect back (Ctrl-C to cancel)…"); + let outcome = pending.wait().await?; + println!("Got token: {:?}", outcome.token); + if let Some(login) = &outcome.login { + println!("Login (echoed from platform): {login}"); + } + let client = Client::new(base_url, outcome.token)?; + let me = client.whoami().await?; + println!("Signed in as {} ({}, role: {})", me.login, me.id, me.role); + Ok(()) +} + +async fn whoami(base_url: &str, token: String) -> Result<(), Box> { + let client = Client::new(base_url, Token::new(token))?; + let me = client.whoami().await?; + println!("login: {}", me.login); + println!("id: {}", me.id); + println!("name: {}", me.name.as_deref().unwrap_or("-")); + println!("email: {}", me.email.as_deref().unwrap_or("-")); + println!("role: {}", me.role); + Ok(()) +} + +async fn revoke(base_url: &str, token: String) -> Result<(), Box> { + let client = Client::new(base_url, Token::new(token))?; + client.revoke_current_token().await?; + println!("Token revoked."); + Ok(()) +} diff --git a/crates/wavekat-platform-client/src/client.rs b/crates/wavekat-platform-client/src/client.rs new file mode 100644 index 0000000..e9585ac --- /dev/null +++ b/crates/wavekat-platform-client/src/client.rs @@ -0,0 +1,251 @@ +//! `Client` — reqwest-backed bearer-auth HTTP against `platform.wavekat.com`. +//! +//! Ported from `wavekat-cli/src/client.rs`. Two intentional changes vs. +//! the CLI: +//! +//! 1. Storage-agnostic constructor: `Client::new(base_url, token)` +//! instead of `Client::from_config()`. Reading auth.json belongs in +//! the consumer (see this crate's `CLAUDE.md`). +//! 2. Typed errors via [`crate::Error`] instead of `anyhow::Result`. +//! Consumers that prefer `anyhow` can `?` straight through. +//! +//! Surface stays close to the CLI so the CLI's eventual migration is +//! mechanical. + +use futures_util::StreamExt; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tokio::io::AsyncWriteExt; + +use crate::error::{Error, Result}; +use crate::token::Token; + +/// HTTP client with the bearer token baked into its default headers. +/// +/// Cheap to clone (it's a thin wrapper around `reqwest::Client`, which is +/// itself an `Arc` internally), so prefer cloning over re-building. +#[derive(Clone)] +pub struct Client { + inner: reqwest::Client, + base_url: String, +} + +impl Client { + /// Build a client for the given platform base URL, authenticated with + /// `token`. The base URL's trailing slash (if any) is stripped. + pub fn new(base_url: impl Into, token: Token) -> Result { + let mut headers = HeaderMap::new(); + let value = format!("Bearer {}", token.as_str()); + let header = HeaderValue::from_str(&value) + .map_err(|_| Error::BadRequest("token contained invalid bytes".into()))?; + headers.insert(AUTHORIZATION, header); + + let inner = reqwest::Client::builder() + .default_headers(headers) + .user_agent(concat!( + "wavekat-platform-client/", + env!("CARGO_PKG_VERSION") + )) + .build()?; + Ok(Self { + inner, + base_url: base_url.into().trim_end_matches('/').to_string(), + }) + } + + /// Base URL the client was configured with, with any trailing slash + /// stripped. Useful for callers that want to print a clickable link + /// alongside an API result (`{base_url}/projects/…`). + pub fn base_url(&self) -> &str { + &self.base_url + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + /// `GET {path}` and decode the JSON response. + pub async fn get_json(&self, path: &str) -> Result { + let url = self.url(path); + let resp = self.inner.get(&url).send().await?; + decode(url, resp).await + } + + /// `GET {path}?query` and decode the JSON response. `query` is any + /// `serde::Serialize` — typically a `&[(K, V)]` or a struct. + pub async fn get_json_query( + &self, + path: &str, + query: &Q, + ) -> Result { + let url = self.url(path); + let resp = self.inner.get(&url).query(query).send().await?; + decode(url, resp).await + } + + /// `POST {path}` with `body` serialized as JSON, decode the JSON + /// response. + pub async fn post_json( + &self, + path: &str, + body: &B, + ) -> Result { + let url = self.url(path); + let resp = self.inner.post(&url).json(body).send().await?; + decode(url, resp).await + } + + /// `POST {path}` with no body, expecting an empty/ignored response. + pub async fn post_empty(&self, path: &str) -> Result<()> { + let url = self.url(path); + let resp = self.inner.post(&url).send().await?; + ensure_success(url, resp).await + } + + /// `POST {path}` with no body, decoding the JSON response. The CLI + /// uses this for `…/finalize` endpoints that take no body but return + /// the updated row. + pub async fn post_empty_returning_json(&self, path: &str) -> Result { + let url = self.url(path); + let resp = self.inner.post(&url).send().await?; + decode(url, resp).await + } + + /// `DELETE {path}`. + pub async fn delete(&self, path: &str) -> Result<()> { + let url = self.url(path); + let resp = self.inner.delete(&url).send().await?; + ensure_success(url, resp).await + } + + /// `PUT {path}` with `body` as `application/octet-stream`. Used by + /// the CLI's `models push` to ship bytes through the platform's + /// proxy upload route when R2 isn't directly reachable. + pub async fn put_proxy_bytes(&self, path: &str, body: Vec) -> Result<()> { + let url = self.url(path); + let resp = self + .inner + .put(&url) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(body) + .send() + .await?; + ensure_success(url, resp).await + } + + /// `PUT` raw bytes to a presigned URL. Deliberately uses a *fresh* + /// `reqwest::Client` (no auth headers) — adding `Authorization: + /// Bearer …` would make S3/R2 reject the request because it's not + /// part of the SigV4 query-string signature. + pub async fn put_presigned_bytes(presigned_url: &str, body: Vec) -> Result<()> { + let resp = reqwest::Client::new() + .put(presigned_url) + .body(body) + .send() + .await?; + ensure_success(presigned_url.to_string(), resp).await + } + + /// Stream a `GET` response body into `sink`. Returns the number of + /// bytes written. Used for big payloads (manifests, audio clips) + /// where holding the whole body in memory would be wasteful. + pub async fn get_stream_to( + &self, + path: &str, + sink: &mut W, + ) -> Result { + let url = self.url(path); + let resp = self.inner.get(&url).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Http { + status: status.as_u16(), + url, + body: truncate(&body, 500).to_string(), + }); + } + let mut stream = resp.bytes_stream(); + let mut written: u64 = 0; + while let Some(chunk) = stream.next().await { + let bytes = chunk?; + sink.write_all(&bytes).await?; + written += bytes.len() as u64; + } + sink.flush().await?; + Ok(written) + } +} + +async fn decode(url: String, resp: reqwest::Response) -> Result { + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + return Err(Error::Http { + status: status.as_u16(), + url, + body: truncate(&text, 500).to_string(), + }); + } + serde_json::from_str(&text).map_err(|source| Error::Decode { url, source }) +} + +async fn ensure_success(url: String, resp: reqwest::Response) -> Result<()> { + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + Err(Error::Http { + status: status.as_u16(), + url, + body: truncate(&body, 500).to_string(), + }) +} + +fn truncate(s: &str, n: usize) -> &str { + if s.len() > n { + // Walk back to the previous char boundary so we don't slice a + // multibyte UTF-8 sequence (the CLI's version of this used a + // raw byte slice, which is a panic waiting for a non-ASCII + // error body). + let mut end = n; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] + } else { + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_error_format_matches_cli_shape() { + // Regression guard: `Display` for `Error::Http` should format + // "{status} {url}: {body}" — matches what the CLI's old `decode` + // produced via `anyhow!`. Consumers (and grep-driven debugging) + // depend on the shape. + let e = Error::Http { + status: 401, + url: "https://platform.wavekat.com/api/me".into(), + body: "unauthorized".into(), + }; + let s = e.to_string(); + assert!(s.contains("401"), "{s}"); + assert!(s.contains("https://platform.wavekat.com/api/me"), "{s}"); + assert!(s.contains("unauthorized"), "{s}"); + } + + #[test] + fn truncate_respects_char_boundaries() { + // Multi-byte char straddling the cap shouldn't panic. + let s = "a".repeat(498) + "é"; // 'é' is 2 bytes in UTF-8. + let t = truncate(&s, 499); + assert!(s.starts_with(t)); + } +} diff --git a/crates/wavekat-platform-client/src/error.rs b/crates/wavekat-platform-client/src/error.rs new file mode 100644 index 0000000..09c5b0e --- /dev/null +++ b/crates/wavekat-platform-client/src/error.rs @@ -0,0 +1,61 @@ +//! Public error type for the crate. +//! +//! Library convention: typed variants so consumers can `match` on the +//! failure mode (network vs. HTTP status vs. OAuth state mismatch). +//! End-user binaries can `?` these into their own `anyhow::Result` +//! without losing information. + +use std::time::Duration; + +/// All errors surfaced by the crate. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The platform returned a non-2xx status. `body` is truncated to a + /// reasonable size before being attached. + #[error("HTTP {status} {url}: {body}")] + Http { + status: u16, + url: String, + body: String, + }, + + /// Underlying transport failure (DNS, TLS, connection reset, …). + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + + /// The response body wasn't valid JSON for the expected shape. + #[error("decoding response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + /// The OAuth callback returned a `state` value that didn't match + /// what we generated. Refusing the token is the only safe move. + #[error("OAuth state mismatch — got {actual:?}, expected {expected:?}")] + StateMismatch { + actual: Option, + expected: String, + }, + + /// The user (or the platform) cancelled the OAuth flow in the + /// browser. The `String` carries the platform-supplied reason. + #[error("OAuth flow cancelled in browser: {0}")] + Cancelled(String), + + /// The OAuth handshake didn't complete within the allotted time. + #[error("OAuth handshake timed out after {0:?}")] + Timeout(Duration), + + /// Caller-side problem — usually a malformed input (e.g. a token + /// that contains bytes we can't put in an HTTP header). + #[error("bad request: {0}")] + BadRequest(String), + + /// Local I/O failure (loopback bind, socket read/write, …). + #[error("I/O: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/wavekat-platform-client/src/lib.rs b/crates/wavekat-platform-client/src/lib.rs index cdac7a0..fcbd5d5 100644 --- a/crates/wavekat-platform-client/src/lib.rs +++ b/crates/wavekat-platform-client/src/lib.rs @@ -1,13 +1,49 @@ //! Rust client for the WaveKat platform. //! //! Reusable across consumers (the [`wavekat-cli`] binary `wk`, the -//! [`wavekat-voice`] desktop daemon, future WaveKat tools) so platform -//! auth and artifact upload have one implementation, not many. +//! WaveKat desktop daemon, future WaveKat tools) so platform auth and +//! HTTP plumbing have one implementation, not many. //! -//! Status: early scaffolding. The first slice — `Client` (reqwest-backed -//! bearer HTTP) and the loopback OAuth handshake — is being ported from -//! [`wavekat-cli`]. See the design notes in the [`wavekat-voice`] repo, -//! `docs/13-platform-login-and-client.md`. +//! ## Quick start +//! +//! ```no_run +//! use wavekat_platform_client::{loopback_handshake, Client, HandshakeOptions}; +//! +//! # async fn run() -> wavekat_platform_client::Result<()> { +//! let pending = loopback_handshake( +//! "https://platform.wavekat.com", +//! HandshakeOptions::default(), +//! )?; +//! println!("Open: {}", pending.url()); +//! let outcome = pending.wait().await?; +//! +//! let client = Client::new("https://platform.wavekat.com", outcome.token)?; +//! let me = client.whoami().await?; +//! println!("signed in as {}", me.login); +//! # Ok(()) } +//! ``` +//! +//! ## What this crate is (and isn't) +//! +//! - **Storage-agnostic.** `Client::new(base_url, token)` is the contract. +//! The crate never reads or writes disk; consumers pick where the token +//! lives (config file, OS keychain, env var, in-memory test fixture). +//! - **Browser-agnostic.** [`loopback_handshake`] returns the sign-in URL; +//! the caller decides how to open it (`webbrowser::open`, +//! `shell.openExternal`, `println!`, …). +//! - **Runtime-light.** Async surface uses `reqwest`; consumers bring +//! their own tokio runtime. //! //! [`wavekat-cli`]: https://github.com/wavekat/wavekat-cli -//! [`wavekat-voice`]: https://github.com/wavekat/wavekat-voice + +mod client; +mod error; +mod me; +mod oauth; +mod token; + +pub use client::Client; +pub use error::{Error, Result}; +pub use me::Me; +pub use oauth::{loopback_handshake, HandshakeOptions, HandshakeOutcome, PendingHandshake}; +pub use token::Token; diff --git a/crates/wavekat-platform-client/src/me.rs b/crates/wavekat-platform-client/src/me.rs new file mode 100644 index 0000000..0c59dd3 --- /dev/null +++ b/crates/wavekat-platform-client/src/me.rs @@ -0,0 +1,36 @@ +//! `/api/me` — the typed shape of the signed-in user. +//! +//! Public because every consumer needs it: the CLI prints it after +//! `wk login`/`wk me`, and the desktop daemon shows the same fields in +//! its Platform settings page. Keeping the struct here (and re-exported +//! from the crate root) means consumers don't redefine it. + +use serde::Deserialize; + +use crate::client::Client; +use crate::error::Result; + +/// The signed-in user, as returned by `GET /api/me`. +#[derive(Debug, Clone, Deserialize)] +pub struct Me { + pub id: i64, + pub login: String, + pub name: Option, + pub email: Option, + pub role: String, +} + +impl Client { + /// Fetch the signed-in user from `/api/me`. The canonical way to + /// verify a freshly-minted token is reachable. + pub async fn whoami(&self) -> Result { + self.get_json("/api/me").await + } + + /// Revoke the bearer token this client is using. After this returns + /// successfully, the same token will start producing 401s — drop the + /// `Client` (and clear whatever storage held the token). + pub async fn revoke_current_token(&self) -> Result<()> { + self.post_empty("/api/auth/cli/tokens/revoke-current").await + } +} diff --git a/crates/wavekat-platform-client/src/oauth.rs b/crates/wavekat-platform-client/src/oauth.rs new file mode 100644 index 0000000..b56103b --- /dev/null +++ b/crates/wavekat-platform-client/src/oauth.rs @@ -0,0 +1,537 @@ +//! Loopback OAuth handshake. +//! +//! Mirrors the flow that `wavekat-cli`'s `wk login` runs today: +//! +//! 1. Bind a TCP listener on `127.0.0.1:`. +//! 2. Generate a one-shot CSRF state. +//! 3. Compute the platform's `/cli-login` URL with +//! `?callback=…&state=…&client=…[&source=…]` and hand it back to +//! the caller via [`PendingHandshake::url`]. +//! 4. Caller opens the URL however they want — `webbrowser::open` for a +//! CLI, `shell.openExternal` for an Electron app, plain `println!` +//! for a remote host with no browser. +//! 5. Caller awaits [`PendingHandshake::wait`], which blocks (on a +//! worker thread) until the platform redirects the browser back to +//! the loopback URL with `?token=…&state=…` (success) or +//! `?error=…&state=…` (cancel). +//! +//! The crate intentionally does **not** call `webbrowser::open` itself — +//! that decision is the consumer's. See `docs/01-initial-port.md` for why. +//! +//! The loopback HTTP server is hand-rolled (`std::net`) for the same +//! reason as the CLI: one request, one response, no need to drag in a +//! framework. Reads are bounded so a stray local probe can't tie us up. + +use rand::RngCore; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::time::{Duration, Instant}; + +use crate::error::{Error, Result}; +use crate::token::Token; + +/// Tunables for [`loopback_handshake`]. +#[derive(Debug, Clone)] +pub struct HandshakeOptions { + /// Short app identifier sent as the `client` query param. Shown as + /// the consent screen title and in the user's "Active sessions" + /// listing. Defaults to `"wavekat-platform-client"`; consumers + /// usually want to override with their own product name (e.g. + /// `"wavekat-voice"`). + pub client: Option, + /// Origin label sent as the `source` query param. Shown beside the + /// title as "from " and in the listing. Typically the + /// machine's hostname; defaults to that. Set to `Some("")` (or + /// override [`Self::omit_source`] to true) to suppress it for + /// privacy. + pub source: Option, + /// If true, no `source` is sent at all — the platform will store + /// `null` and the consent UI won't render a "from …" line. Useful + /// for desktop apps that don't want to disclose the hostname. + pub omit_source: bool, + /// How long to wait for the browser callback. Default: 5 min. + pub timeout: Duration, +} + +impl Default for HandshakeOptions { + fn default() -> Self { + Self { + client: None, + source: None, + omit_source: false, + timeout: Duration::from_secs(5 * 60), + } + } +} + +/// Result of a successful handshake. +#[derive(Debug)] +pub struct HandshakeOutcome { + /// The signed-in token. Hand to [`crate::Client::new`]. + pub token: Token, + /// Echoed back from the platform — typically the user's login. + /// Useful so callers can skip an extra `/api/me` round-trip right + /// after sign-in. `None` when the platform didn't include it (older + /// platforms, or the `error=` path). + pub login: Option, +} + +/// In-flight handshake. Returned by [`loopback_handshake`] before the +/// caller decides how to surface the URL. +/// +/// Drop this without calling [`PendingHandshake::wait`] to abandon the +/// flow; the listener closes when the value goes out of scope. +pub struct PendingHandshake { + listener: TcpListener, + url: String, + state: String, + timeout: Duration, +} + +impl PendingHandshake { + /// The URL to open in the user's browser. Hand this to whatever + /// "open external URL" facility the consumer has — `webbrowser::open`, + /// `shell.openExternal`, or `println!`. + pub fn url(&self) -> &str { + &self.url + } + + /// The CSRF state we generated. Mostly useful for tests / debugging; + /// callers don't normally need to look at it. + pub fn state(&self) -> &str { + &self.state + } + + /// Block (on a worker thread) until the browser redirects back, or + /// the timeout fires. + pub async fn wait(self) -> Result { + let PendingHandshake { + listener, + state, + timeout, + .. + } = self; + + // The accept loop is sync-by-nature (`std::net::TcpListener`), + // so we run it on a blocking worker. The deadline lives inside + // the worker so we never strand a thread on a permanently-open + // listener — set_nonblocking + sleep poll until the deadline. + let join = tokio::task::spawn_blocking(move || accept_loop(listener, &state, timeout)); + match join.await { + Ok(result) => result, + Err(e) => Err(Error::BadRequest(format!( + "loopback worker panicked or was cancelled: {e}" + ))), + } + } +} + +/// Bind the loopback listener and compute the platform sign-in URL. +/// +/// This is sync because binding a `std::net::TcpListener` is sync — there's +/// no `.await` to be had. The async work (waiting for the browser +/// redirect) lives on [`PendingHandshake::wait`]. +pub fn loopback_handshake(base_url: &str, options: HandshakeOptions) -> Result { + // Loopback only — never bind 0.0.0.0. Anything bound to a non-loopback + // interface could be reached by another host on the LAN for the brief + // window we listen, and the token in the redirect URL is a credential. + let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))?; + let port = listener.local_addr()?.port(); + + let state = random_state(); + let client = options + .client + .unwrap_or_else(|| "wavekat-platform-client".to_string()); + let callback = format!("http://127.0.0.1:{port}/callback"); + + let base = base_url.trim_end_matches('/'); + let mut url = format!( + "{base}/cli-login?callback={cb}&state={st}&client={cl}", + cb = url_encode(&callback), + st = url_encode(&state), + cl = url_encode(&client), + ); + if !options.omit_source { + let source = options.source.unwrap_or_else(default_source); + if !source.is_empty() { + url.push_str("&source="); + url.push_str(&url_encode(&source)); + } + } + + Ok(PendingHandshake { + listener, + url, + state, + timeout: options.timeout, + }) +} + +fn accept_loop( + listener: TcpListener, + expected_state: &str, + timeout: Duration, +) -> Result { + listener.set_nonblocking(true)?; + let deadline = Instant::now() + timeout; + + loop { + if Instant::now() >= deadline { + return Err(Error::Timeout(timeout)); + } + match listener.accept() { + Ok((stream, _addr)) => { + // Switch the accepted stream back to blocking for the + // tiny request/response we're about to do. + stream.set_nonblocking(false)?; + match handle_callback(stream, expected_state) { + Ok(HandlerResult::Got(outcome)) => return Ok(outcome), + Ok(HandlerResult::KeepListening) => continue, + Err(e @ Error::StateMismatch { .. }) => return Err(e), + Err(e @ Error::Cancelled(_)) => return Err(e), + Err(_) => { + // A stray probe (favicon, devtools HEAD, etc.) + // shouldn't break the real one. Keep listening. + continue; + } + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + Err(e) => return Err(e.into()), + } + } +} + +enum HandlerResult { + Got(HandshakeOutcome), + KeepListening, +} + +fn handle_callback(mut stream: TcpStream, expected_state: &str) -> Result { + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + // Read just the request line + headers. Cap at 8 KiB — the URL we + // care about is well under that, and we never need the body. + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + + let mut header_bytes = 0usize; + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line)?; + if n == 0 || line == "\r\n" || line == "\n" { + break; + } + header_bytes += n; + if header_bytes > 8192 { + return Err(Error::BadRequest("request headers too large".into())); + } + } + + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let target = parts.next().unwrap_or(""); + if method != "GET" { + respond(&mut stream, 405, "method not allowed", "method not allowed")?; + return Ok(HandlerResult::KeepListening); + } + if !target.starts_with("/callback") { + // Browsers fetch /favicon.ico; some OSes probe /. Reply 404 and + // keep listening — only /callback matters. + respond(&mut stream, 404, "not found", "not found")?; + return Ok(HandlerResult::KeepListening); + } + + let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); + let mut token: Option = None; + let mut state: Option = None; + let mut error: Option = None; + let mut login: Option = None; + for (k, v) in parse_query(query) { + match k.as_str() { + "token" => token = Some(v), + "state" => state = Some(v), + "error" => error = Some(v), + "login" => login = Some(v), + _ => {} + } + } + + if state.as_deref() != Some(expected_state) { + respond( + &mut stream, + 400, + "bad state", + "

State mismatch

Re-run the sign-in to start over.

", + )?; + return Err(Error::StateMismatch { + actual: state, + expected: expected_state.to_string(), + }); + } + + if let Some(err) = error { + respond( + &mut stream, + 200, + "OK", + &format!( + "

Login cancelled

You can close this tab and try again.

reason: {}

", + html_escape(&err), + ), + )?; + return Err(Error::Cancelled(err)); + } + + let Some(tok) = token else { + respond(&mut stream, 400, "missing token", "missing token")?; + return Err(Error::BadRequest("callback missing token".into())); + }; + + respond( + &mut stream, + 200, + "OK", + "WaveKat sign-in complete

You're signed in.

You can close this tab and return to the app.

", + )?; + Ok(HandlerResult::Got(HandshakeOutcome { + token: Token::new(tok), + login, + })) +} + +fn respond(stream: &mut TcpStream, status: u16, reason: &str, body: &str) -> Result<()> { + let body_bytes = body.as_bytes(); + let resp = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n", + len = body_bytes.len(), + ); + stream.write_all(resp.as_bytes())?; + stream.write_all(body_bytes)?; + let _ = stream.flush(); + // Drain a tiny amount of any unread bytes so the kernel doesn't RST + // the connection before the browser reads our response. + let mut sink = [0u8; 64]; + let _ = stream.set_read_timeout(Some(Duration::from_millis(50))); + let _ = stream.read(&mut sink); + Ok(()) +} + +fn random_state() -> String { + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + base64url(&bytes) +} + +// URL-safe base64 (no padding). Hand-rolled to avoid pulling in another +// crate just for 24 bytes of CSRF state. +fn base64url(bytes: &[u8]) -> String { + const ALPHA: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3)); + let mut i = 0; + while i + 3 <= bytes.len() { + let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | (bytes[i + 2] as u32); + out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char); + out.push(ALPHA[(n & 0x3f) as usize] as char); + i += 3; + } + let rem = bytes.len() - i; + if rem == 1 { + let n = (bytes[i] as u32) << 16; + out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char); + } else if rem == 2 { + let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8); + out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char); + } + out +} + +fn default_source() -> String { + std::env::var("HOSTNAME") + .ok() + .or_else(|| hostname().ok()) + .unwrap_or_else(|| "unknown-host".to_string()) +} + +#[cfg(unix)] +fn hostname() -> Result { + let out = std::process::Command::new("hostname").output()?; + if !out.status.success() { + return Err(Error::BadRequest("hostname exited non-zero".into())); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +#[cfg(not(unix))] +fn hostname() -> Result { + std::env::var("COMPUTERNAME") + .map_err(|e| Error::BadRequest(format!("COMPUTERNAME not set: {e}"))) +} + +fn url_encode(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + +fn parse_query(q: &str) -> Vec<(String, String)> { + url::form_urlencoded::parse(q.as_bytes()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // Carried verbatim from `wavekat-cli/src/commands/login.rs`. + + #[test] + fn base64url_rfc_vectors() { + assert_eq!(base64url(b""), ""); + assert_eq!(base64url(b"f"), "Zg"); + assert_eq!(base64url(b"fo"), "Zm8"); + assert_eq!(base64url(b"foo"), "Zm9v"); + assert_eq!(base64url(b"foob"), "Zm9vYg"); + assert_eq!(base64url(b"fooba"), "Zm9vYmE"); + assert_eq!(base64url(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn base64url_uses_url_safe_alphabet() { + assert_eq!(base64url(&[0xfb, 0xff, 0xff]), "-___"); + let big: Vec = (0u8..=255).collect(); + let out = base64url(&big); + assert!(!out.contains('+')); + assert!(!out.contains('/')); + assert!(!out.contains('=')); + } + + #[test] + fn random_state_shape() { + let s = random_state(); + assert_eq!(s.len(), 32); + let alpha: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + for b in s.as_bytes() { + assert!(alpha.contains(b), "unexpected byte {b:#x} in state"); + } + } + + #[test] + fn random_state_is_not_constant() { + assert_ne!(random_state(), random_state()); + } + + #[test] + fn html_escape_handles_metacharacters() { + assert_eq!( + html_escape("it's & ok"), + "<a href="x">it's & ok</a>", + ); + assert_eq!(html_escape("plain text"), "plain text"); + assert_eq!(html_escape(""), ""); + } + + // New tests for the v0.0.1 surface. + + #[test] + fn handshake_options_default_has_sensible_timeout() { + let opts = HandshakeOptions::default(); + assert_eq!(opts.timeout, Duration::from_secs(300)); + assert!(opts.client.is_none()); + assert!(opts.source.is_none()); + assert!(!opts.omit_source); + } + + #[test] + fn default_source_falls_back_to_a_hostname() { + let s = default_source(); + assert!(!s.is_empty(), "should never produce an empty source"); + } + + #[test] + fn loopback_handshake_returns_url_with_loopback_callback() { + let pending = + loopback_handshake("https://platform.wavekat.com", HandshakeOptions::default()) + .expect("bind loopback"); + let url = pending.url(); + assert!(url.starts_with("https://platform.wavekat.com/cli-login?")); + assert!(url.contains("127.0.0.1"), "{url}"); + assert!(url.contains(&format!( + "state={}", + url::form_urlencoded::byte_serialize(pending.state().as_bytes()).collect::() + ))); + // Default `client` is the crate name. + assert!( + url.contains("client=wavekat-platform-client"), + "expected client=wavekat-platform-client in {url}", + ); + // Default options send a source (the hostname). + assert!(url.contains("&source="), "expected &source=... in {url}"); + } + + #[test] + fn loopback_handshake_uses_explicit_client_and_source() { + let pending = loopback_handshake( + "https://platform.wavekat.com", + HandshakeOptions { + client: Some("wavekat-voice".into()), + source: Some("studio-mac".into()), + ..Default::default() + }, + ) + .expect("bind loopback"); + let url = pending.url(); + assert!(url.contains("client=wavekat-voice"), "{url}"); + assert!(url.contains("source=studio-mac"), "{url}"); + } + + #[test] + fn loopback_handshake_omits_source_when_requested() { + let pending = loopback_handshake( + "https://platform.wavekat.com", + HandshakeOptions { + client: Some("wavekat-voice".into()), + omit_source: true, + ..Default::default() + }, + ) + .expect("bind loopback"); + let url = pending.url(); + assert!(url.contains("client=wavekat-voice"), "{url}"); + assert!(!url.contains("source="), "should not include source: {url}"); + } + + #[test] + fn loopback_handshake_strips_trailing_slash() { + let pending = loopback_handshake("https://platform.wavekat.com/", Default::default()) + .expect("bind loopback"); + assert!(pending + .url() + .starts_with("https://platform.wavekat.com/cli-login?")); + } +} diff --git a/crates/wavekat-platform-client/src/token.rs b/crates/wavekat-platform-client/src/token.rs new file mode 100644 index 0000000..808235f --- /dev/null +++ b/crates/wavekat-platform-client/src/token.rs @@ -0,0 +1,84 @@ +//! Newtype wrapper around the platform bearer token. +//! +//! Why a newtype rather than `String`? +//! +//! - **Redacted `Debug`.** `format!("{t:?}")` won't leak the secret into +//! logs or panic messages. The CLI bit us once where a `dbg!(cfg)` +//! spilled the full token into a stderr line that ended up in a +//! support transcript. +//! - **No `Display`.** Callers must explicitly opt in via [`Token::as_str`] +//! to get the raw string, which makes accidental `format!("{t}")` (or +//! `println!("{t}")`) a compile error. +//! - **Storage-agnostic.** Construction from any `Into` so +//! consumers can hand in whatever shape their storage layer returns. + +use std::fmt; + +/// Bearer token used to authenticate against the platform. +/// +/// Tokens are minted by completing the loopback OAuth handshake (see +/// [`crate::oauth::loopback_handshake`]) or accepted out-of-band by the +/// caller (e.g. read from an environment variable in CI). +#[derive(Clone)] +pub struct Token(String); + +impl Token { + /// Wrap a raw token string. + pub fn new(raw: impl Into) -> Self { + Self(raw.into()) + } + + /// The raw token. Hand to HTTP code that needs to set the bearer + /// header. Avoid logging this — that's the whole reason `Display` + /// isn't implemented. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Show the prefix up to (and including) the underscore — which + // is conventional for these tokens (`wk_…`) and useful for + // quickly distinguishing "I have a token" from "I don't" in + // logs without exposing any of the secret bytes. + let prefix = match self.0.split_once('_') { + Some((p, _)) => p, + None => "", + }; + if prefix.is_empty() { + write!(f, "Token(***redacted)") + } else { + write!(f, "Token({prefix}_***redacted)") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_redacts_secret() { + let secret = "wk_abcdef1234567890"; + let t = Token::new(secret); + let dbg = format!("{t:?}"); + assert!(dbg.contains("***"), "{dbg} should contain ***"); + assert!(!dbg.contains(secret), "{dbg} leaked the secret"); + assert!(dbg.contains("wk_"), "{dbg} should keep the prefix"); + } + + #[test] + fn debug_handles_no_prefix() { + let t = Token::new("rawvalue"); + let dbg = format!("{t:?}"); + assert!(!dbg.contains("rawvalue")); + assert!(dbg.contains("***")); + } + + #[test] + fn as_str_returns_raw() { + let t = Token::new("wk_xyz"); + assert_eq!(t.as_str(), "wk_xyz"); + } +} diff --git a/docs/01-initial-port.md b/docs/01-initial-port.md new file mode 100644 index 0000000..e04a84d --- /dev/null +++ b/docs/01-initial-port.md @@ -0,0 +1,240 @@ +# 01 — Initial port: scaffold → v0.0.1 + +> Status: planning · Date: 2026-05-14 +> +> This is the work plan to get the crate from "empty scaffold" to "usable v0.0.1 published on crates.io," at which point the first downstream consumer ([`wavekat-voice`](https://github.com/wavekat/wavekat-voice)) can depend on it. The companion design rationale — *why* this crate exists separately from `wavekat-cli` — lives in `wavekat-voice/docs/13-platform-login-and-client.md`. + +## Where we are + +`main` ships scaffold only: + +- Workspace layout (`crates/wavekat-platform-client/`) cribbed from `wavekat-core`. +- `lib.rs` contains only an intent docstring. No public types, no public functions. +- `Cargo.toml` declares zero dependencies. +- CI + release-plz workflows pre-wired; `cargo check --workspace` is clean. + +That means `cargo add wavekat-platform-client` from a consumer would compile, but `use wavekat_platform_client::Client` wouldn't — there is no `Client`. The next step is to actually port the code from [`wavekat-cli`](https://github.com/wavekat/wavekat-cli), which has a battle-tested implementation of everything v0.0.1 needs. + +## What we're porting (not redesigning) + +The CLI already does platform auth correctly, and its `client.rs` + `login.rs` were written with the assumption they might one day be shared. We are **moving** that code, with minimal adjustments — not redesigning it. + +| Source in `wavekat-cli` | Target here | Notes | +|---|---|---| +| `src/client.rs::Client` and helpers | `crates/wavekat-platform-client/src/client.rs` | Reqwest-backed bearer-auth HTTP. The CLI-specific helpers `get_stream_to`, `put_proxy_bytes`, `put_presigned_bytes` come along — they're agnostic of the consumer. | +| `src/client.rs::decode` + `truncate` | Private inside `client.rs` | Internal. | +| `src/commands/login.rs::browser_handshake` + `handle_callback` + `respond` | `crates/wavekat-platform-client/src/oauth.rs` | The whole loopback dance, refactored as a single async-or-blocking entry point. | +| `src/commands/login.rs::random_state` + `base64url` + `html_escape` + `hostname` + `client_name` | Inside `oauth.rs` (private) | The unit tests for these (`base64url_rfc_vectors`, `base64url_uses_url_safe_alphabet`, `random_state_shape`, `random_state_is_not_constant`, `html_escape_handles_metacharacters`) port over verbatim. | +| The `Me` deserialize struct in `src/commands/me.rs` | `crates/wavekat-platform-client/src/me.rs` | Public — `Me { id, login, name, email, role }`. | +| `Client::post_empty("/api/auth/cli/tokens/revoke-current")` | `Client::revoke_current_token()` | Typed method, same endpoint. | + +What is **not** ported: `config.rs` (file-backed storage), all `commands/*` other than `login`/`logout`/`me`. Storage and CLI-shaped concerns stay in the CLI per this repo's `CLAUDE.md`. + +## Public surface for v0.0.1 + +```rust +// lib.rs — re-exports only +pub use client::Client; +pub use error::{Error, Result}; +pub use me::Me; +pub use oauth::{loopback_handshake, HandshakeOptions, HandshakeOutcome}; +pub use token::Token; + +// client.rs +pub struct Client { /* … */ } +impl Client { + pub fn new(base_url: impl Into, token: Token) -> Result; + pub fn base_url(&self) -> &str; + + pub async fn whoami(&self) -> Result; + pub async fn revoke_current_token(&self) -> Result<()>; + + // Lower-level helpers — typed wrappers come on top of these. + pub async fn get_json(&self, path: &str) -> Result; + pub async fn post_json( + &self, path: &str, body: &B, + ) -> Result; + pub async fn post_empty(&self, path: &str) -> Result<()>; + pub async fn delete(&self, path: &str) -> Result<()>; +} + +// oauth.rs +pub struct HandshakeOptions { + /// Display name shown in the user's platform "Active sessions" list. + /// Defaults to `"wavekat-platform-client on "`. + pub client_name: Option, + /// How long to wait for the browser callback. Default 5 min. + pub timeout: Duration, +} + +pub struct HandshakeOutcome { + /// The signed-in token. Hand to `Client::new`. + pub token: Token, + /// Echoed back from the platform — typically the user's login. + /// Useful so callers can avoid an extra `/api/me` round-trip after sign-in. + pub login: Option, +} + +/// Two-phase API so callers can show the URL in their UI before +/// blocking on the browser callback. +pub struct PendingHandshake { /* … */ } +impl PendingHandshake { + pub fn url(&self) -> &str; + pub fn state(&self) -> &str; + pub async fn wait(self) -> Result; +} + +pub fn loopback_handshake( + base_url: &str, + options: HandshakeOptions, +) -> Result; + +// token.rs — newtype wrapper with redacted Debug +pub struct Token(/* private */ String); +impl Token { + pub fn new(raw: impl Into) -> Self; + pub fn as_str(&self) -> &str; +} +impl fmt::Debug for Token { /* prints "Token(wk_***redacted)" */ } +// no Display — must opt in via .as_str() + +// error.rs +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("HTTP {status} {url}: {body}")] + Http { status: u16, url: String, body: String }, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + #[error("decoding response from {url}: {source}")] + Decode { url: String, source: serde_json::Error }, + #[error("OAuth state mismatch — got {actual:?}, expected {expected:?}")] + StateMismatch { actual: Option, expected: String }, + #[error("OAuth flow cancelled in browser: {0}")] + Cancelled(String), + #[error("OAuth handshake timed out after {0:?}")] + Timeout(Duration), + #[error("bad request: {0}")] + BadRequest(String), + #[error("I/O: {0}")] + Io(#[from] std::io::Error), +} +pub type Result = std::result::Result; +``` + +## One design adjustment vs the CLI: the crate doesn't open browsers + +The CLI calls `webbrowser::open(&auth_url)` inside its handshake. That's fine for `wk login` (one process, one terminal, one consenting user). But the second consumer — `wavekat-voice` — runs as a desktop app with an Electron host and opens external URLs via `shell.openExternal`, not via the `webbrowser` crate. + +So the crate **returns the URL**; the caller opens it. Concretely the two-phase API above lets a caller do: + +```rust +let pending = loopback_handshake("https://platform.wavekat.com", opts)?; +// caller is free to: +// - println!("Open: {}", pending.url()) // CLI +// - shell.openExternal(pending.url()) // Electron / Voice +// - webbrowser::open(pending.url()) // CLI convenience (still allowed) +let outcome = pending.wait().await?; +``` + +This also means the crate doesn't depend on the `webbrowser` crate — one less transitive dep on the desktop side. + +## Dependency set for v0.0.1 + +Each justified: + +| Crate | Why | Features | +|---|---|---| +| `reqwest` | HTTP client (carries over from CLI). | `rustls-tls`, `json`, `gzip`, `stream` | +| `serde` | All payload types. | `derive` | +| `serde_json` | Bodies + error-body parsing. | – | +| `url` | URL-encoding the OAuth callback params. | – | +| `rand` | Random state for CSRF (carries over from CLI). | – | +| `thiserror` | Typed `Error` enum. | – | +| `tokio` | Time + sync primitives for the `PendingHandshake::wait` future. | `time`, `sync` (NOT `rt-multi-thread` — let consumers pick) | + +Total fresh adds vs the CLI's already-vetted set: just `thiserror`. Everything else is already in the CLI's tree. + +**Deliberately NOT pulled:** `clap`, `webbrowser`, `dirs`, `tokio` macros / rt-multi-thread, `arrow*`, `parquet`, `hound`, `rayon`, `anyhow`. Each one would shape this crate's character in the wrong direction. + +## File layout + +``` +crates/wavekat-platform-client/src/ +├── lib.rs # crate docstring + pub use re-exports only +├── client.rs # Client + low-level get/post/delete helpers +├── error.rs # Error enum (thiserror) + Result alias +├── me.rs # Me struct + Client::whoami() +├── oauth.rs # loopback_handshake, PendingHandshake, HandshakeOptions/Outcome +└── token.rs # Token newtype with redacted Debug +``` + +Five small files. Each module's preamble carries the WHY-comment style the CLI established (see `commands/login.rs:1-22` for the canonical example). + +## Test plan + +### In-repo (CI-runnable) + +- **Unit tests carried over verbatim from `wavekat-cli`:** + - `base64url_rfc_vectors` + - `base64url_uses_url_safe_alphabet` + - `random_state_shape` + - `random_state_is_not_constant` + - `html_escape_handles_metacharacters` +- **New unit tests:** + - `Token::Debug` redacts the secret (`assert!(format!("{:?}", t).contains("***"))` and `!contains(secret)`). + - `HandshakeOptions::default()` round-trips a sensible `client_name`. + - `Error::Http` formats the way the CLI's `decode` used to (so we don't regress error UX). + +### Manual smoke (not in CI) + +Done by a developer with platform credentials, against a running `platform.wavekat.com` (or staging): + +1. `cargo run --example smoke -- login` — completes the loopback dance, prints the new `Token` (redacted) and the `Me` row. (Add an `examples/smoke.rs` for this; uses `webbrowser::open` for convenience since it's a binary.) +2. Re-run `cargo run --example smoke -- whoami --token $TOKEN` — succeeds without re-prompting. +3. `cargo run --example smoke -- revoke --token $TOKEN` — succeeds; subsequent whoami with the same token returns 401. + +Don't put platform credentials in CI for v0.0.1; the cost-to-reward isn't worth it yet. Manual smoke covers the surface that mocks can't. + +## Publishing v0.0.1 + +Gate the publish on: + +- All in-repo tests green on CI (`make ci` locally; `ci.yml` on GitHub). +- Manual smoke passed once against the live platform. +- `cargo publish --dry-run -p wavekat-platform-client` clean. +- README's [Status](../README.md#status) table updated to show v0.0.1 surfaces as ✅ instead of "Coming in v0.0.1." + +Mechanically: merge to `main` with a Conventional Commit subject (`feat: port Client and loopback OAuth from wavekat-cli`). [`release-plz`](../release-plz.toml) picks it up, opens a release PR, and on merge of the release PR cuts the tag and publishes to crates.io. + +## What's explicitly out of v0.0.1 + +- **Artifact upload** (3-step create → presigned PUT → finalize). The CLI's `commands/models.rs` has the pattern, but `wavekat-voice` doesn't have a recording to upload yet. Lands in v0.0.2 when Voice's recording PR is ready to consume it. +- **CLI migration.** A follow-up PR on the `wavekat-cli` repo will rewrite its `client.rs` + the relevant half of `login.rs` to depend on this crate. Sequenced after v0.0.1 ships; the CLI keeps working unchanged in the meantime. +- **Local (file-backed) storage helper.** Even though the CLI uses it today, baking a storage policy into this crate would undo the storage-agnostic principle (CLAUDE.md). The CLI keeps its own `config.rs`; if a third consumer ever wants the same file-backed flow we can extract a `wavekat-platform-client-fsstore` companion. +- **OAuth refresh / device-code / PKCE.** Loopback is what the platform supports today; if/when the platform adds other flows we'll add them here. Not blocking v0.0.1. + +## Open design questions + +Defaults are baked into the surface sketch above; flag dissent before implementation lands. + +1. **`thiserror` vs `anyhow` for the public Error.** CLI uses `anyhow` because it's an end-user binary; libraries traditionally pick `thiserror` so consumers can match on variants. Default: **`thiserror`** as sketched above. The CLI itself, once it migrates, can `?` these errors into its own `anyhow::Result` painlessly. +2. **Token as a newtype.** Adding `Token(String)` with redacted `Debug` costs nothing and avoids ever accidentally logging the secret. Default: **yes**, ship as `Token` from v0.0.1. Consumer ergonomics: `Token::new(s)` and `t.as_str()`. No `Display`, no `From` impl that allows accidental logging via `format!("{}", t)`. +3. **Sync handshake, async client.** The CLI's `browser_handshake` is sync (`std::net::TcpListener`, blocking `accept`). The rest of the CLI is async. Library proposal: keep that mix — sync `loopback_handshake` (returns `PendingHandshake`, a struct holding the listener), async `PendingHandshake::wait` that internally `spawn_blocking`s the accept loop. Avoids dragging in `tokio::net` and the runtime opinions that come with it, while still presenting an `.await`-able tail. Default: **as sketched.** +4. **Hostname source.** CLI calls `hostname` via `std::process::Command` on Unix and `COMPUTERNAME` env on Windows. Works. Alternative: `gethostname` crate (~50 LOC, no fork). Default: **port the CLI's approach verbatim**; revisit if the spawn becomes a real cost. +5. **MSRV.** Set `rust-version = "1.75"` (or whatever matches `wavekat-cli`'s and `wavekat-core`'s implicit MSRV). Pin before publish so consumers know. + +## Sequencing + +After this doc merges: + +1. **PR `feat/initial-client-port`** on this repo. Lands all five `src/*.rs` files + the unit tests + an `examples/smoke.rs`. Conventional title: `feat: port Client and loopback OAuth from wavekat-cli`. Single PR; the modules are small. +2. **Manual smoke** against `platform.wavekat.com` per [Test plan](#manual-smoke-not-in-ci). +3. **Release-plz cuts v0.0.1.** Auto-published on merge of the release PR. +4. **`wavekat-voice` consumes it.** PR 2 in [`wavekat-voice/docs/13-platform-login-and-client.md`](https://github.com/wavekat/wavekat-voice/blob/main/docs/13-platform-login-and-client.md) — daemon-side sign-in, keychain storage, Platform settings page. +5. **`wavekat-cli` migrates.** Standalone follow-up PR on the CLI repo; replaces its `client.rs` and the handshake half of `login.rs` with calls into this crate. Validates that the surface really does fit both consumers. + +## What this doc is not + +- Not a v0.0.2+ plan. Artifact upload, the upload queue, recording-disclosure plumbing — all later. +- Not a deviation from the CLI's design. Where defaults change (no browser-open in the crate, `Token` newtype, `thiserror` typed errors), they're called out above with reasoning. +- Not a hard commitment to the surface above. Five open questions; defaults stand unless redirected before PR 1 lands.