Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
// The only file that talks to Anthropic — the vendor half of the refreshable-grant adapter
|
|
// (novox/hq ADR 0050). Isolated exactly as cloudflare-dns isolates its registrar call, so the
|
|
// vendor is swappable and the one place a token endpoint is reached is auditable.
|
|
//
|
|
// Two endpoints, and they are different hosts (port-map "don't-map" #1): the TOKEN host mints a new
|
|
// access token from the refresh token; the USAGE host reports utilisation against an access token.
|
|
|
|
/** The token endpoint, overridable so the lab can point the whole flow at a stub without a vendor. */
|
|
export function tokenEndpoint(env = process.env): string {
|
|
return env.MESH_ANTHROPIC_TOKEN_ENDPOINT ?? "https://platform.claude.com/v1/oauth/token";
|
|
}
|
|
|
|
/** The usage endpoint, likewise overridable for the lab. */
|
|
export function usageEndpoint(env = process.env): string {
|
|
return env.MESH_ANTHROPIC_USAGE_ENDPOINT ?? "https://api.anthropic.com/api/oauth/usage";
|
|
}
|
|
|
|
// The OAuth client id is a hard-won constant, ported byte-exact from the mature implementation: a
|
|
// metadata URL in its place yields 400. It is not a secret (it identifies the public Claude Code
|
|
// client), so it lives in code.
|
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
|
|
/** The vendor's token response, snake_case as the wire has it. */
|
|
export interface RefreshedGrant {
|
|
readonly access_token?: string;
|
|
readonly refresh_token?: string;
|
|
readonly expires_in?: number;
|
|
readonly refresh_token_expires_in?: number;
|
|
readonly scopes?: string[];
|
|
readonly subscription_type?: string;
|
|
}
|
|
|
|
/**
|
|
* Exchange a refresh token for a fresh grant. Returns null on any non-ok response, surfacing the
|
|
* OAuth error body (invalid_grant/invalid_client/…) — the difference between "the token is dead" and
|
|
* "the endpoint was unreachable", which a bare status hides.
|
|
*/
|
|
export async function refreshGrant(
|
|
refreshToken: string,
|
|
env = process.env,
|
|
): Promise<RefreshedGrant | null> {
|
|
const resp = await fetch(tokenEndpoint(env), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: CLIENT_ID,
|
|
}),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.text().catch(() => "<unreadable>");
|
|
console.error(
|
|
`[anthropic-manager] token refresh failed: ${resp.status} ${resp.statusText} — ${body.slice(0, 400)}`,
|
|
);
|
|
return null;
|
|
}
|
|
return (await resp.json()) as RefreshedGrant;
|
|
}
|
|
|
|
/** The vendor's usage response — utilisation percentages against several windows. */
|
|
export interface UsageLimits {
|
|
readonly five_hour?: { utilization: number; resets_at?: string };
|
|
readonly seven_day?: { utilization: number; resets_at?: string };
|
|
readonly seven_day_sonnet?: { utilization: number; resets_at?: string };
|
|
readonly seven_day_opus?: { utilization: number; resets_at?: string };
|
|
readonly extra_usage?: { utilization: number };
|
|
readonly [key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Read utilisation for an access token. Never refreshes here (a 401 is just reported): a second
|
|
* refresh source racing the first is the fault the mature implementation warns against.
|
|
*/
|
|
export async function readUsage(accessToken: string, env = process.env): Promise<UsageLimits | null> {
|
|
const resp = await fetch(usageEndpoint(env), {
|
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.text().catch(() => "");
|
|
console.error(`[anthropic-manager] usage endpoint returned ${resp.status}: ${body.slice(0, 200)}`);
|
|
return null;
|
|
}
|
|
return (await resp.json()) as UsageLimits;
|
|
}
|
|
|
|
/** The licence-grain reading ADR 0054 fixes, flattened from the vendor's windows. */
|
|
export interface UsageReading {
|
|
readonly sessionPct: number | null;
|
|
readonly sessionResetsAt: string | null;
|
|
readonly weeklyPct: number | null;
|
|
readonly sonnetPct: number | null;
|
|
readonly extraPct: number | null;
|
|
readonly raw: UsageLimits;
|
|
}
|
|
|
|
export function flattenUsage(u: UsageLimits): UsageReading {
|
|
return {
|
|
sessionPct: u.five_hour?.utilization ?? null,
|
|
sessionResetsAt: u.five_hour?.resets_at ?? null,
|
|
weeklyPct: u.seven_day?.utilization ?? null,
|
|
sonnetPct: u.seven_day_sonnet?.utilization ?? null,
|
|
extraPct: u.extra_usage?.utilization ?? null,
|
|
raw: u,
|
|
};
|
|
}
|
|
|
|
/** The access-token-only grant a holder is delivered — the port-map credential-file shape's fields. */
|
|
export interface AccessGrant {
|
|
readonly accessToken: string;
|
|
readonly expiresAt: number | null;
|
|
readonly refreshTokenExpiresAt: number | null;
|
|
readonly scopes: string[] | null;
|
|
readonly subscriptionType: string | null;
|
|
}
|
|
|
|
/**
|
|
* Turn a vendor refresh into what the manager submits: the access-token-only grant for holders, and
|
|
* the rotated refresh token if the vendor sent one. Never clobbers a good grant from an empty
|
|
* response — no access_token means the caller keeps what it had.
|
|
*/
|
|
export function grantFromRefresh(r: RefreshedGrant, nowMs: number): { access: AccessGrant; rotatedRefresh: string | null } | null {
|
|
if (!r.access_token) return null;
|
|
return {
|
|
access: {
|
|
accessToken: r.access_token,
|
|
expiresAt: typeof r.expires_in === "number" ? nowMs + r.expires_in * 1000 : null,
|
|
refreshTokenExpiresAt:
|
|
typeof r.refresh_token_expires_in === "number" ? nowMs + r.refresh_token_expires_in * 1000 : null,
|
|
scopes: r.scopes ?? null,
|
|
subscriptionType: r.subscription_type ?? null,
|
|
},
|
|
rotatedRefresh: r.refresh_token ?? null,
|
|
};
|
|
}
|