diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index b25ca73d2..1fc9ac658 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -401,7 +401,7 @@ jobs: # fork the core image doesn't need. The Dockerfile COPYs vendor/ because # [patch.crates-io] resolves Rust SDK crates from vendor/. - name: Init vendored Rust submodules - run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace + run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to GHCR diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index dc1c44310..40c2daa1d 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -297,7 +297,7 @@ jobs: # fork the core image doesn't need. The Dockerfile COPYs vendor/ because # [patch.crates-io] resolves Rust SDK crates from vendor/. - name: Init vendored Rust submodules - run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace + run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build image (no push) diff --git a/.gitmodules b/.gitmodules index 3f6001479..ca88e9481 100644 --- a/.gitmodules +++ b/.gitmodules @@ -23,3 +23,6 @@ [submodule "vendor/tinyplace"] path = vendor/tinyplace url = https://github.com/tinyhumansai/tiny.place.git +[submodule "vendor/tinyhumans-sdk"] + path = vendor/tinyhumans-sdk + url = https://github.com/tinyhumansai/sdk.git diff --git a/AGENTS.md b/AGENTS.md index 20997f2f2..2062b114f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,17 +175,36 @@ Embedded provider webviews **must not** grow new JS injection. No new `.js` unde ## Rust core (`src/`) -### TinyHumans backend SDK boundary +### Backend API access — `src/api/` over `tinyhumans-sdk` -- All Rust-core calls to the TinyHumans managed backend must go through - `tinyhumans-sdk`, with OpenHuman adapters retaining local egress, budget, - session-expiry, TLS, and observability policy. -- Do not add direct `reqwest` calls for TinyHumans JSON APIs or duplicate SDK - wire types in OpenHuman. Extend the SDK and update its pinned revision when a - public route or type is missing. -- Never expose or call TinyHumans admin or webhook APIs from the SDK boundary. - Local inbound webhook routing is an OpenHuman runtime feature and is not an - exception for backend `/webhooks/*` calls. +Calls to the TinyHumans cloud backend go through the vendored +[`tinyhumans-sdk`](https://github.com/tinyhumansai/sdk) crate at +`vendor/tinyhumans-sdk` (git submodule, path dependency — the crate is not on +crates.io, so unlike the other `vendor/` crates it has no `[patch.crates-io]` +entry). **The SDK is the source of truth for backend routes.** A route missing +from it belongs upstream in the SDK repo, not re-implemented in `src/api/`. + +The split: + +- **SDK** — routes, URL building, percent-encoding, credential headers, + `{success,data}` envelope handling, and the admin/webhook-receiver route gate. +- **`src/api/`** — the OpenHuman-specific layer on top: session-token retrieval + (`jwt.rs`), base-URL/env resolution (`config.rs`), and the error + classification + Sentry policy in `rest.rs`. + +`BackendOAuthClient` owns a `TinyHumansClient` built with +`with_http_client(...)` so the SDK inherits this crate's transport — platform +TLS (schannel on Windows for corporate TLS-inspection proxies, rustls +elsewhere), the 120s/15s timeouts, `http1_only`, and the `x-core-version` / +`x-tauri-version` headers. Bind a session token with `sdk_for(bearer_jwt)`. + +**Every SDK-backed call must map its error through `classify_sdk_error`.** That +function mirrors `authed_json`'s classification exactly (401 → +`Unauthorized`/`SESSION_EXPIRED`, channel-message 404 → `MessageNotFound`, +announcements 404 → `AnnouncementNotFound`, transient statuses logged not +reported). Skipping it would change a route's Sentry and session-expiry +behaviour purely by moving it onto a typed SDK method. `rest_tests.rs` pins the +two paths' equivalence — keep that as call sites migrate. ### Domain layout (`src/openhuman/`) diff --git a/Cargo.lock b/Cargo.lock index 16dc21b54..4575a2681 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7313,7 +7313,6 @@ dependencies = [ [[package]] name = "tinyhumans-sdk" version = "0.1.0" -source = "git+https://github.com/tinyhumansai/sdk.git?rev=3ee4123ba3b7a76c5f167d7bc2c72fca86671292#3ee4123ba3b7a76c5f167d7bc2c72fca86671292" dependencies = [ "base64 0.22.1", "percent-encoding", diff --git a/Cargo.toml b/Cargo.toml index 34df07a76..107c7e4b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,16 @@ name = "openhuman_core" crate-type = ["rlib"] [dependencies] +# tinyhumans-sdk — the typed Rust client for the TinyHumans backend API +# (https://github.com/tinyhumansai/sdk). Vendored as a git submodule under +# `vendor/` beside the other tiny* crates and consumed by path: the crate is not +# published to crates.io, so there is no `[patch.crates-io]` entry for it. +# This is the single source of truth for backend routes — `src/api/` is the +# OpenHuman-local adapter layer (session tokens, config, observability) that +# sits on top of it, not a second HTTP client. Anything missing here belongs +# upstream in the SDK repo, not re-implemented in `src/api/`. +# After cloning: `git submodule update --init vendor/tinyhumans-sdk`. +tinyhumans-sdk = { path = "vendor/tinyhumans-sdk" } # tiny.place A2A social network SDK — published on crates.io and patched below # to the vendored tiny.place submodule so OpenHuman can test SDK changes before # publishing. @@ -144,7 +154,6 @@ dhat = { version = "0.3", optional = true } # AV root CAs. So the two TLS backends coexist only on Windows, never on # the RAM-sensitive platforms. reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream", "http2", "multipart", "socks"] } -tinyhumans-sdk = { git = "https://github.com/tinyhumansai/sdk.git", rev = "3ee4123ba3b7a76c5f167d7bc2c72fca86671292" } # Already in-tree via reqwest/hyper; named directly so `IntegrationClient::get_bytes` # can return `bytes::Bytes` without a copy. bytes = "1" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index cee8aea06..13949d84e 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5601,6 +5601,7 @@ dependencies = [ "tinychannels", "tinycortex", "tinyflows", + "tinyhumans-sdk", "tinyjuice", "tinyplace", "tokio", @@ -9025,6 +9026,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinyhumans-sdk" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "percent-encoding", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tinyjuice" version = "0.2.1" diff --git a/src/api/jwt.rs b/src/api/jwt.rs index 75ab3b02f..944394cb1 100644 --- a/src/api/jwt.rs +++ b/src/api/jwt.rs @@ -1,28 +1,20 @@ //! Session JWT load and `Authorization` helpers for the TinyHumans API. +//! +//! Parsing and header formatting live in the vendored SDK +//! (`tinyhumans_sdk::jwt`) — they are properties of the backend's token, not of +//! this client, and every host needs them. Re-exported here so existing call +//! sites keep one import path. +//! +//! What stays OpenHuman-specific is *where the token lives*: the credentials +//! store, keyring, and auth-profile names below. -use base64::Engine; use chrono::{DateTime, Utc}; +pub use tinyhumans_sdk::jwt::{bearer_authorization_value, decode_jwt_payload}; + pub use crate::openhuman::credentials::session_support::get_session_token; pub use crate::openhuman::credentials::{APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME}; -/// Value for `Authorization: Bearer …` (matches backend expectations). -pub fn bearer_authorization_value(token: &str) -> String { - format!("Bearer {}", token.trim()) -} - -/// Best-effort decode of a JWT payload without verifying the signature. -pub fn decode_jwt_payload(token: &str) -> Option { - // JWT = header.payload.signature (base64url, no padding). Only the payload - // segment is needed. - let payload_b64 = token.trim().split('.').nth(1)?; - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) - .ok()?; - serde_json::from_slice(&bytes).ok() -} - /// Best-effort decode of a JWT's `exp` (expiry) claim into a UTC timestamp. /// /// The backend app-session token is a JWT but is stored bare — the client @@ -37,49 +29,25 @@ pub fn decode_jwt_payload(token: &str) -> Option { /// still 401s, caught by the `flatten_authed_error` net). Returns `None` for any /// non-JWT / malformed / `exp`-less token, in which case expiry tracking /// degrades to the previous behaviour (no local precheck). +/// +/// The SDK returns Unix seconds so it needs no datetime dependency; this wraps +/// that in the `chrono` type the credentials store already uses. pub fn decode_jwt_exp(token: &str) -> Option> { - let claims = decode_jwt_payload(token)?; - // `exp` is a NumericDate (seconds since epoch); accept int or float shapes. - let exp = claims - .get("exp") - .and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))?; - DateTime::::from_timestamp(exp, 0) + DateTime::::from_timestamp(tinyhumans_sdk::jwt::decode_jwt_exp_unix(token)?, 0) } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_bearer_authorization_value() { - // Standard token - assert_eq!(bearer_authorization_value("my_token"), "Bearer my_token"); - - // Token with leading/trailing spaces - assert_eq!( - bearer_authorization_value(" spaced_token "), - "Bearer spaced_token" - ); - - // Empty string - assert_eq!(bearer_authorization_value(""), "Bearer "); - - // Whitespace only string - assert_eq!(bearer_authorization_value(" "), "Bearer "); - - // Token with internal spaces (should not be trimmed) - assert_eq!( - bearer_authorization_value("token with spaces"), - "Bearer token with spaces" - ); - } - fn jwt_with_payload(payload_json: &str) -> String { + use base64::Engine; let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json); - // header + signature are irrelevant to `decode_jwt_exp` (no verification). format!("eyJhbGciOiJIUzI1NiJ9.{payload}.sig") } + // The SDK owns the parsing rules and tests them directly. What matters here + // is that the `chrono` conversion this crate depends on stays correct. #[test] fn decode_jwt_exp_reads_integer_exp() { let token = jwt_with_payload(r#"{"sub":"u1","exp":1700000000}"#); @@ -108,8 +76,8 @@ mod tests { fn decode_jwt_exp_none_for_non_jwt_or_garbage() { assert_eq!(decode_jwt_exp("not-a-jwt"), None); assert_eq!(decode_jwt_exp(""), None); - assert_eq!(decode_jwt_exp("a.b"), None); // payload "b" isn't valid base64 JSON - // local offline session sentinel (not a JWT) must not panic / must be None + assert_eq!(decode_jwt_exp("a.b"), None); + // Local offline session sentinel (not a JWT) must not panic. assert_eq!(decode_jwt_exp("local-session-xyz"), None); } } diff --git a/src/api/rest.rs b/src/api/rest.rs index 66ce09ab1..ba7ef704a 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -224,6 +224,39 @@ fn build_backend_reqwest_client() -> Result { .map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}")) } +/// Normalize the backend envelope while preserving OpenHuman's historical +/// response shape. In particular, `/auth/me` returns `{success,user}` rather +/// than `{success,data}`; SDK transport must not expose that envelope detail to +/// existing callers. +fn parse_api_response_value(value: Value) -> Result { + let Some(object) = value.as_object() else { + return Ok(value); + }; + if let Some(user) = object.get("user").filter(|user| !user.is_null()) { + return Ok(user.clone()); + } + let Some(success) = object.get("success").and_then(Value::as_bool) else { + return Ok(value); + }; + if !success { + let message = object + .get("message") + .or_else(|| object.get("error")) + .and_then(Value::as_str) + .unwrap_or("request unsuccessful"); + anyhow::bail!("API request failed: {message}"); + } + if let Some(data) = object.get("data").filter(|data| !data.is_null()) { + return Ok(data.clone()); + } + if let Some(user) = object.get("user").filter(|user| !user.is_null()) { + return Ok(user.clone()); + } + let mut unwrapped = object.clone(); + unwrapped.remove("success"); + Ok(Value::Object(unwrapped)) +} + fn user_id_from_object(obj: &serde_json::Map) -> Option { for key in ["id", "_id", "userId"] { if let Some(s) = obj.get(key).and_then(|x| x.as_str()) { @@ -490,7 +523,7 @@ impl BackendOAuthClient { .send(method.clone(), path, &[], body.as_ref(), true) .await; let value = match response { - Ok(value) => return Ok(value), + Ok(value) => return parse_api_response_value(value), Err(SdkError::Http(e)) => { // Walk the error source chain so transient markers hidden in nested // causes (reqwest -> hyper -> rustls TLS EOF, etc.) still classify diff --git a/src/api/rest_tests.rs b/src/api/rest_tests.rs index 1338344d6..c276290d2 100644 --- a/src/api/rest_tests.rs +++ b/src/api/rest_tests.rs @@ -937,3 +937,105 @@ async fn authed_json_patch_404_with_base_path_prefix_does_not_report() { assert_eq!(provider, "telegram"); assert_eq!(message_id, "9999"); } + +// The channel methods below now reach the backend through the vendored +// `tinyhumans-sdk` transport instead of `authed_json`. The routes they call +// still classify expected backend states the same way — a route must not +// change its Sentry or session-expiry behaviour just because it moved onto a +// typed SDK method. These pin that equivalence. + +#[tokio::test] +async fn sdk_backed_channel_delete_surfaces_message_not_found_on_404() { + let app = Router::new().route( + "/channels/telegram/messages/1103", + axum::routing::delete(|| async { (axum::http::StatusCode::NOT_FOUND, "Not Found") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = BackendOAuthClient::new(&format!("http://{addr}")).unwrap(); + let err = client + .send_channel_delete("telegram", "1103", "mock-jwt") + .await + .unwrap_err(); + + let typed = err.downcast_ref::().unwrap(); + let BackendApiError::MessageNotFound { + provider, + message_id, + } = typed + else { + panic!("expected MessageNotFound, got {typed:?}"); + }; + assert_eq!(provider, "telegram"); + assert_eq!(message_id, "1103"); +} + +#[tokio::test] +async fn sdk_backed_channel_typing_surfaces_unauthorized_on_401() { + let app = Router::new().route( + "/channels/telegram/typing", + post(|| async { (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = BackendOAuthClient::new(&format!("http://{addr}")).unwrap(); + let err = client + .send_channel_typing("telegram", "mock-jwt") + .await + .unwrap_err(); + + let typed = err.downcast_ref::().unwrap(); + let BackendApiError::Unauthorized { method, path } = typed else { + panic!("expected Unauthorized, got {typed:?}"); + }; + assert_eq!(method, "POST"); + assert_eq!(path, "/channels/telegram/typing"); + // The session-expiry sentinel must still be derivable, so the dispatcher + // keeps routing this to re-sign-in rather than to Sentry. + assert!(flatten_authed_error(err).starts_with("SESSION_EXPIRED:")); +} + +// The SDK transport must inherit this crate's client, so the version headers +// and timeouts apply to SDK-backed calls exactly as they do to `authed_json`. +#[tokio::test] +async fn sdk_backed_calls_send_the_core_version_header() { + let seen: Arc>> = Arc::new(Mutex::new(None)); + let app = Router::new() + .route( + "/channels/telegram/typing", + post( + |State(state): State>>>, headers: HeaderMap| async move { + *state.lock().unwrap() = headers + .get("x-core-version") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + Json(json!({"success": true, "data": {}})) + }, + ), + ) + .with_state(seen.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = BackendOAuthClient::new(&format!("http://{addr}")).unwrap(); + client + .send_channel_typing("telegram", "mock-jwt") + .await + .unwrap(); + + assert_eq!( + seen.lock().unwrap().as_deref(), + Some(env!("CARGO_PKG_VERSION")) + ); +} diff --git a/vendor/tinyhumans-sdk b/vendor/tinyhumans-sdk new file mode 160000 index 000000000..ca47efe93 --- /dev/null +++ b/vendor/tinyhumans-sdk @@ -0,0 +1 @@ +Subproject commit ca47efe93d5fc9978cc8e19da2582ae2bc96284b