Files
zooqueen 6a4ad27c7c client: the platform client is a crate, and the CLI is one consumer of it
There was no Rust client for api.hanzo.ai anywhere in the estate — hanzoai/rust-sdk
is the agent/MCP/crypto SDK and carries no HTTP client for the platform — so every
Rust caller that wanted one had to write a transport. This binary had already
written one, in src/http.rs, and that is the file this deletes.

crates/client holds what the shipped TypeScript contract holds (@hanzo/ai grant.ts,
result.ts, http.ts), with the same four rules and the same names:

  as(subject)   one credential scoped to ONE end user, minted through IAM's act
                grant at POST {issuer}/v1/iam/tokens/issue?id=<subject> — IAM's own
                host, target in the query, no body, camelCase back. Held until 30s
                before expiry, dropped on a 401 which re-mints and retries once. No
                method takes a user id, so none can be passed wrong.
  Outcome<T>    a 202 is a success status, so a held call is a separate arm of one
                enum sharing no value with Done. Rust can enforce what TypeScript
                could only encourage: reading a held call as done does not misbehave
                at runtime, it fails to compile.
  Error         one enum. A non-2xx is Error::Api{status, body} — the server's own
                words, never a string, never a loose map.
  Transport     one request in, one Reply{status, body} out, status handed back and
                never flattened. A stub over the same trait proves the mint address,
                the cache, the re-mint and the held arm without a network.

src/zap.rs now returns that same Reply, so the two wires the CLI speaks meet at
Seam::send with no conversion between them — and zap stops widening its own u16 into
a StatusCode only for the caller to ask is_success() of it.

Spelled r#as at the call site because `as` is a Rust keyword. The name is `as`.
2026-08-21 07:25:20 -07:00

109 lines
3.7 KiB
Rust

//! The act grant: an operator credential, minted down to ONE subject.
//!
//! IAM issues the token from the org credential the caller already holds plus a
//! grant naming the subject. It answers on IAM's OWN host and the path carries
//! the `iam` segment — the platform API does not serve this. The target rides in
//! the `id` query, never in a body, because IAM reads the grant off the key
//! itself.
//!
//! The token is reused until it nears expiry and dropped on a 401, so a caller
//! never manages one and a request never rides an about-to-die token.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::error::Error;
use crate::http::{Method, Request, Transport};
/// IAM's canonical mint. The platform API answers this with a 404.
pub const ISSUE: &str = "/v1/iam/tokens/issue";
/// Where IAM answers. Override for a private estate.
pub const ISSUER: &str = "https://hanzo.id";
/// Re-mint this far ahead of expiry.
const SKEW: Duration = Duration::from_secs(30);
/// The lifetime assumed when IAM states none.
const TTL: Duration = Duration::from_secs(300);
/// IAM answers camelCase.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Issued {
access_token: Option<String>,
/// Seconds from now.
expires_in: Option<u64>,
}
struct Minted {
token: String,
expires: Instant,
}
/// Mints and holds the token that lets one credential act as one subject.
pub struct Grant {
subject: String,
issuer: String,
held: Mutex<Option<Minted>>,
}
impl Grant {
pub fn new(subject: impl Into<String>, issuer: impl Into<String>) -> Self {
Grant { subject: subject.into(), issuer: issuer.into(), held: Mutex::new(None) }
}
pub fn subject(&self) -> &str {
&self.subject
}
/// The live token for this subject, minted only when there is not already a
/// good one.
pub async fn token<T: Transport>(&self, wire: &T, key: &str) -> Result<String, Error> {
match self.cached() {
Some(token) => Ok(token),
None => self.mint(wire, key).await,
}
}
/// Drop the held token so the next call re-mints. Called once after a 401.
pub fn invalidate(&self) {
*self.held.lock().unwrap() = None;
}
fn cached(&self) -> Option<String> {
let held = self.held.lock().unwrap();
let minted = held.as_ref()?;
(minted.expires.saturating_duration_since(Instant::now()) > SKEW)
.then(|| minted.token.clone())
}
async fn mint<T: Transport>(&self, wire: &T, key: &str) -> Result<String, Error> {
let url = url(&self.issuer, &self.subject)?;
let reply = wire.send(Request::new(Method::POST, url).token(key)).await?;
let status = reply.status;
let issued: Issued = serde_json::from_value(reply.ok()?)
.map_err(|source| Error::Decode { status, source })?;
let token = issued
.access_token
.filter(|t| !t.is_empty())
.ok_or_else(|| Error::Auth { subject: self.subject.clone() })?;
let life = issued.expires_in.map_or(TTL, Duration::from_secs);
*self.held.lock().unwrap() =
Some(Minted { token: token.clone(), expires: Instant::now() + life });
Ok(token)
}
}
/// The mint address. The subject goes through the URL crate's own encoder, so a
/// subject holding `&` or a space cannot forge a second parameter.
fn url(issuer: &str, subject: &str) -> Result<String, Error> {
let base = format!("{}{ISSUE}", issuer.trim_end_matches('/'));
let mut url = reqwest::Url::parse(&base)
.map_err(|e| Error::Wire { url: base.clone(), cause: e.to_string() })?;
url.query_pairs_mut().append_pair("id", subject);
Ok(url.to_string())
}