use crate::context::Context;
use crate::error::{Error, Result};
use crate::provider::ProviderId;
use crate::state::{Key, Park, State};
use crate::store;
use serde_json::Value;
const PREFIX: &str = "pitboard-park-";
pub fn service_name(account_uuid: &str, at_millis: i64) -> String {
format!("{PREFIX}{account_uuid}-{at_millis}")
}
pub fn is_park_name(service: &str) -> bool {
parts_of(service).is_some()
}
pub fn parts_of(service: &str) -> Option<(String, i64)> {
let rest = service.strip_prefix(PREFIX)?;
let (uuid, millis) = rest.rsplit_once('-')?;
let at_millis: i64 = millis.parse().ok()?;
(!uuid.is_empty()).then(|| (uuid.to_string(), at_millis))
}
pub fn reserve(ctx: &Context, account_uuid: &str) -> Result<String> {
let start = ctx.now_millis();
for offset in 0..1_000 {
let candidate = service_name(account_uuid, start + offset);
if store::vault_read(ctx, &candidate)?.is_none() {
crate::pending::reserve(ctx, &candidate)?;
return Ok(candidate);
}
}
Err(Error::ParkSlotExhausted)
}
pub fn price(
ctx: &Context,
provider: ProviderId,
label: &str,
service: &str,
document: &Value,
) -> Result<Option<crate::service::Warning>> {
let body = serde_json::to_string(document).expect("a credential slice is always serialisable");
let Some(price) = store::vault_cost(ctx, service, &body) else {
return Ok(None);
};
if price.refused() {
return Err(Error::CredentialTooLarge {
tool: provider,
label: label.to_string(),
bytes: price.needs,
limit: price.limit,
});
}
Ok(price
.on_the_second_route()
.then_some(crate::service::Warning::WrittenOnTheCommandLine {
tool: provider,
bytes: price.needs,
limit: price.limit,
}))
}
pub fn store_at(
ctx: &Context,
provider: ProviderId,
service: &str,
document: &Value,
) -> Result<Park> {
let park = describe(provider, service, ctx.now(), document);
if park.refresh_fingerprint.is_empty() {
return Err(Error::LiveCredentialShapeUnexpected {
tool: provider,
detail: "it has no refresh token, so it could never be restored".into(),
});
}
let body = serde_json::to_string(document).expect("a credential slice is always serialisable");
store::vault_write(ctx, service, &body)?;
Ok(park)
}
pub fn describe(provider: ProviderId, service: &str, parked_at: i64, document: &Value) -> Park {
let tool = crate::provider::of(provider);
let expiry = tool.expiry(document);
Park {
service: service.to_string(),
parked_at,
refresh_fingerprint: tool.fingerprint(document),
access_expires_at: expiry.access_expires_at,
refresh_expires_at: expiry.refresh_expires_at,
}
}
pub fn load(ctx: &Context, key: &Key, park: &Park) -> Result<Value> {
let label = key.typed();
let raw =
store::vault_read(ctx, &park.service)?.ok_or_else(|| Error::ParkedCredentialMissing {
label: label.clone(),
})?;
let value: Value = serde_json::from_str(&raw).map_err(|e| Error::ParkedCredentialCorrupt {
label: label.clone(),
detail: e.to_string(),
})?;
let found = crate::provider::of(key.provider).fingerprint(&value);
if park.refresh_fingerprint.is_empty() || found != park.refresh_fingerprint {
return Err(Error::ParkedCredentialCorrupt {
label,
detail: "it does not match the fingerprint pitboard recorded".into(),
});
}
Ok(value)
}
pub fn is_live_twin(ctx: &Context, provider: ProviderId, document: &Value) -> bool {
let tool = crate::provider::of(provider);
if tool.park_semantics() != crate::provider::ParkSemantics::MoveOnly {
return false;
}
let parked = tool.fingerprint(document);
!parked.is_empty() && live_fingerprint(ctx, provider).is_some_and(|live| live == parked)
}
fn live_fingerprint(ctx: &Context, provider: ProviderId) -> Option<String> {
let tool = crate::provider::of(provider);
let live = tool.read_live(ctx).ok().flatten()?;
Some(tool.fingerprint(&live.raw)).filter(|found| !found.is_empty())
}
pub fn live_twins(ctx: &Context, state: &State) -> Vec<String> {
let mut twins = Vec::new();
for &provider in ProviderId::ALL {
let held: Vec<&crate::state::Park> = state
.accounts
.iter()
.filter(|a| a.provider() == provider)
.filter_map(|a| a.parked.as_ref())
.collect();
if held.is_empty() || (provider == ProviderId::Claude && crate::settings::custom_oauth(ctx))
{
continue;
}
let Some(live) = live_fingerprint(ctx, provider) else {
continue;
};
twins.extend(
held.into_iter()
.filter(|park| park.refresh_fingerprint == live)
.map(|park| park.service.clone()),
);
}
twins
}
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::*;
use crate::store::memory::{Fault, MemoryHost};
use crate::time::FixedClock;
use serde_json::json;
use std::sync::Arc;
fn work() -> Key {
Key::new(ProviderId::Claude, "work")
}
fn oauth(token: &str) -> Value {
json!({
"refreshToken": token,
"accessToken": "a",
"expiresAt": 1_790_000_000_000i64,
"refreshTokenExpiresAt": 1_792_000_000_000i64
})
}
fn machine() -> (Context, Arc<MemoryHost>, Scratch) {
let root = std::env::temp_dir().join(format!(
"pitboard-park-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
let mem = MemoryHost::new();
let clock = Arc::new(FixedClock::at(1_760_000_000));
let ctx = Context::new(root.clone())
.with_pitboard_home(root.clone())
.with_memory_stores(Arc::clone(&mem))
.with_clock(clock as Arc<dyn crate::time::Clock>);
(ctx, mem, Scratch(root))
}
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_parked_login_reads_back_through_its_fingerprint() {
let (ctx, mem, _scratch) = machine();
let name = reserve(&ctx, "acc").expect("a free name");
let park = store_at(&ctx, ProviderId::Claude, &name, &oauth("r")).expect("stored");
assert_eq!(mem.vault().services(), vec![name.clone()]);
assert_eq!(load(&ctx, &work(), &park).expect("loads"), oauth("r"));
}
#[test]
fn a_park_that_vanished_and_one_that_cannot_be_read_are_different_answers() {
let (ctx, mem, _scratch) = machine();
let name = reserve(&ctx, "acc").expect("a free name");
let park = store_at(&ctx, ProviderId::Claude, &name, &oauth("r")).expect("stored");
mem.vault().fault(&name, Fault::Vanish);
assert!(matches!(
load(&ctx, &work(), &park),
Err(Error::ParkedCredentialMissing { .. })
));
mem.vault()
.fault(&name, Fault::Unreadable("the keychain is locked".into()));
assert!(
matches!(
load(&ctx, &work(), &park),
Err(Error::Store(crate::store::Error::Unreadable(_)))
),
"a store that could not answer must never read as an absent login"
);
}
#[test]
fn a_reserved_name_steps_past_one_that_is_taken() {
let (ctx, mem, _scratch) = machine();
let first = reserve(&ctx, "acc").expect("a free name");
mem.vault().plant(&first, "{}");
let second = reserve(&ctx, "acc").expect("another free name");
assert_ne!(first, second, "the clock has not moved, so the name must");
}
#[test]
fn a_login_with_no_refresh_token_is_never_parked() {
let (ctx, mem, _scratch) = machine();
let name = reserve(&ctx, "acc").expect("a free name");
assert!(matches!(
store_at(
&ctx,
ProviderId::Claude,
&name,
&json!({"accessToken": "a"})
),
Err(Error::LiveCredentialShapeUnexpected { .. })
));
assert!(mem.vault().services().is_empty());
}
#[test]
fn a_park_records_when_its_login_stops_working() {
let park = describe(
ProviderId::Claude,
"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,
crate::provider::claude::document::fingerprint_of(&serde_json::json!({
"refreshToken": "r"
}))
);
}
#[test]
fn service_names_carry_the_account_and_the_moment() {
let s = service_name("1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", 1789935600123);
assert_eq!(
s,
"pitboard-park-1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0-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(),
ProviderId::Claude,
"pitboard-park-test-no-refresh",
&serde_json::json!({"accessToken": "a"}),
);
assert!(refused.is_err());
}
}