refactor(api): vendor tinyhumans-sdk and route the backend client through it (#5232)

This commit is contained in:
Steven Enamakel
2026-07-28 15:24:40 +03:00
committed by GitHub
parent 5758e4ecdf
commit 61fb2d277c
11 changed files with 214 additions and 66 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+3
View File
@@ -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
+29 -10
View File
@@ -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/`)
Generated
-1
View File
@@ -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",
+10 -1
View File
@@ -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"
+14
View File
@@ -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"
+19 -51
View File
@@ -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<serde_json::Value> {
// 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<serde_json::Value> {
/// 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<DateTime<Utc>> {
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::<Utc>::from_timestamp(exp, 0)
DateTime::<Utc>::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);
}
}
+34 -1
View File
@@ -224,6 +224,39 @@ fn build_backend_reqwest_client() -> Result<Client> {
.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<Value> {
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<String, Value>) -> Option<String> {
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
+102
View File
@@ -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::<BackendApiError>().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::<BackendApiError>().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<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let app = Router::new()
.route(
"/channels/telegram/typing",
post(
|State(state): State<Arc<Mutex<Option<String>>>>, 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"))
);
}
Vendored Submodule
+1
Submodule vendor/tinyhumans-sdk added at ca47efe93d