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
50 lines
2.4 KiB
TypeScript
50 lines
2.4 KiB
TypeScript
// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the
|
|
// token or any API — it lives in a sibling CLI state file, `~/.claude.json` →
|
|
// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the
|
|
// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence
|
|
// account refuses rather than guesses, because delivering an access token to the wrong account is the
|
|
// exact fault this exists to catch.
|
|
//
|
|
// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded
|
|
// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does
|
|
// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied,
|
|
// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
export type IdentityVerdict =
|
|
| { state: "verified"; accountUuid: string }
|
|
| { state: "no-identity-file" }
|
|
| { state: "licence-not-adopted" }
|
|
| { state: "wrong-account"; found: string; expected: string };
|
|
|
|
interface ClaudeJson {
|
|
oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string };
|
|
}
|
|
|
|
/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */
|
|
export function readAccountUuid(path: string): string | null {
|
|
try {
|
|
const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson;
|
|
return raw.oauthAccount?.accountUuid ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its
|
|
* inputs so the fail-closed logic is tested without a filesystem.
|
|
*
|
|
* TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the
|
|
* control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the
|
|
* port map's five states. Until then only the two safe verdicts are reachable: verified when an
|
|
* expected uuid is provided and matches, refuse otherwise.
|
|
*/
|
|
export function check(found: string | null, expected: string | null): IdentityVerdict {
|
|
if (found === null) return { state: "no-identity-file" };
|
|
if (!expected) return { state: "licence-not-adopted" };
|
|
if (found === expected) return { state: "verified", accountUuid: found };
|
|
return { state: "wrong-account", found, expected };
|
|
}
|