mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
refactor(api): vendor tinyhumans-sdk and route the backend client through it (#5232)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+14
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
+1
Submodule vendor/tinyhumans-sdk added at ca47efe93d
Reference in New Issue
Block a user