Files
mesh-catalog/modules/anthropic-manager/client.ts
jschoubben c206e2e11e anthropic model-access modules: manager (refreshable-grant) and consumer
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
2026-09-07 01:00:12 +02:00

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,
};
}