Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ resolver = "2"
version = "0.0.1"
edition = "2021"
license = "Apache-2.0"
rust-version = "1.75"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions crates/wavekat-platform-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
112 changes: 112 additions & 0 deletions crates/wavekat-platform-client/examples/smoke.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut iter = args.into_iter();
let cmd = iter
.next()
.ok_or("missing subcommand: login | whoami | revoke")?;

let mut token: Option<String> = None;
let mut base_url: Option<String> = 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let client = Client::new(base_url, Token::new(token))?;
client.revoke_current_token().await?;
println!("Token revoked.");
Ok(())
}
Loading