Skip to content

Commit cceb97d

Browse files
committed
feat(databases run): child ApiClient consumes HOTDATA_DATABASE_TOKEN + refreshes near expiry
1 parent 447c349 commit cceb97d

3 files changed

Lines changed: 101 additions & 17 deletions

File tree

src/api.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,33 @@ impl ApiClient {
5050

5151
// Auth source precedence:
5252
//
53-
// 1. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child
53+
// 1. `HOTDATA_DATABASE_TOKEN` env var — a `databases run` child
54+
// is executing with the parent's credentials scrubbed and a
55+
// database-scoped JWT injected. Refresh in-memory via
56+
// `HOTDATA_DATABASE_REFRESH_TOKEN` near expiry; never write
57+
// to disk (the child's FS may not be writable).
58+
// 2. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child
5459
// is executing with the parent's credentials scrubbed.
5560
// Refresh in-memory via `HOTDATA_SANDBOX_REFRESH_TOKEN` if
5661
// the JWT is close to expiry; never write to disk (the
5762
// child's FS may not be writable).
58-
// 2. `~/.hotdata/sandbox_session.json` — the user ran
63+
// 3. `~/.hotdata/sandbox_session.json` — the user ran
5964
// `hotdata sandbox set <id>` (or `sandbox new` / `sandbox
6065
// run` in the parent shell). The sandbox JWT is the active
6166
// bearer for *every* command until `sandbox set` (with no
6267
// id) clears the file.
63-
// 3. `~/.hotdata/session.json` + optional api_key fallback —
68+
// 4. `~/.hotdata/session.json` + optional api_key fallback —
6469
// normal user-scoped CLI session.
6570
let api_url = profile_config.api_url.to_string();
66-
let access_token = if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() {
71+
let access_token = if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() {
72+
match crate::database_session::refresh_from_env(&api_url) {
73+
Some(t) => t,
74+
None => {
75+
eprintln!("{}", "error: HOTDATA_DATABASE_TOKEN is empty".red());
76+
std::process::exit(1);
77+
}
78+
}
79+
} else if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() {
6780
match crate::sandbox_session::refresh_from_env(&api_url) {
6881
Some(t) => t,
6982
None => {
@@ -118,7 +131,9 @@ impl ApiClient {
118131
}
119132
profile_config.sandbox
120133
}),
121-
database_id: workspace_id.and_then(|ws| crate::config::load_current_database("default", ws)),
134+
database_id: std::env::var("HOTDATA_DATABASE").ok().or_else(|| {
135+
workspace_id.and_then(|ws| crate::config::load_current_database("default", ws))
136+
}),
122137
}
123138
}
124139

src/database_session.rs

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,7 @@ use std::io::Write;
1616
use std::path::PathBuf;
1717
use std::time::{SystemTime, UNIX_EPOCH};
1818

19-
// The refresh path below (REFRESH_LEEWAY_SECONDS, now_unix, MintResponse,
20-
// redact, refresh, session_from_response) mirrors sandbox_session.rs and is
21-
// covered by tests, but has no production caller yet: it's reserved for when
22-
// a child of `databases run` re-mints an expiring HOTDATA_DATABASE_TOKEN
23-
// (the child-side ApiClient consumption is not wired up yet). Annotated
24-
// #[allow(dead_code)] until that lands so the build stays warning-clean.
25-
#[allow(dead_code)]
19+
/// Refresh ahead of expiry to avoid racing it.
2620
const REFRESH_LEEWAY_SECONDS: u64 = 60;
2721

2822
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -74,15 +68,13 @@ pub fn clear() {
7468
}
7569
}
7670

77-
#[allow(dead_code)] // Part of the reserved refresh path (see REFRESH_LEEWAY_SECONDS).
7871
fn now_unix() -> u64 {
7972
SystemTime::now()
8073
.duration_since(UNIX_EPOCH)
8174
.map(|d| d.as_secs())
8275
.unwrap_or(0)
8376
}
8477

85-
#[allow(dead_code)] // Part of the reserved refresh path (see REFRESH_LEEWAY_SECONDS).
8678
#[derive(Deserialize)]
8779
pub(crate) struct MintResponse {
8880
token: String,
@@ -92,15 +84,13 @@ pub(crate) struct MintResponse {
9284
refresh_expires_in: u64,
9385
}
9486

95-
#[allow(dead_code)] // Part of the reserved refresh path (see REFRESH_LEEWAY_SECONDS).
9687
fn redact(s: &str) -> String {
9788
util::mask_credential(s)
9889
}
9990

10091
/// Trade a refresh token for a fresh database JWT (no rotation). Same
10192
/// endpoint as the new-mint path: `POST /v1/auth/database` with
10293
/// grant_type=refresh_token.
103-
#[allow(dead_code)] // Part of the reserved refresh path (see REFRESH_LEEWAY_SECONDS).
10494
pub fn refresh(api_url: &str, refresh_token: &str) -> Result<DatabaseSession, String> {
10595
let url = format!("{}/auth/database", api_url.trim_end_matches('/'));
10696
let body = serde_json::json!({
@@ -135,7 +125,6 @@ pub fn refresh(api_url: &str, refresh_token: &str) -> Result<DatabaseSession, St
135125
/// to). For refresh, `workspace_id` is left blank — the caller fills it
136126
/// from the prior session, since the database-id ↔ workspace mapping is
137127
/// invariant across refreshes.
138-
#[allow(dead_code)] // Part of the reserved refresh path (see REFRESH_LEEWAY_SECONDS).
139128
pub(crate) fn session_from_response(resp: MintResponse, workspace_id: String) -> DatabaseSession {
140129
let now = now_unix();
141130
DatabaseSession {
@@ -148,6 +137,81 @@ pub(crate) fn session_from_response(resp: MintResponse, workspace_id: String) ->
148137
}
149138
}
150139

140+
/// Decode a JWT's payload (without verifying the signature) and pull
141+
/// out the named string claim. Returns `None` if the token is
142+
/// unparseable or the claim is missing.
143+
fn jwt_string_claim(token: &str, claim: &str) -> Option<String> {
144+
use base64::Engine;
145+
let parts: Vec<&str> = token.split('.').collect();
146+
if parts.len() < 2 {
147+
return None;
148+
}
149+
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
150+
.decode(parts[1].as_bytes())
151+
.ok()?;
152+
let value: serde_json::Value = serde_json::from_slice(&payload).ok()?;
153+
value.get(claim).and_then(|v| v.as_str()).map(String::from)
154+
}
155+
156+
/// Decode the `exp` claim out of a JWT without verifying the signature.
157+
/// Returns `None` if the token is unparseable; in that case the caller
158+
/// should treat it as expired (force-refresh or fail).
159+
fn jwt_exp(token: &str) -> Option<u64> {
160+
use base64::Engine;
161+
let parts: Vec<&str> = token.split('.').collect();
162+
if parts.len() < 2 {
163+
return None;
164+
}
165+
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
166+
.decode(parts[1].as_bytes())
167+
.ok()?;
168+
let value: serde_json::Value = serde_json::from_slice(&payload).ok()?;
169+
value.get("exp").and_then(|v| v.as_u64())
170+
}
171+
172+
/// If `HOTDATA_DATABASE_TOKEN` is set in the environment, return
173+
/// `(token, database_id)` — the database id read from the JWT's
174+
/// `database` claim. Returns `None` if no env var is set, or if the
175+
/// token isn't a parseable JWT (in which case we can still use it as
176+
/// a bearer but can't identify the database).
177+
pub fn database_token_in_use() -> Option<(String, Option<String>)> {
178+
let token = std::env::var("HOTDATA_DATABASE_TOKEN").ok()?;
179+
if token.is_empty() {
180+
return None;
181+
}
182+
let database_id = jwt_string_claim(&token, "database");
183+
Some((token, database_id))
184+
}
185+
186+
/// In-child equivalent of a parent-side `ensure_access_token`: operates
187+
/// on env vars only. Used by [`crate::api::ApiClient`] when the parent
188+
/// `databases run` already passed in `HOTDATA_DATABASE_TOKEN` and
189+
/// `HOTDATA_DATABASE_REFRESH_TOKEN`. The new tokens are *not* persisted
190+
/// to disk — the child may not have write access to the parent's
191+
/// config dir (sandboxed FS), and re-doing the refresh on the next
192+
/// invocation costs one HTTP call.
193+
///
194+
/// Falls back to the current `HOTDATA_DATABASE_TOKEN` value if a
195+
/// refresh isn't needed or fails.
196+
pub fn refresh_from_env(api_url: &str) -> Option<String> {
197+
let current = std::env::var("HOTDATA_DATABASE_TOKEN").ok()?;
198+
let needs_refresh = match jwt_exp(&current) {
199+
Some(exp) => exp.saturating_sub(REFRESH_LEEWAY_SECONDS) <= now_unix(),
200+
None => true,
201+
};
202+
if !needs_refresh {
203+
return Some(current);
204+
}
205+
let rt = std::env::var("HOTDATA_DATABASE_REFRESH_TOKEN").ok()?;
206+
if rt.is_empty() {
207+
return Some(current);
208+
}
209+
match refresh(api_url, &rt) {
210+
Ok(new_session) => Some(new_session.access_token),
211+
Err(_) => Some(current),
212+
}
213+
}
214+
151215
#[cfg(test)]
152216
mod tests {
153217
use super::*;

src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ extern "C" fn print_sandbox_footer() {
136136

137137
extern "C" fn print_database_footer() {
138138
use crossterm::style::Stylize;
139+
// Inside a `databases run` child the parent already announced the
140+
// database at spawn; mirror sandbox's footer suppression.
141+
if database_session::database_token_in_use().is_some() {
142+
return;
143+
}
139144
if let Some(ws_id) = ACTIVE_WORKSPACE_ID.get() {
140145
if let Some(id) = config::load_current_database("default", ws_id) {
141146
eprintln!(

0 commit comments

Comments
 (0)