use crate::context::Context;
use crate::error::{Error, Result};
use crate::state::{Park, State};
use crate::{api, store, time};
use serde_json::{Value, json};
pub fn service_name(account_uuid: &str, at_millis: i64) -> String {
format!("pitboard-park-{account_uuid}-{at_millis}")
}
pub fn reserve(ctx: &Context, account_uuid: &str) -> Result<String> {
let start = time::now_millis();
for offset in 0..1_000 {
let candidate = service_name(account_uuid, start + offset);
if store::vault_read(ctx, &candidate)?.is_none() {
return Ok(candidate);
}
}
Err(Error::ParkSlotExhausted)
}
pub fn store_at(ctx: &Context, service: &str, oauth: &Value) -> Result<Park> {
let park = describe(service, time::now(), oauth);
if park.refresh_fingerprint.is_empty() {
return Err(Error::LiveCredentialShapeUnexpected {
detail: "it has no refresh token, so it could never be restored".into(),
});
}
let body = serde_json::to_string(oauth).expect("an oauth block is always serialisable");
store::vault_write(ctx, service, &body)?;
Ok(park)
}
pub fn describe(service: &str, parked_at: i64, oauth: &Value) -> Park {
let expiry = |key: &str| oauth.get(key).and_then(Value::as_i64).map(|ms| ms / 1000);
Park {
service: service.to_string(),
parked_at,
refresh_fingerprint: fingerprint_of(oauth),
access_expires_at: expiry("expiresAt"),
refresh_expires_at: expiry("refreshTokenExpiresAt"),
}
}
pub fn renewed(oauth: &Value, fresh: &api::Renewed, now_millis: i64) -> Value {
let mut next = oauth.clone();
let Some(fields) = next.as_object_mut() else {
return next;
};
fields.insert("accessToken".into(), json!(fresh.access_token));
if let Some(refresh) = &fresh.refresh_token {
fields.insert("refreshToken".into(), json!(refresh));
}
fields.insert(
"expiresAt".into(),
json!(now_millis + fresh.expires_in * 1000),
);
if let Some(seconds) = fresh.refresh_token_expires_in {
fields.insert(
"refreshTokenExpiresAt".into(),
json!(now_millis + seconds * 1000),
);
}
if let Some(scopes) = &fresh.scopes {
fields.insert("scopes".into(), json!(scopes));
}
next
}
pub fn fingerprint_of(oauth: &Value) -> String {
oauth
.get("refreshToken")
.and_then(Value::as_str)
.map(store::fingerprint)
.unwrap_or_default()
}
pub fn load(ctx: &Context, label: &str, park: &Park) -> Result<Value> {
let raw =
store::vault_read(ctx, &park.service)?.ok_or_else(|| Error::ParkedCredentialMissing {
label: label.to_string(),
})?;
let value: Value = serde_json::from_str(&raw).map_err(|e| Error::ParkedCredentialCorrupt {
label: label.to_string(),
detail: e.to_string(),
})?;
if park.refresh_fingerprint.is_empty() || fingerprint_of(&value) != park.refresh_fingerprint {
return Err(Error::ParkedCredentialCorrupt {
label: label.to_string(),
detail: "it does not match the fingerprint pitboard recorded".into(),
});
}
Ok(value)
}
pub fn purge(ctx: &Context, state: &mut State) -> usize {
state
.discarded
.retain(|service| store::vault_delete(ctx, service).is_err());
state.discarded.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_park_records_when_its_login_stops_working() {
let park = describe(
"pitboard-park-x-1",
50,
&serde_json::json!({
"refreshToken": "r",
"expiresAt": 1_790_000_000_123i64,
"refreshTokenExpiresAt": 1_792_000_000_999i64
}),
);
assert_eq!(park.access_expires_at, Some(1_790_000_000));
assert_eq!(park.refresh_expires_at, Some(1_792_000_000));
assert_eq!(
park.refresh_fingerprint,
fingerprint_of(&serde_json::json!({"refreshToken": "r"}))
);
}
#[test]
fn a_renewed_login_is_stored_as_claude_code_stores_its_own() {
let parked = json!({
"accessToken": "a1", "refreshToken": "r1", "expiresAt": 1,
"refreshTokenExpiresAt": 2, "scopes": ["user:inference"],
"subscriptionType": "max", "rateLimitTier": "default_claude_max_20x"
});
let fresh = api::Renewed {
access_token: "a2".into(),
refresh_token: Some("r2".into()),
expires_in: 60,
refresh_token_expires_in: Some(120),
scopes: None,
};
let next = renewed(&parked, &fresh, 1_000_000);
assert_eq!(next["accessToken"], "a2");
assert_eq!(next["refreshToken"], "r2");
assert_eq!(next["expiresAt"], 1_060_000);
assert_eq!(next["refreshTokenExpiresAt"], 1_120_000);
assert_eq!(
next["scopes"],
json!(["user:inference"]),
"kept when not answered"
);
assert_eq!(
next["subscriptionType"], "max",
"what renewal does not touch stays"
);
let kept = api::Renewed {
refresh_token: None,
refresh_token_expires_in: None,
..fresh
};
let next = renewed(&parked, &kept, 1_000_000);
assert_eq!(
next["refreshToken"], "r1",
"the server kept the refresh token"
);
assert_eq!(next["refreshTokenExpiresAt"], 2);
}
#[test]
fn service_names_carry_the_account_and_the_moment() {
let s = service_name("9aeb9c89-316c-4344-84c5-603d71dc5c9a", 1789935600123);
assert_eq!(
s,
"pitboard-park-9aeb9c89-316c-4344-84c5-603d71dc5c9a-1789935600123"
);
assert!(
s.starts_with("pitboard-park-"),
"must never collide with a Claude Code item"
);
}
#[test]
fn a_credential_with_no_refresh_token_is_refused_rather_than_parked() {
let refused = store_at(
&Context::from_env(),
"pitboard-park-test-no-refresh",
&serde_json::json!({"accessToken": "a"}),
);
assert!(refused.is_err());
}
#[test]
fn a_credential_with_no_refresh_token_fingerprints_to_nothing_rather_than_panicking() {
assert_eq!(fingerprint_of(&serde_json::json!({"accessToken": "a"})), "");
}
}