Skip to content

Commit 442d63a

Browse files
committed
feat(rpc): key Tooling Access JWT cache by account (DX-6130)
Tooling Access endpoints are provisioned per account, not per API key, so the token cache now keys by account id via a two-level offline lookup: sha256(api_key) -> account_id -> { endpoint_url, token, exp_unix }. All API keys for one account share a single cached JWT. A cache hit resolves the account offline (no control-plane call); a miss learns the account id from account_info once, alongside the mint already occurring. tokens.toml gains a [keys] table (fingerprint -> account id) and per-account [tokens.<id>] entries. save/delete are read-modify-write so one account's token going stale never disturbs another's. An older on-disk schema is a cache miss and is rewritten in place. The custom-endpoint lane is unchanged. Adds config unit tests and updates the rpc integration tests: 137 lib tests plus the rpc suite cover shared-token reuse, zero calls on hit, one account_info on miss, per-account stale-token clear, old-schema miss, and 0600 with no key on disk.
1 parent 2690086 commit 442d63a

3 files changed

Lines changed: 379 additions & 134 deletions

File tree

src/commands/rpc.rs

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//! refreshes a short-lived session JWT automatically. Because each CLI
77
//! invocation is a fresh process, we persist that JWT to `tokens.toml` and
88
//! re-seed it next time (see `crate::config` token cache), so a valid token
9-
//! means no control-plane round trip.
9+
//! means no control-plane round trip. The cache is keyed by account id (all API
10+
//! keys for one account share a token); the account is resolved offline on a hit
11+
//! and learned via `account_info` only on a miss, where a mint already occurs.
1012
//!
1113
//! On a never-provisioned account the first call fails with "not enabled"; we
1214
//! offer to enable (prompt on a TTY, `--yes` for scripts/agents, otherwise an
@@ -104,10 +106,8 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> {
104106
let config_path = global.resolve_config_path();
105107
let networks_path = config::networks_cache_path(config_path.as_deref());
106108
let token_path = config::token_cache_path(config_path.as_deref());
107-
let seed = match (&token_path, resolve_key_quietly(&global)) {
108-
(Some(p), Some(key)) => config::load_token(p, &key),
109-
_ => None,
110-
};
109+
let (seed, _account_id) =
110+
load_cached_token(&token_path, resolve_key_quietly(&global).as_deref());
111111
let (ctx, _api_key) = Ctx::from_global_with_rpc_seed(global, seed, None)?;
112112
let map = ensure_networks(&ctx, networks_path.as_deref()).await?;
113113
emit_networks(&ctx, &map)
@@ -134,13 +134,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
134134
let token_path = config::token_cache_path(config_path.as_deref());
135135
let networks_path = config::networks_cache_path(config_path.as_deref());
136136

137-
// We don't know the API key until Ctx builds it, but the cache is keyed by
138-
// the key's fingerprint. Resolve it once here for the load; Ctx re-resolves
139-
// and returns it for the write-back (cheap, and keeps Ctx the source of truth).
140-
let seed = match (&token_path, resolve_key_quietly(&global)) {
141-
(Some(p), Some(key)) => config::load_token(p, &key),
142-
_ => None,
143-
};
137+
// The cache is keyed by account id, resolved offline from the key's
138+
// fingerprint. Resolve the key once here for the load; Ctx re-resolves and
139+
// returns it for the write-back (cheap, and keeps Ctx the source of truth).
140+
// A known key yields both a seed token and its account id, so a cache hit
141+
// never needs a control-plane account lookup on write-back.
142+
let (seed, mut account_id) =
143+
load_cached_token(&token_path, resolve_key_quietly(&global).as_deref());
144144

145145
let (ctx, api_key) = Ctx::from_global_with_rpc_seed(global, seed, config_endpoint_url)?;
146146

@@ -185,10 +185,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
185185
if disabled_per_status(&ctx).await {
186186
// The stale token points at the disabled endpoint. Drop it both
187187
// in memory (so this retry mints fresh) and on disk (so the next
188-
// process does too) before enabling and retrying.
188+
// process does too) before enabling and retrying. Only this
189+
// account's entry is removed; other accounts' tokens and every
190+
// key mapping in the shared file are preserved. A stale token
191+
// implies a cache hit, so `account_id` is known here.
189192
ctx.sdk.rpc.clear_cached_token();
190-
if let Some(p) = &token_path {
191-
let _ = config::delete_config(p);
193+
if let (Some(p), Some(id)) = (&token_path, account_id) {
194+
let _ = config::delete_account_token(p, id);
192195
}
193196
maybe_enable(&ctx).await?;
194197
call_after_enable(&ctx, method, &params, args.network.clone()).await?
@@ -203,13 +206,26 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
203206

204207
// Snapshot the (possibly refreshed) token and write it back. On the custom-URL
205208
// lane no token is minted, so `current_token()` is `None` and nothing is
206-
// written — the existing cache is left untouched. Otherwise we always persist
207-
// when a token is present: the write is an idempotent atomic replace, the
208-
// token is short-lived, and re-writing an unchanged token is harmless.
209-
// Best-effort — a cache write failure must not fail the call, which already
210-
// succeeded; the next run simply re-mints.
209+
// written — the existing cache is left untouched. Otherwise we persist under
210+
// the account id: an idempotent atomic replace preserving other accounts'
211+
// entries. On a cache hit `account_id` is already known (no extra call); on a
212+
// miss (a new key that just minted) we learn it from `account_info()` once,
213+
// which is cheap relative to the mint that just happened. Best-effort — a
214+
// cache write failure must not fail the call, which already succeeded.
211215
if let (Some(p), Some(current)) = (&token_path, ctx.sdk.rpc.current_token()) {
212-
let _ = config::save_token(p, &api_key, &current);
216+
if account_id.is_none() {
217+
account_id = ctx
218+
.sdk
219+
.admin
220+
.account_info()
221+
.await
222+
.ok()
223+
.and_then(|r| r.data)
224+
.map(|a| a.id);
225+
}
226+
if let Some(id) = account_id {
227+
let _ = config::save_token(p, &api_key, id, &current);
228+
}
213229
}
214230

215231
emit_result(&ctx, &result)
@@ -317,6 +333,27 @@ fn emit_networks(
317333
Ok(())
318334
}
319335

336+
/// Offline two-level cache load: resolve `key`'s account id from `[keys]`, then
337+
/// load that account's cached JWT. Returns `(seed, account_id)`. The account id
338+
/// is returned even when there's no token yet, so a subsequent write-back can
339+
/// reuse it without a control-plane `account_info` call. A cache miss (unknown
340+
/// key) yields `(None, None)`.
341+
fn load_cached_token(
342+
token_path: &Option<PathBuf>,
343+
key: Option<&str>,
344+
) -> (Option<quicknode_sdk::CachedToken>, Option<i64>) {
345+
let (Some(p), Some(key)) = (token_path, key) else {
346+
return (None, None);
347+
};
348+
let Some(account_id) = config::account_for_key(p, key) else {
349+
return (None, None);
350+
};
351+
(
352+
config::load_token_for_account(p, account_id),
353+
Some(account_id),
354+
)
355+
}
356+
320357
/// Resolve the API key without prompting, swallowing errors (the real
321358
/// resolution + error happens in `Ctx::build`). Used only to scope the cache
322359
/// load before `Ctx` is constructed.

0 commit comments

Comments
 (0)