Skip to content

Commit 6f2fadf

Browse files
authored
fix(atlas): keep sessions through transient 403s, fix wallet gate edge cases, warn on stranded legacy config (#67)
- syncServices: only a 401 clears the session now. 403s also come from WAFs and rate limiters; treating them as revocation silently signed users out mid-session, which presented as the Atlas connection randomly dropping. - wallet gate: getBalance returns null (not -1) when the balance can't be determined, so an overdraft of exactly -$1 no longer reads as 'unavailable'; the gate blocks on any verified balance <= 0 instead of exactly 0 (negative wallets used to sail through); blocking drops the 30s balance cache so a top-up is visible on the next attempt. The wire format still encodes unknown as -1. - global: when both the legacy synsc dir and the new config dir exist, say the legacy one is ignored instead of silently stranding the auth and config inside it.
1 parent 591a5c8 commit 6f2fadf

5 files changed

Lines changed: 46 additions & 15 deletions

File tree

backend/cli/src/global/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ function migrateDir(base: string): string {
2222
return old
2323
}
2424
}
25+
// Both dirs existing means the legacy one was restored (backup, dotfiles)
26+
// after the new dir was created. It will never be read or migrated — say
27+
// so instead of silently stranding whatever auth/config lives in it.
28+
if (existsSync(next) && existsSync(old)) {
29+
console.error(`openscience: ignoring legacy config at ${old} (${next} already exists) — merge or remove it`)
30+
}
2531
return next
2632
}
2733

backend/cli/src/openscience/index.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,19 @@ export namespace OpenScience {
607607
})
608608

609609
if (!res.ok) {
610-
if (res.status === 401 || res.status === 403) {
610+
if (res.status === 401) {
611611
log.info("session invalid, clearing")
612612
await clearSession()
613613
return null
614614
}
615+
if (res.status === 403) {
616+
// 403s also come from WAFs and rate limiters, not just key
617+
// revocation. Destroying the session on one silently signed the
618+
// user out; keep it and let the next sync retry. A genuinely
619+
// revoked key comes back as 401.
620+
log.warn("sync got 403, keeping session")
621+
return null
622+
}
615623
if (res.status === 402) {
616624
// No active Atlas subscription. Don't clear the session
617625
// (the auth itself is fine) — surface the message so the
@@ -964,27 +972,40 @@ export namespace OpenScience {
964972
let cachedBalance: { value: number; at: number } | null = null
965973
const BALANCE_CACHE_TTL = 30 * 1000
966974

975+
/** Drop the cached balance so the next getBalance() refetches. Called when
976+
* the wallet gate blocks, so a top-up is visible on the next attempt
977+
* instead of after the cache TTL. */
978+
export function invalidateBalance() {
979+
cachedBalance = null
980+
}
981+
967982
/** Get current credit balance (cached for 30s).
968-
* Returns the balance in USD, or -1 on API failure (fail-closed). */
969-
export async function getBalance(): Promise<number> {
983+
* Returns the balance in USD, or null when it can't be determined (no
984+
* session, API failure). null is distinct from a real negative balance —
985+
* the old -1 sentinel collided with an overdraft of exactly -$1. */
986+
export async function getBalance(): Promise<number | null> {
970987
if (cachedBalance && Date.now() - cachedBalance.at < BALANCE_CACHE_TTL) {
971988
return cachedBalance.value
972989
}
973990
const session = await getSession()
974-
if (!session) return -1
991+
if (!session) return null
975992
try {
976993
const res = await fetch(`${API_BASE}/api/cli/balance`, {
977994
headers: { Authorization: `Bearer ${session.api_key}` },
978995
})
979-
if (!res.ok) return -1
996+
if (!res.ok) return null
980997
const data = await res.json()
981-
const usd = typeof data.balance_usd === "number"
982-
? data.balance_usd
983-
: (typeof data.balance_cents === "number" ? data.balance_cents / 100 : -1)
998+
const usd =
999+
typeof data.balance_usd === "number"
1000+
? data.balance_usd
1001+
: typeof data.balance_cents === "number"
1002+
? data.balance_cents / 100
1003+
: null
1004+
if (usd === null) return null
9841005
cachedBalance = { value: usd, at: Date.now() }
9851006
return usd
9861007
} catch {
987-
return -1
1008+
return null
9881009
}
9891010
}
9901011

backend/cli/src/server/routes/account.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export const AccountRoutes = lazy(() =>
6363
async (c) => {
6464
const session = await OpenScience.getSession()
6565
const sync = session ? await OpenScience.syncServices() : null
66-
const balance = session ? await OpenScience.getBalance() : -1
66+
// -1 is the wire encoding for "unknown" (schema: number)
67+
const balance = (session ? await OpenScience.getBalance() : null) ?? -1
6768
const billing = session ? await OpenScience.getBillingMode() : null
6869
return c.json({
6970
session: !!session,
@@ -85,7 +86,7 @@ export const AccountRoutes = lazy(() =>
8586
},
8687
},
8788
}),
88-
async (c) => c.json({ balance_usd: await OpenScience.getBalance() }),
89+
async (c) => c.json({ balance_usd: (await OpenScience.getBalance()) ?? -1 }),
8990
)
9091
.get(
9192
"/devices",

backend/cli/src/server/routes/settings/billing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const BillingPatch = z.object({
3030
async function readState(): Promise<BillingState> {
3131
const cfg = await Config.getGlobal()
3232
const session = await OpenScience.getSession().catch(() => null)
33-
const balanceUsd = session ? await OpenScience.getBalance().catch(() => -1) : -1
33+
const balanceUsd = (session ? await OpenScience.getBalance().catch(() => null) : null) ?? -1
3434
return {
3535
llm: cfg.billing?.llm ?? null,
3636
compute: cfg.billing?.compute ?? "byok",

backend/cli/src/session/processor.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,16 @@ export namespace SessionProcessor {
7070
}
7171

7272
// Pre-flight wallet check for managed-proxy calls only. Block ONLY on a
73-
// VERIFIED empty wallet (balance === 0). If the balance can't be verified
74-
// (-1: no/expired Atlas session or a transient error), do NOT hard-block —
73+
// VERIFIED empty or overdrafted wallet. If the balance can't be verified
74+
// (null: no/expired Atlas session or a transient error), do NOT hard-block —
7575
// the managed proxy is the billing authority and returns 402 if actually
7676
// out of credits. Hard-blocking here strands a user whose session lapsed.
7777
if (requiresWalletBalance(credentialSource)) {
7878
const balance = await OpenScience.getBalance()
79-
if (balance === 0) {
79+
if (balance !== null && balance <= 0) {
80+
// Drop the 30s cache so a top-up is visible on the next
81+
// attempt instead of blocking until the TTL expires.
82+
OpenScience.invalidateBalance()
8083
throw new Error(
8184
"Your Atlas wallet is empty. Top up at app.syntheticsciences.ai/cli, or switch LLM spend to BYOK in Settings → Spend — BYOK uses your own key and is never billed.",
8285
)

0 commit comments

Comments
 (0)