// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq // ADR 0183, design 36 §5). Pure where it decides, so the rules are tested without a file. // // The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?, // scopes?, subscriptionType?, rateLimitTier? }, ... }`. A node never holds a refresh token, so the // one this module writes never carries one, and a full grant a login left behind is stripped the // moment the manager hands the node its own. // // The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same // licence is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a // switch to another licence is applied regardless, because across licences the expiries are // unrelated numbers. import { readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; export interface Grant { readonly accessToken: string; readonly expiresAt: number; readonly refreshTokenExpiresAt?: number | null; readonly scopes?: readonly string[] | null; readonly subscriptionType?: string | null; readonly rateLimitTier?: string | null; } export type ApplySource = "rotation" | "switch"; export type ApplyDecision = | { apply: true; reissued?: boolean } | { apply: false; reason: "already-current" } | { apply: false; reason: "not-newer"; localExpiresAt: number }; /** Two refresh-token expiries within a day are one lineage; a login starts a fresh window weeks away. */ export const GENERATION_TOLERANCE_MS = 24 * 60 * 60 * 1000; export function sameGeneration(a?: number | null, b?: number | null): boolean { if (a == null || b == null) return true; return Math.abs(Number(a) - Number(b)) <= GENERATION_TOLERANCE_MS; } export function decideApply(local: Grant | null | undefined, offered: Grant, source: ApplySource): ApplyDecision { if (!local?.accessToken) return { apply: true }; if (local.accessToken === offered.accessToken) return { apply: false, reason: "already-current" }; const reissued = !sameGeneration(local.refreshTokenExpiresAt, offered.refreshTokenExpiresAt); if (source === "rotation" && !reissued && Number(local.expiresAt) >= Number(offered.expiresAt)) { return { apply: false, reason: "not-newer", localExpiresAt: Number(local.expiresAt) }; } return reissued ? { apply: true, reissued: true } : { apply: true }; } type Oauth = Record & { accessToken?: string; refreshToken?: string; expiresAt?: number }; type Credentials = Record & { claudeAiOauth?: Oauth }; export function readCredentials(path: string): Credentials | null { try { const parsed = JSON.parse(readFileSync(path, "utf8")) as Credentials; return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } /** The grant the file holds, or null. */ export function grantOf(creds: Credentials | null): Grant | null { const o = creds?.claudeAiOauth; if (!o?.accessToken) return null; return { accessToken: o.accessToken, expiresAt: Number(o.expiresAt ?? 0), refreshTokenExpiresAt: o.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt), }; } /** Does the file hold a full grant — a refresh token this module never writes, so a person's login? */ export function holdsLogin(creds: Credentials | null): boolean { return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0; } /** Overlay the handed grant on what is there, and delete any refresh token. */ export function withGrant(local: Credentials | null, grant: Grant): Credentials { const next: Credentials = { ...(local ?? {}) }; const oauth: Oauth = { ...(local?.claudeAiOauth ?? {}) }; oauth.accessToken = grant.accessToken; oauth.expiresAt = grant.expiresAt; for (const k of ["refreshTokenExpiresAt", "scopes", "subscriptionType", "rateLimitTier"] as const) { const v = grant[k]; if (v != null) oauth[k] = v as unknown; } delete oauth.refreshToken; next.claudeAiOauth = oauth; return next; } /** Write atomically at 0600: a partial credentials file must never be read as a whole one. */ export function writeCredentials(path: string, creds: Credentials): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const tmp = `${path}.mesh-tmp`; writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 }); renameSync(tmp, path); }