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
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user