diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 841e009..f3f80de 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -11,17 +11,18 @@ "binds": { "mcp-endpoint": "${dir:state}/mcp-endpoint.json" }, - "consumes": [ - "claude-licence-manager.licence.rotated", - "claude-licence-manager.licence.switched" - ], "state": [ - "servers" + "servers", + "holdings" + ], + "reads": [ + "claude-licence-manager.bindings" ], "tools": [ "claude_code_status", "claude_code_render", "claude_code_pull", + "claude_code_grant", "claude_code_mcp_list", "claude_code_mcp_register", "claude_code_mcp_unregister" diff --git a/modules/claude-code/node.ts b/modules/claude-code/node.ts index 98c8b59..7090108 100644 --- a/modules/claude-code/node.ts +++ b/modules/claude-code/node.ts @@ -2,19 +2,21 @@ // bus and a way to emit an event — so every path is tested without a bus (novox/hq design 36 §4–§5, // ADR 0183, ADR 0198). // -// **Over NATS, in two kinds** (design 32 §10): an event says that something happened and carries no -// secret, because a stream keeps it; a token travels on a request, which nothing keeps. So: -// - the licence manager's `licence.rotated` and `licence.switched` events tell this module to ask the -// seat for its current token, sealed to the key it sends with the request; -// - a login a person made here — a refresh token this module never writes — is offered to the seat at -// once, sealed to the seat's key: the one moment a refresh token travels, because the login made the -// manager's stale; -// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0201): one -// key per server in the module's `servers` bucket — `all.` for every node, `.` -// for one — which every node watches. A node that joins later, or was off, reads the whole current set -// at start; unregistering is a delete. A secret never goes in an entry: the runtime refuses one. +// **Over NATS** (design 32 §10, ADR 0201, ADR 0206): what is *current* is state, a secret only ever +// travels on a request, sealed to its one recipient, and nothing is an event any more: +// - **what this node holds** is the module's `holdings` state, one key per node: the account, the kind, +// fingerprints and expiries — never a token. Written at start and on every change of the credentials +// file, so the licence manager learns a login, or a node already logged in, from the state alone; +// - **the grant itself** leaves only when the manager asks `claude_code_grant`, sealed to the key it +// gives — the manager adopts a licence by refreshing it, and from then on is its only refresher; +// - **what this node should hold** is the manager's `bindings` state; a newer generation for this node +// is fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only, +// so the agent here never refreshes and a refresh token appearing later is a person's login; +// - an MCP server registered through this module is a key in its `servers` state — `all.` for +// every node, `.` for one — which every node watches. -import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { join } from "node:path"; import { render, entryProblem, MANAGED_DIR, type Binding, type Facts, type Settings, type Servers } from "./render.js"; @@ -23,6 +25,13 @@ import { decideApply, grantOf, holdsLogin, readCredentials, replacedBy, withGran import { readIdentity, writeIdentity, type Identity } from "./identity.js"; export const SEAT = "anthropic-licence-manager"; +/** The manager module whose `bindings` state this module reads (ADR 0206). */ +export const MANAGER = "claude-licence-manager"; +/** A seat's verb as the runtime addresses it: a role, not a module. */ +export const seatVerb = (verb: string) => `seat:${SEAT}.${verb}`; + +/** A token named without being one: the first 16 hex of its SHA-256. */ +export const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16); export interface Paths { state: string; @@ -79,25 +88,112 @@ export function renderNow(p: Paths, write: WriteManaged): string[] { // ---- the licence ---------------------------------------------------------------------------------- +/** What the licence this node holds was, as it was last applied: its name, kind and generation. */ +export interface Applied extends Binding { + readonly generation?: number; +} + +/** What the manager's `bindings` state says one consumer should hold (ADR 0206). */ +export interface BindingState { + readonly licence: string; + readonly kind: "subscription" | "api-key"; + readonly generation: number; +} + /** What the seat answers to `current`: the licence this node is bound to and its token, sealed. */ export interface Current { licence: string; kind: "subscription" | "api-key"; + generation?: number; sealed: SealedBox; identity?: Identity | null; } +/** + * What this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager to tell a + * login it has not adopted from one it has, and never a token — fingerprints and expiries only. + */ +export interface Holdings { + readonly node: string; + readonly identity: Identity | null; + readonly kind: "subscription" | "api-key" | null; + /** The refresh token's fingerprint, and whether the file holds one at all: a login waiting. */ + readonly refresh: { readonly present: boolean; readonly fingerprint: string | null; readonly expiresAt: number | null }; + readonly access: { readonly fingerprint: string; readonly expiresAt: number } | null; + /** The licence and generation this module last applied, or null before any. */ + readonly licence: string | null; + readonly generation: number; + /** When the credentials file last changed: the newest login of several is tried first. */ + readonly changedAt: string | null; +} + +export function holdingsOf(p: Paths): Holdings { + const creds = readCredentials(credentialsPath(p)); + const o = creds?.claudeAiOauth as Record | undefined; + const applied = readJson(bindingPath(p), null); + let changedAt: string | null = null; + try { + changedAt = statSync(credentialsPath(p)).mtime.toISOString(); + } catch { + /* no file */ + } + const refreshValue = typeof o?.refreshToken === "string" && o.refreshToken ? o.refreshToken : null; + const accessValue = typeof o?.accessToken === "string" && o.accessToken ? o.accessToken : null; + const apiKey = existsSync(apiKeyPath(p)); + return { + node: p.node, + identity: readIdentity(accountPath(p)), + kind: apiKey ? "api-key" : accessValue ? "subscription" : null, + refresh: { + present: refreshValue !== null, + fingerprint: refreshValue ? fingerprint(refreshValue) : null, + expiresAt: o?.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt), + }, + access: accessValue ? { fingerprint: fingerprint(accessValue), expiresAt: Number(o?.expiresAt ?? 0) } : null, + licence: applied?.licence ?? null, + generation: applied?.generation ?? 0, + changedAt, + }; +} + +/** + * The full grant in the credentials file, sealed to the key the manager gives — the one time a refresh + * token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Null when the file holds + * no refresh token: there is nothing to adopt. + */ +export function grantFor(p: Paths, managerPublicKey: string): { sealed: SealedBox; identity: Identity | null; fingerprint: string } | null { + const creds = readCredentials(credentialsPath(p)); + if (!holdsLogin(creds)) return null; + const oauth = creds!.claudeAiOauth!; + return { + sealed: seal(JSON.stringify(oauth), managerPublicKey), + identity: readIdentity(accountPath(p)), + fingerprint: fingerprint(String(oauth.refreshToken)), + }; +} + /** Ask the seat for this node's current token and apply it. */ export async function pull(p: Paths, ask: Ask, write: WriteManaged): Promise> { - const answer = (await ask(`${SEAT}.current`, { node: p.node, public_key: keypair(p).publicKey })) as Current | null; + const answer = (await ask(seatVerb("current"), { consumer: p.node, public_key: keypair(p).publicKey })) as Current | null; if (!answer?.sealed) return { applied: false, reason: "the seat holds no licence for this node" }; return apply(p, answer, write); } +/** + * The manager's `bindings` key for this node changed (ADR 0206): fetch the token when the generation is + * newer than the one applied. A released binding keeps the last token, which lives hours, and says so. + */ +export async function onBinding(p: Paths, b: BindingState | null, ask: Ask, write: WriteManaged): Promise { + if (!b) return "this node's binding was released; it keeps its last token until it expires"; + const applied = readJson(bindingPath(p), null); + if (applied && (applied.generation ?? 0) >= b.generation) return null; + return JSON.stringify(await pull(p, ask, write)); +} + /** Apply what the seat handed over. A switch replaces the grant whole and cleans up after the old licence. */ export function apply(p: Paths, handed: Current, write: WriteManaged): Record { const plain = open(handed.sealed, keypair(p).privateKey); - const previous = readJson(bindingPath(p), null); + const previous = readJson(bindingPath(p), null); const switched = previous?.licence !== handed.licence; let outcome: Record = { applied: true, licence: handed.licence, kind: handed.kind, switched }; if (handed.kind === "api-key") { @@ -107,7 +203,10 @@ export function apply(p: Paths, handed: Current, write: WriteManaged): Record(bindingPath(p), null)?.licence; - return false; -} - -/** A refresh token in the credentials file is a login: this module never writes one. Offer it to the seat. */ -export async function offerLogin(p: Paths, ask: Ask): Promise | null> { - const creds = readCredentials(credentialsPath(p)); - if (!holdsLogin(creds)) return null; - const key = (await ask(`${SEAT}.public_key`, {})) as { public_key?: string } | null; - if (!key?.public_key) throw new Error("the licence manager did not say what key to seal a login to"); - return (await ask(`${SEAT}.adopt`, { - node: p.node, - identity: readIdentity(accountPath(p)), - sealed: seal(JSON.stringify(creds!.claudeAiOauth), key.public_key), - })) as Record; -} - // ---- MCP servers ---------------------------------------------------------------------------------- export interface Registration { diff --git a/modules/claude-code/test/node.test.ts b/modules/claude-code/test/node.test.ts index d7cd92d..2d840da 100644 --- a/modules/claude-code/test/node.test.ts +++ b/modules/claude-code/test/node.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { - apply, concerns, keypair, offerLogin, onServerChange, pull, registerServer, registered, ServerView, type Paths, + apply, grantFor, holdingsOf, keypair, onBinding, onServerChange, pull, registerServer, registered, ServerView, type Paths, type ServerChange, type ServerState, } from "../dist/node.js"; import { generateKeyPair, open, seal } from "../dist/seal.js"; @@ -21,7 +21,7 @@ function node(name = "laptop"): { p: Paths; written: Record } { } const writer = (w: Record) => (name: string, content: string) => { w[name] = content; return `${name}: written`; }; const creds = (p: Paths) => JSON.parse(readFileSync(join(p.home, ".claude", ".credentials.json"), "utf8")); -const grantFor = (p: Paths, licence: string, token: string, kind: "subscription" | "api-key" = "subscription", identity?: object) => ({ +const grantFor_ = (p: Paths, licence: string, token: string, kind: "subscription" | "api-key" = "subscription", identity?: object) => ({ licence, kind, identity, sealed: seal(kind === "api-key" ? token : JSON.stringify({ accessToken: token, expiresAt: NOW + 3_600_000, refreshTokenExpiresAt: NOW + 86_400_000, subscriptionType: licence }), keypair(p).publicKey), }); @@ -29,9 +29,9 @@ const grantFor = (p: Paths, licence: string, token: string, kind: "subscription" test("a pull asks the seat with this node's key and applies what it answers", async () => { const { p, written } = node(); let asked: [string, Record] | null = null; - const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor(p, "personal", "at-1"); }, writer(written)); - assert.equal(asked![0], "anthropic-licence-manager.current"); - assert.equal(asked![1].node, "laptop"); + const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor_(p, "personal", "at-1"); }, writer(written)); + assert.equal(asked![0], "seat:anthropic-licence-manager.current"); + assert.equal(asked![1].consumer, "laptop"); assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/); assert.equal(r.applied, true); assert.equal(creds(p).claudeAiOauth.accessToken, "at-1"); @@ -41,8 +41,8 @@ test("a pull asks the seat with this node's key and applies what it answers", as test("a switch replaces the old licence's grant whole and points the account at the new one", () => { const { p, written } = node(); writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "old" }, projects: { keep: 1 } })); - apply(p, grantFor(p, "personal", "at-1"), writer(written)); - const r = apply(p, grantFor(p, "work", "at-2", "subscription", { accountUuid: "new", emailAddress: "w@example.org" }), writer(written)); + apply(p, grantFor_(p, "personal", "at-1"), writer(written)); + const r = apply(p, grantFor_(p, "work", "at-2", "subscription", { accountUuid: "new", emailAddress: "w@example.org" }), writer(written)); assert.equal(r.switched, true); assert.equal(creds(p).claudeAiOauth.accessToken, "at-2"); assert.equal(creds(p).claudeAiOauth.subscriptionType, "work", "the old licence's subscription type survived the switch"); @@ -53,41 +53,67 @@ test("a switch replaces the old licence's grant whole and points the account at test("switching to the API key adds the key-helper; switching away removes the key and the helper", () => { const { p, written } = node(); - apply(p, grantFor(p, "api", "sk-key", "api-key"), writer(written)); + apply(p, grantFor_(p, "api", "sk-key", "api-key"), writer(written)); assert.ok(JSON.parse(written["managed-settings.json"]).apiKeyHelper); assert.ok(existsSync(join(p.state, "api-key"))); - apply(p, grantFor(p, "personal", "at-1"), writer(written)); + apply(p, grantFor_(p, "personal", "at-1"), writer(written)); assert.ok(!("apiKeyHelper" in JSON.parse(written["managed-settings.json"]))); assert.ok(!existsSync(join(p.state, "api-key")) && !existsSync(join(p.state, "api-key-helper"))); }); -test("a rotation event concerns the node bound to that licence; a switch event the node it names", () => { - const { p, written } = node(); - apply(p, grantFor(p, "personal", "at-1"), writer(written)); - assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "personal" }), true); - assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "work" }), false); - assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "laptop", licence: "work" }), true); - assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "server" }), false); +test("what a node holds is reported with fingerprints and its account, never a token", () => { + const { p } = node(); + writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-secret", refreshToken: "rt-secret", expiresAt: NOW + 1000, refreshTokenExpiresAt: NOW + 9000 } })); + writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" } })); + const h = holdingsOf(p); + assert.equal(h.node, "laptop"); + assert.equal(h.identity?.accountUuid, "u-1"); + assert.equal(h.kind, "subscription"); + assert.equal(h.refresh.present, true); + assert.match(String(h.refresh.fingerprint), /^sha256:[0-9a-f]{16}$/); + assert.equal(h.access?.expiresAt, NOW + 1000); + assert.ok(h.changedAt); + const text = JSON.stringify(h); + assert.ok(!text.includes("at-secret") && !text.includes("rt-secret"), "a token is in the report"); + assert.ok(!/token|secret|password/i.test(Object.keys(h).join(" ") + " " + Object.keys(h.refresh).join(" ")), + "a field the runtime would refuse is in the report"); }); -test("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => { +test("a node with nothing reports nothing held, and the grant answers only a waiting login", () => { const { p } = node(); + const h = holdingsOf(p); + assert.equal(h.kind, null); + assert.equal(h.refresh.present, false); const manager = generateKeyPair(); - writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW } })); + assert.equal(grantFor(p, manager.publicKey), null); + writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", refreshToken: "rt-login", expiresAt: NOW } })); writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-9" } })); - const calls: [string, Record][] = []; - await offerLogin(p, async (address, args) => { calls.push([address, args]); return address.endsWith("public_key") ? { public_key: manager.publicKey } : { adopted: true }; }); - assert.deepEqual(calls.map((c) => c[0]), ["anthropic-licence-manager.public_key", "anthropic-licence-manager.adopt"]); - const adopt = calls[1][1] as { identity: { accountUuid: string }; sealed: never }; - assert.equal(adopt.identity.accountUuid, "u-9"); - assert.equal(JSON.parse(open(adopt.sealed, manager.privateKey)).refreshToken, "rt-login"); - assert.ok(!JSON.stringify(adopt).includes("rt-login"), "the refresh token crossed in the clear"); + const g = grantFor(p, manager.publicKey)!; + assert.equal(g.identity?.accountUuid, "u-9"); + assert.equal(JSON.parse(open(g.sealed, manager.privateKey)).refreshToken, "rt-login"); + assert.ok(!JSON.stringify(g).includes("rt-login"), "the refresh token crossed in the clear"); }); -test("no refresh token in the file is no login, and nothing is asked", async () => { - const { p } = node(); - writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", expiresAt: NOW } })); - assert.equal(await offerLogin(p, async () => { throw new Error("asked"); }), null); +test("a newer generation in the bindings fetches the token once, by the seat's verb; an equal one asks nothing", async () => { + const { p, written } = node(); + const asked: string[] = []; + const seatAsk = async (address: string) => { asked.push(address); return { ...grantFor_(p, "personal", "at-1"), generation: 3 }; }; + await onBinding(p, { licence: "personal", kind: "subscription", generation: 3 }, seatAsk, writer(written)); + assert.deepEqual(asked, ["seat:anthropic-licence-manager.current"]); + assert.equal(creds(p).claudeAiOauth.accessToken, "at-1"); + assert.equal(await onBinding(p, { licence: "personal", kind: "subscription", generation: 3 }, seatAsk, writer(written)), null); + assert.equal(asked.length, 1, "an equal generation asked again"); + assert.equal(holdingsOf(p).generation, 3); +}); + +test("the token a node is handed replaces a login's grant and leaves no refresh token", () => { + const { p, written } = node(); + writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-spent", expiresAt: NOW + 7_200_000 } })); + const r = apply(p, grantFor_(p, "personal", "at-new"), writer(written)); + assert.equal(r.applied, true); + assert.equal(creds(p).claudeAiOauth.accessToken, "at-new"); + assert.equal(creds(p).claudeAiOauth.refreshToken, undefined, "a refresh token survived the hand-over"); + assert.equal(holdingsOf(p).refresh.present, false); }); /** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */ diff --git a/modules/claude-code/tools/index.ts b/modules/claude-code/tools/index.ts index 1bd234e..cf274dd 100644 --- a/modules/claude-code/tools/index.ts +++ b/modules/claude-code/tools/index.ts @@ -3,10 +3,10 @@ // runtime. It is given its state directory and two files the mesh renders into it (ADR 0192), beside the // runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr. // -// At start it renders the agent's managed directory, asks the licence manager for this node's token, -// begins watching the credentials file for a login, takes the manager's licence events, and watches the -// module's `servers` state — every node's MCP server registrations (novox/hq ADR 0201). node.ts holds the -// logic. +// At start it renders the agent's managed directory, reports what this node holds as the module's +// `holdings` state and again whenever the credentials file changes, watches the licence manager's +// `bindings` state for this node and fetches the token when it says so (novox/hq ADR 0206), and watches +// the module's `servers` state — every node's MCP server registrations (ADR 0201). node.ts holds the logic. import { mkdtempSync, readFileSync, rmSync, watchFile, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -14,18 +14,16 @@ import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { broker } from "@novox/mesh-sdk/messaging"; -import { on } from "@novox/mesh-sdk/events"; import { state } from "@novox/mesh-sdk/state"; import { - MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer, - registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged, + MANAGED_DIR, MANAGER, ServerView, fingerprint, grantFor, holdingsOf, keypair, onBinding, onServerChange, pull, + readJson, registerServer, registered, renderNow, + type Ask, type BindingState, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged, } from "../node.js"; import { grantOf, holdsLogin, readCredentials } from "../grant.js"; -import { createHash } from "node:crypto"; const say = (line: string) => console.error(`[claude-code] ${line}`); -const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16); function pathsFrom(env: NodeJS.ProcessEnv): Paths | null { const state = env.MESH_CLAUDE_CODE_STATE, facts = env.MESH_CLAUDE_CODE_FACTS; @@ -58,11 +56,16 @@ const writeManaged: WriteManaged = (name, content) => { return `${name}: written`; }; -/** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */ +/** + * A tool on the bus, through the runtime. The runtime answers with the tool's value itself — a refusal is + * the request failing — so this reads an MCP envelope only where something answered with one. + */ const ask: Ask = async (address, args) => { - const answer = (await broker().request, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {}; - const text = answer.content?.map((c) => c.text ?? "").join("") ?? ""; - if (answer.isError) throw new Error(`${address}: ${text}`); + const answer = await broker().request, unknown>(address, args); + const env = answer as { content?: { text?: string }[]; isError?: boolean } | null; + if (!env || typeof env !== "object" || !Array.isArray(env.content)) return answer; + const text = env.content.map((c) => c.text ?? "").join(""); + if (env.isError) throw new Error(`${address}: ${text}`); try { return JSON.parse(text); } catch { @@ -72,7 +75,7 @@ const ask: Ask = async (address, args) => { /** The nodes claude-code runs on, from the controller's list of modules — for the register tool's question. */ async function nodesRunningMe(): Promise { - const out = await ask("mesh-controller.modules", {}); + const out = await ask("seat:mesh-controller.modules", {}); const text = typeof out === "string" ? out : String((out as { output?: string })?.output ?? ""); const line = text.split("\n").find((l) => /^claude-code\s/.test(l)) ?? ""; const on = line.split(" on ")[1] ?? ""; @@ -94,6 +97,7 @@ function status(p: Paths): Record { licence: readJson(join(p.state, "licence.json"), null), token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(), loginWaiting: holdsLogin(creds) } : null, + holdings: holdingsOf(p), managed, registered: Object.keys(registered(p)), }; @@ -129,6 +133,17 @@ function tools(p: Paths): ToolDefinition[] { input: {}, run: async () => pull(p, ask, writeManaged), }, + { + name: "claude_code_grant", + description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.", + input: { public_key: { type: "string", description: "the manager's public key, PEM; the grant opens only with its private half" } }, + run: async (a) => { + if (typeof a.public_key !== "string" || !a.public_key.includes("PUBLIC KEY")) { + throw new Error("claude_code_grant seals to a public key, and none was given"); + } + return grantFor(p, a.public_key) ?? { waiting: false }; + }, + }, { name: "claude_code_mcp_list", description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.` for every node, `.` for one.", @@ -181,12 +196,6 @@ const p = process.env.MESH_SERVED_MODULE ? pathsFrom(process.env) : null; if (p) { const loud = (what: string) => (err: unknown) => say(`${what}: ${err instanceof Error ? err.message : String(err)}`); - void on<{ licence?: string; node?: string }>("claude-licence-manager.licence.*", async (event) => { - if (!concerns(p, event.type, event.body ?? {})) return; - say(`${event.type} — asking ${SEAT} for this node's token`); - say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) })))); - }).catch(loud("the licence events")); - // Every node's MCP servers: the whole current set first, then each change (ADR 0201). **Not awaited // where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the // bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle @@ -211,14 +220,40 @@ if (p) { }; watchServers(); - // Catch up once at start: a node that was off takes its current token now. - void pull(p, ask, writeManaged).then((r) => say(`at start: ${JSON.stringify(r)}`), loud("asking for this node's token at start")); + /** Ask the state again until it answers: its bucket or the bus's grant may arrive after the module. */ + const persist = (what: string, attempt: () => Promise, done: (n: number) => void, n = 0): void => { + attempt().then(() => done(n), (err) => { + const wait = [2, 5, 10, 30][n] ?? 60; + say(`${what} not yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`); + setTimeout(() => persist(what, attempt, done, n + 1), wait * 1000); + }); + }; - // A login: a refresh token appears in the credentials file. Polled, because the file is replaced by - // rename and a watch on the old inode would go quiet. - const credentials = join(p.home, ".claude", ".credentials.json"); - watchFile(credentials, { interval: 5000 }, () => { - void offerLogin(p, ask).then((r) => { if (r) say(`a login here was offered to ${SEAT}: ${JSON.stringify(r)}`); }, - loud("offering a login to the licence manager")); - }); + // What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on + // every change of the credentials file, polled because the file is replaced by rename and a watch on the + // old inode would go quiet. Fingerprints and expiries only; the runtime refuses a token anyway. + const holdings = state>("holdings"); + let reported = ""; + const report = (): void => { + const now = holdingsOf(p); + const text = JSON.stringify(now); + if (text === reported) return; + persist("reporting what this node holds", () => holdings.put(p.node, now as unknown as Record), () => { + reported = text; + say(`reported: ${now.identity?.emailAddress ?? "no account"}, ${now.kind ?? "no token"}` + + `${now.refresh.present ? ", a login waiting" : ""}${now.licence ? `, licence ${now.licence} g${now.generation}` : ""}`); + }); + }; + report(); + watchFile(join(p.home, ".claude", ".credentials.json"), { interval: 5000 }, report); + + // What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer generation + // is fetched with the seat's `current`, sealed to this module's key. Absent until the manager exists. + persist("watching this node's licence binding", () => state(`${MANAGER}.bindings`).watch(async (c) => { + if (c.key !== p.node) return; + const done = await onBinding(p, c.op === "put" ? (c.value as BindingState) : null, ask, writeManaged) + .catch((err) => `fetching this node's token failed: ${err instanceof Error ? err.message : String(err)}`); + if (done) say(done); + report(); + }, { key: p.node }), (n) => say(`watching this node's licence binding${n ? ` (after ${n} refusal(s))` : ""}`)); } diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md new file mode 100644 index 0000000..dbd2912 --- /dev/null +++ b/modules/claude-licence-manager/README.md @@ -0,0 +1,55 @@ +# claude-licence-manager + +Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one +rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39). + +## How a licence comes to exist + +Nothing is configured. Every node running `claude-code` reports what it holds as that module's +`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report +when it starts and watches them: + +1. A report with a refresh token it does not hold is a **candidate**. +2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it. +3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest + with the key the vault made for it — and from then on it is the only refresher. If not, the candidate + is recorded dead and nothing is adopted. +4. Several nodes logged in to one account: newest login first; the rest are never exchanged. +5. A node reporting that account and bound to nothing is bound to it. + +Each node is then handed an access token only, so the agent there never refreshes, and a refresh token +appearing on a node later can only be a person's login — which wins if it refreshes. + +An API key enters through `adopt`, from a file on this module's node. + +## What each consumer holds + +This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a +generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer +generation, asks `current` with its public key. + +## The seat's verbs + +`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through +the console as `anthropic-licence-manager.`. No answer carries a token. + +## Settings + +`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60), +`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3). + +## Events + +`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret. + +## Code and tests + +Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle, +`prepare` as the run-once preparation step. The sealed box is the agent module's own format, byte for +byte, and a test opens one the TypeScript sealed (and has TypeScript open one Go sealed, where `node` and +a built `claude-code` are beside it). + + go test ./... + # the store against a real postgres: + docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine + MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./... diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go new file mode 100644 index 0000000..78cc180 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go @@ -0,0 +1,64 @@ +package main + +// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the +// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the +// key derived from the vault's secret by SHA-256 so a secret of any length serves. + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "os" + "strings" +) + +// Crypt seals and opens what the store keeps. +type Crypt struct{ key []byte } + +// NewCrypt is the store's cipher from the vault's secret. +func NewCrypt(secret string) (*Crypt, error) { + secret = strings.TrimSpace(secret) + if secret == "" { + return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet") + } + sum := sha256.Sum256([]byte(secret)) + return &Crypt{key: sum[:]}, nil +} + +// CryptFromFile reads the vault's secret from the file the mesh delivered it to. +func CryptFromFile(path string) (*Crypt, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return NewCrypt(string(raw)) +} + +// Seal encrypts a plaintext as `v1..`. +func (c *Crypt) Seal(plaintext string) string { + gcm, _ := newGCM(c.key) + iv := make([]byte, 12) + _, _ = rand.Read(iv) + b64 := base64.StdEncoding.EncodeToString + return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) +} + +// Open decrypts what Seal made with the same key. +func (c *Crypt) Open(sealed string) (string, error) { + parts := strings.Split(sealed, ".") + if len(parts) != 3 || parts[0] != "v1" { + return "", errors.New("not a grant this module sealed") + } + iv, err1 := base64.StdEncoding.DecodeString(parts[1]) + ct, err2 := base64.StdEncoding.DecodeString(parts[2]) + if err := errors.Join(err1, err2); err != nil { + return "", err + } + gcm, _ := newGCM(c.key) + plain, err := gcm.Open(nil, iv, ct, nil) + if err != nil { + return "", errors.New("the grant does not open with this module's key") + } + return string(plain), nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go new file mode 100644 index 0000000..2879356 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -0,0 +1,348 @@ +// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control +// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the +// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports +// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. +// +// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the +// version that needs it, with the module's words and no bus, and it brings the store's schema to shape. +// +// stdout is the MCP channel; everything this module says, it says on stderr. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds. +const Seat = "anthropic-licence-manager" + +func say(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[claude-licence-manager] "+format+"\n", args...) +} + +func main() { + if len(os.Args) > 1 && os.Args[1] == "prepare" { + if err := prepare(); err != nil { + say("preparing the store failed: %v", err) + os.Exit(1) + } + say("the store's schema is what this version needs") + return + } + go daemon() + if err := stdio.Serve("", tools()); err != nil { + say("%v", err) + os.Exit(1) + } +} + +func prepare() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + store, err := PgStoreFromEnv(ctx) + if err != nil { + return err + } + defer store.Close() + return store.Migrate(ctx) +} + +// ---- what the binary is handed ----------------------------------------------------------------------- + +var ( + built *Manager + buildMu sync.Mutex +) + +// manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never +// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal. +func manager() (*Manager, error) { + buildMu.Lock() + defer buildMu.Unlock() + if built != nil { + return built, nil + } + dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE") + if dir == "" || keyFile == "" { + return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module") + } + crypt, err := CryptFromFile(keyFile) + if err != nil { + return nil, err + } + keys, err := keypair(dir) + if err != nil { + return nil, err + } + store, err := PgStoreFromEnv(context.Background()) + if err != nil { + return nil, err + } + node, _ := os.Hostname() + if n := os.Getenv("MESH_NODE"); n != "" { + node = n + } + bindings := stdio.State("bindings") + built = &Manager{ + Store: store, + Vendor: LiveVendor(), + Crypt: crypt, + Keys: keys, + AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { + var a GrantAnswer + raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey}) + if err != nil { + return a, err + } + return a, json.Unmarshal(raw, &a) + }, + PutBinding: func(_ context.Context, consumer string, b BindingState) error { + _, err := bindings.Put(consumer, b) + return err + }, + DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) }, + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Now: time.Now, + Log: say, + Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), + Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + } + return built, nil +} + +// keypair is this module's own, made once in its state directory; the private half never leaves it. +// Written whole, then linked into place, so a second process reads the first's key and never half of it. +func keypair(dir string) (KeyPair, error) { + file := filepath.Join(dir, "manager-key.json") + if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { + k, err := GenerateKeyPair() + if err != nil { + return KeyPair{}, err + } + raw, _ := json.Marshal(k) + tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return KeyPair{}, err + } + _ = os.Link(tmp, file) // fails when another made it first, which is right + _ = os.Remove(tmp) + } + raw, err := os.ReadFile(file) + if err != nil { + return KeyPair{}, err + } + var k KeyPair + return k, json.Unmarshal(raw, &k) +} + +// ---- the long-running half --------------------------------------------------------------------------- + +func daemon() { + var mu sync.Mutex + reports := map[string]Holdings{} + var passing sync.Mutex + pass := func() { + passing.Lock() // one pass at a time: each account is leased, and a pass is cheap + defer passing.Unlock() + m, err := manager() + if err != nil { + say("not ready: %v", err) + return + } + mu.Lock() + all := make([]Holdings, 0, len(reports)) + for _, r := range reports { + all = append(all, r) + } + mu.Unlock() + sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node }) + adopted, err := m.Consider(context.Background(), all) + if err != nil { + say("considering the reports failed: %v", err) + } + if len(adopted) > 0 { + say("adopted %s", strings.Join(adopted, ", ")) + } + } + + // What every node holds (ADR 0206): the whole current set, then each change. Asked again until it + // answers — the channel to the runtime opens as the bundle starts, and the agent module's state may + // arrive after this one. + go func() { + waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second} + for attempt := 0; ; attempt++ { + err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error { + mu.Lock() + if c.Op == "put" { + var h Holdings + if json.Unmarshal(c.Value, &h) == nil { + reports[c.Key] = h + } + } else { + delete(reports, c.Key) + } + mu.Unlock() + // During the current values the pass waits for the whole set: newest login first needs all. + if !c.Current { + go pass() + } + return nil + }) + if err == nil { + mu.Lock() + n := len(reports) + mu.Unlock() + say("watching what %d node(s) hold", n) + pass() + return + } + pause := time.Minute + if attempt < len(waits) { + pause = waits[attempt] + } + say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause) + time.Sleep(pause) + } + }() + + refresh := time.NewTicker(time.Minute) + usage := time.NewTicker(5 * time.Minute) + for { + select { + case <-refresh.C: + if m, err := manager(); err == nil { + out, err := m.RotateDue(context.Background()) + for _, r := range out { + b, _ := json.Marshal(r) + say("%s", b) + } + if err != nil { + say("refreshing failed: %v", err) + } + } + pass() // a login whose node did not answer last time is asked again + case <-usage.C: + if m, err := manager(); err == nil { + if err := m.ReadUsage(context.Background()); err != nil { + say("reading usage failed: %v", err) + } + } + } + } +} + +// ---- the seat's verbs -------------------------------------------------------------------------------- + +func text(a map[string]any, k string) (string, error) { + v, _ := a[k].(string) + if strings.TrimSpace(v) == "" { + return "", fmt.Errorf("%s is required", k) + } + return strings.TrimSpace(v), nil +} + +// verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's subject. +func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, + Run: func(a map[string]any) (any, error) { + m, err := manager() + if err != nil { + return nil, err + } + return run(context.Background(), m, a) + }} +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func two(a map[string]any, k1, k2 string) (string, string, error) { + v1, err1 := text(a, k1) + v2, err2 := text(a, k2) + return v1, v2, errors.Join(err1, err2) +} + +func tools() []stdio.Tool { + consumer := str("the node's name") + return []stdio.Tool{ + verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.", + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }), + verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.", + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }), + verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.", + map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + c, l, err := two(a, "consumer", "licence") + if err != nil { + return nil, err + } + return m.Bind(ctx, c, l, "bind") + }), + verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.", + map[string]any{"consumer": consumer, "licence": str("the licence to move to")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + c, l, err := two(a, "consumer", "licence") + if err != nil { + return nil, err + } + return m.Bind(ctx, c, l, "switch") + }), + verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.", + map[string]any{"consumer": consumer}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + c, err := text(a, "consumer") + if err != nil { + return nil, err + } + return m.Release(ctx, c, "release") + }), + verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.", + map[string]any{"licence": str("a licence; absent for every due one")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + if l, _ := a["licence"].(string); l != "" { + return m.Rotate(ctx, l, true) + } + return m.RotateDue(ctx) + }), + verb("usage", "Usage readings, the latest first, for every licence or one.", + map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + l, _ := a["licence"].(string) + limit := 20 + if v, ok := a["limit"].(float64); ok && v > 0 { + limit = int(v) + } + return m.Store.Usage(ctx, l, limit) + }), + verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.", + map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + n, f, err := two(a, "name", "file") + if err != nil { + return nil, err + } + return m.AdoptKey(ctx, n, f) + }), + verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.", + map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + c, k, err := two(a, "consumer", "public_key") + if err != nil { + return nil, err + } + return m.Current(ctx, c, k) + }), + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go new file mode 100644 index 0000000..b6ffb76 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -0,0 +1,640 @@ +package main + +// The Anthropic licence manager's rules (novox/hq ADR 0183, ADR 0206, design 39), written against what it +// is handed — a store, the vendor, a way to ask a node, its own state, a way to emit — so every rule is +// tested without a bus, a database or the vendor. +// +// - A licence is an account, learned from what the nodes report (`holdings`, the agent module's state). +// A report with a refresh token the manager does not hold is a candidate. +// - The secret is asked for, sealed to this module's key, never published. +// - Adopting is refreshing: newest login first, once per account; a failure adopts nothing. +// - What each consumer should hold is this module's `bindings` state, with a generation that grows with +// every rotation and switch; the consumer fetches its token by asking `current`. +// - One rotation source: every exchange with the vendor runs under a lease. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "sort" + "strings" + "time" +) + +// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare. +func Fingerprint(s string) string { + sum := sha256.Sum256([]byte(s)) + return "sha256:" + hex.EncodeToString(sum[:])[:16] +} + +// Identity is the account a grant belongs to, as the agent's own state file names it. +type Identity struct { + AccountUUID string `json:"accountUuid"` + EmailAddress string `json:"emailAddress,omitempty"` + OrganizationUUID string `json:"organizationUuid,omitempty"` +} + +// Holdings is one node's report, as the agent module writes it to its `holdings` state (ADR 0206). +type Holdings struct { + Node string `json:"node"` + Identity *Identity `json:"identity"` + Kind string `json:"kind"` + Refresh struct { + Present bool `json:"present"` + Fingerprint string `json:"fingerprint"` + } `json:"refresh"` + ChangedAt string `json:"changedAt"` +} + +// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which +// generation. +type BindingState struct { + Licence string `json:"licence"` + Kind string `json:"kind"` + Generation int64 `json:"generation"` +} + +// Settings are the manager's own, declared with defaults (design 39 §8). +type Settings struct { + Cadence time.Duration // rotate a grant once older than this + Floor time.Duration // refresh in any case with less than this left + FailuresToNotify int + Cooldown time.Duration // at most one notification per licence in this window + RefreshWarn time.Duration // warn this long before a refresh token itself expires +} + +// Defaults are the settings a fresh mesh runs with. +var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour} + +// SettingsFrom reads the settings file, keeping a default for anything absent or not positive. +func SettingsFrom(path string) Settings { + s := Defaults + raw, err := os.ReadFile(path) + if err != nil { + return s + } + var f map[string]float64 + if json.Unmarshal(raw, &f) != nil { + return s + } + set := func(k string, unit time.Duration, into *time.Duration) { + if v := f[k]; v > 0 { + *into = time.Duration(v * float64(unit)) + } + } + set("cadence_minutes", time.Minute, &s.Cadence) + set("floor_minutes", time.Minute, &s.Floor) + set("cooldown_hours", time.Hour, &s.Cooldown) + if v := f["refresh_warn_days"]; v > 0 { + s.RefreshWarn = time.Duration(v * float64(24*time.Hour)) + } + if v := f["failures_to_notify"]; v > 0 { + s.FailuresToNotify = int(v) + } + return s +} + +// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing. +type GrantAnswer struct { + Sealed *SealedBox `json:"sealed"` + Identity *Identity `json:"identity"` + Fingerprint string `json:"fingerprint"` +} + +// Manager is the rules and what they are handed. +type Manager struct { + Store Store + Vendor Vendor + Crypt *Crypt + Keys KeyPair + // AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error + // is the node not answering; a nil Sealed is no login waiting. + AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error) + // PutBinding and DeleteBinding are this module's `bindings` state. + PutBinding func(ctx context.Context, consumer string, b BindingState) error + DeleteBinding func(ctx context.Context, consumer string) error + Emit func(event string, body map[string]any) error + Now func() time.Time + Log func(format string, args ...any) + // Holder names this process in a lease, so a second run is told apart. + Holder string + Settings Settings +} + +const leaseFor = 2 * time.Minute + +// NameFor is a licence's name: the account's address where it has one, else its id. +func NameFor(id Identity) string { + if e := strings.TrimSpace(id.EmailAddress); e != "" { + return e + } + return id.AccountUUID +} + +// ---- learning licences from what the nodes hold ------------------------------------------------------ + +// Candidate is a report the manager should try. +type Candidate struct { + Node string + Identity Identity + Fingerprint string + ChangedAt int64 +} + +// CandidatesIn is the candidates in a set of reports by account, newest login first (ADR 0206 §2, §4). A +// report without an identity is not one: a grant is filed under its account or not at all. +func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[string][]Candidate, error) { + out := map[string][]Candidate{} + for _, r := range reports { + if !r.Refresh.Present || r.Refresh.Fingerprint == "" || r.Identity == nil || r.Identity.AccountUUID == "" { + continue + } + settled, err := m.Store.Outcome(ctx, r.Refresh.Fingerprint) + if err != nil { + return nil, err + } + if settled != "" { + continue // adopted, dead, skipped, refused or gone: settled once + } + held, err := m.Store.LicenceForAccount(ctx, r.Identity.AccountUUID) + if err != nil { + return nil, err + } + if held != nil && held.RefreshFingerprint == r.Refresh.Fingerprint { + continue + } + var changed int64 + if t, err := time.Parse(time.RFC3339Nano, r.ChangedAt); err == nil { + changed = t.UnixMilli() + } + // The latest login wins: one no newer than the grant held is not a newer login. + if held != nil && changed <= held.AdoptedAt { + continue + } + out[r.Identity.AccountUUID] = append(out[r.Identity.AccountUUID], + Candidate{Node: r.Node, Identity: *r.Identity, Fingerprint: r.Refresh.Fingerprint, ChangedAt: changed}) + } + for _, list := range out { + sort.SliceStable(list, func(i, j int) bool { return list[i].ChangedAt > list[j].ChangedAt }) + } + return out, nil +} + +// Consider every report (ADR 0206): for each account with candidates, under that account's lease, try them +// newest first; the first that refreshes is adopted and the rest are settled as skipped without being +// exchanged. Answers what was adopted. +func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) { + byAccount, err := m.CandidatesIn(ctx, reports) + if err != nil { + return nil, err + } + accounts := make([]string, 0, len(byAccount)) + for a := range byAccount { + accounts = append(accounts, a) + } + sort.Strings(accounts) + var adopted []string + for _, account := range accounts { + list := byAccount[account] + key := "account:" + account + ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) + if err != nil || !ok { + continue + } + for i, c := range list { + name, err := m.adoptOne(ctx, c, reports) + if err != nil { + m.Log("adopting %s's login failed: %v", c.Node, err) + continue + } + if name != "" { + adopted = append(adopted, name) + for _, rest := range list[i+1:] { + _ = m.Store.RecordOutcome(ctx, rest.Fingerprint, rest.Node, account, Skipped, c.Node+"'s newer login was adopted first") + } + break + } + } + _ = m.Store.Unlease(ctx, key, m.Holder) + } + return adopted, nil +} + +func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) { + answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey) + if err != nil { + // Not answering is not an answer: asked again on the next pass. + m.Log("%s did not hand over its login: %v", c.Node, err) + return "", nil + } + if answer.Sealed == nil { + return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Gone, "no login was waiting when asked") + } + plain, err := Open(*answer.Sealed, m.Keys.PrivateKey) + if err != nil { + return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant did not open with this module's key") + } + var offered FullGrant + if err := json.Unmarshal([]byte(plain), &offered); err != nil || offered.RefreshToken == "" { + return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant holds no refresh token") + } + if Fingerprint(offered.RefreshToken) != c.Fingerprint { + m.Log("%s's login changed while it was asked for; waiting for its next report", c.Node) + return "", nil + } + + // Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the + // only holder of a live refresh token for the account. + r := m.Vendor.Refresh(ctx, offered) + if !r.OK { + _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason)) + _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}) + _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), + "reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)}) + m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason) + return "", nil + } + // The identity guard, on two sources (ADR 0206 §8). + if r.Account != "" && r.Account != c.Identity.AccountUUID { + _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, + "the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account) + _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}) + _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), + "reason": "the account the node reported is not the one the vendor answered for"}) + return "", nil + } + + held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID) + if err != nil { + return "", err + } + now := m.Now().UnixMilli() + l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID, + Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID} + if held != nil { + l.Name, l.NotifiedAt = held.Name, held.NotifiedAt + if l.Email == "" { + l.Email = held.Email + } + if l.OrganizationUUID == "" { + l.OrganizationUUID = held.OrganizationUUID + } + } + m.keep(&l, r.Grant) + l.AdoptedAt = max(now, c.ChangedAt) + if err := m.Store.SaveLicence(ctx, l); err != nil { + return "", err + } + why := "a new licence" + if held != nil { + why = "a newer login" + } + _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why) + _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID, + "vendorNamedAccount": r.Account != ""}) + + // A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing. + for _, rep := range reports { + if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID { + continue + } + if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil { + continue + } + if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil { + _ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"}) + } + } + if err := m.publishAdvance(ctx, l); err != nil { + return "", err + } + _ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil}) + m.Log("adopted %s from %s (%s)", l.Name, c.Node, why) + return l.Name, nil +} + +// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh. +func (m *Manager) keep(l *Licence, g FullGrant) { + raw, _ := json.Marshal(g) + l.Sealed = m.Crypt.Seal(string(raw)) + l.RefreshFingerprint = Fingerprint(g.RefreshToken) + l.AccessExpiresAt = g.ExpiresAt + if g.RefreshTokenExpiresAt != nil { + l.RefreshExpiresAt = *g.RefreshTokenExpiresAt + } + l.Failures = 0 + l.RotatedAt = m.Now().UnixMilli() +} + +// publishAdvance gives every consumer of a licence a new generation, and tells each in the state. +func (m *Manager) publishAdvance(ctx context.Context, l Licence) error { + bindings, err := m.Store.Advance(ctx, l.Name) + if err != nil { + return err + } + for _, b := range bindings { + if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil { + return err + } + } + return nil +} + +// ---- keeping grants alive ---------------------------------------------------------------------------- + +// Due says whether a licence needs a refresh now: near expiry, or older than the cadence. +func Due(l Licence, now time.Time, s Settings) bool { + if l.Kind != "subscription" || l.Sealed == "" { + return false + } + ms := now.UnixMilli() + if l.AccessExpiresAt != 0 && l.AccessExpiresAt-ms < s.Floor.Milliseconds() { + return true + } + return l.RotatedAt == 0 || ms-l.RotatedAt >= s.Cadence.Milliseconds() +} + +// Rotate refreshes one licence under its lease (design 39 §3). A second run started together finds the +// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes +// whatever the age — the seat's `refresh` verb. +func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) { + key := "licence:" + name + ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor) + if err != nil { + return nil, err + } + if !ok { + return map[string]any{"licence": name, "refreshed": false, "reason": "another run holds its lease"}, nil + } + defer func() { _ = m.Store.Unlease(ctx, key, m.Holder) }() + l, err := m.Store.Licence(ctx, name) + if err != nil { + return nil, err + } + if l == nil { + return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil + } + if l.Kind != "subscription" || l.Sealed == "" { + return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil + } + now := m.Now() + if !force && !Due(*l, now, m.Settings) { + return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil + } + plain, err := m.Crypt.Open(l.Sealed) + if err != nil { + return nil, err + } + var g FullGrant + if err := json.Unmarshal([]byte(plain), &g); err != nil { + return nil, err + } + r := m.Vendor.Refresh(ctx, g) + if !r.OK { + l.Failures++ + m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason) + _ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}) + m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify) + if err := m.Store.SaveLicence(ctx, *l); err != nil { + return nil, err + } + return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil + } + m.keep(l, r.Grant) + if l.RefreshExpiresAt != 0 { + left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond + m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24), + left < m.Settings.RefreshWarn) + } + if err := m.Store.SaveLicence(ctx, *l); err != nil { + return nil, err + } + _ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt}) + if err := m.publishAdvance(ctx, *l); err != nil { + return nil, err + } + return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil +} + +// notify emits `licence.failing` at most once per cooldown (design 39 §3); it carries no secret. +func (m *Manager) notify(l *Licence, why string, when bool) { + if !when { + return + } + now := m.Now().UnixMilli() + if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() { + return + } + l.NotifiedAt = now + _ = m.Emit("licence.failing", map[string]any{"licence": l.Name, "why": why}) +} + +// RotateDue refreshes every licence that is due. +func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { + all, err := m.Store.Licences(ctx) + if err != nil { + return nil, err + } + var out []map[string]any + for _, l := range all { + if Due(l, m.Now(), m.Settings) { + r, err := m.Rotate(ctx, l.Name, false) + if err != nil { + return out, err + } + out = append(out, r) + } + } + return out, nil +} + +// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers. +func (m *Manager) ReadUsage(ctx context.Context) error { + all, err := m.Store.Licences(ctx) + if err != nil { + return err + } + for _, l := range all { + if l.Kind != "subscription" || l.Sealed == "" { + continue + } + plain, err := m.Crypt.Open(l.Sealed) + if err != nil { + return err + } + var g FullGrant + _ = json.Unmarshal([]byte(plain), &g) + raw, err := m.Vendor.Usage(ctx, g.AccessToken) + if err != nil || raw == nil { + continue + } + reading := FlattenUsage(raw) + if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil { + return err + } + _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, + "weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct}) + } + return nil +} + +// ---- the seat's verbs -------------------------------------------------------------------------------- + +// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access +// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to +// nothing. +func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) { + if !strings.Contains(publicKey, "PUBLIC KEY") { + return nil, errors.New("current seals to the consumer's public key, and none was given") + } + b, err := m.Store.Binding(ctx, consumer) + if err != nil || b == nil { + return nil, err + } + l, err := m.Store.Licence(ctx, b.Licence) + if err != nil || l == nil || l.Sealed == "" { + return nil, err + } + plain, err := m.Crypt.Open(l.Sealed) + if err != nil { + return nil, err + } + handed := plain + if l.Kind == "subscription" { + var g FullGrant + if err := json.Unmarshal([]byte(plain), &g); err != nil { + return nil, err + } + access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt, + "refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes, + "subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier}) + handed = string(access) + } + box, err := Seal(handed, publicKey) + if err != nil { + return nil, err + } + out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box} + if l.AccountUUID != "" { + out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID} + } + return out, nil +} + +// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation. +func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) { + l, err := m.Store.Licence(ctx, licence) + if err != nil { + return nil, err + } + if l == nil { + return nil, fmt.Errorf("there is no licence %s; `licences` lists them", licence) + } + before, err := m.Store.Binding(ctx, consumer) + if err != nil { + return nil, err + } + b, err := m.Store.Bind(ctx, consumer, licence) + if err != nil { + return nil, err + } + from, what := "", "bound" + if before != nil { + from, what = before.Licence, "switched" + } + _ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by}) + if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil { + return nil, err + } + return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil +} + +// Release unbinds a consumer; its node keeps its last token, which expires within hours. +func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) { + was, err := m.Store.Binding(ctx, consumer) + if err != nil { + return nil, err + } + if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok { + return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err + } + if err := m.DeleteBinding(ctx, consumer); err != nil { + return nil, err + } + _ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by}) + return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil +} + +var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`) + +// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6). +func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) { + if !licenceName.MatchString(name) { + return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name) + } + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + key := strings.TrimSpace(string(raw)) + if key == "" { + return nil, fmt.Errorf("%s is empty", file) + } + held, err := m.Store.Licence(ctx, name) + if err != nil { + return nil, err + } + if held != nil && held.Kind != "api-key" { + return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name) + } + l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()} + if err := m.Store.SaveLicence(ctx, l); err != nil { + return nil, err + } + _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"}) + if err := m.publishAdvance(ctx, l); err != nil { + return nil, err + } + _ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil}) + return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil +} + +// Licences is each licence as a person reads it: health and who is bound, never a token. +func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) { + all, err := m.Store.Licences(ctx) + if err != nil { + return nil, err + } + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + stamp := func(ms int64) any { + if ms == 0 { + return nil + } + return time.UnixMilli(ms).UTC().Format(time.RFC3339) + } + out := []map[string]any{} + for _, l := range all { + bound := []string{} + for _, b := range bindings { + if b.Licence == l.Name { + bound = append(bound, b.Consumer) + } + } + account := l.Email + if account == "" { + account = l.AccountUUID + } + out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account, + "accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt), + "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound}) + } + return out, nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go new file mode 100644 index 0000000..f269c33 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -0,0 +1,338 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + +// stubVendor rotates like the real one is presumed to: each refresh token exchanges once. +type stubVendor struct { + mu sync.Mutex + live map[string]bool + exchanged []string + issued int + account string + now func() time.Time +} + +func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed { + v.mu.Lock() + defer v.mu.Unlock() + v.exchanged = append(v.exchanged, g.RefreshToken) + if !v.live[g.RefreshToken] { + return Refreshed{Status: 400, Reason: `{"error":"invalid_grant"}`} + } + delete(v.live, g.RefreshToken) + v.issued++ + next := fmt.Sprintf("rt-%d", v.issued) + v.live[next] = true + g.AccessToken = fmt.Sprintf("at-%d", v.issued) + g.RefreshToken = next + g.ExpiresAt = v.now().Add(8 * time.Hour).UnixMilli() + exp := v.now().Add(30 * 24 * time.Hour).UnixMilli() + g.RefreshTokenExpiresAt = &exp + return Refreshed{OK: true, Grant: g, Account: v.account} +} + +func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) { + return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil +} + +type miniMesh struct { + m *Manager + store *MemoryStore + vendor *stubVendor + logins map[string]FullGrant // node → what its credentials file holds + state map[string]BindingState + events []string + now time.Time + keys KeyPair + askDown bool +} + +func newMesh(t *testing.T) *miniMesh { + t.Helper() + mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0} + now := func() time.Time { return mm.now } + mm.store = NewMemoryStore(now) + mm.vendor = &stubVendor{live: map[string]bool{}, now: now} + crypt, _ := NewCrypt("a key the vault made") + mm.keys, _ = GenerateKeyPair() + mm.m = &Manager{ + Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys, + AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { + if mm.askDown { + return GrantAnswer{}, fmt.Errorf("503 no responders") + } + g, ok := mm.logins[node] + if !ok { + return GrantAnswer{}, nil + } + raw, _ := json.Marshal(g) + box, err := Seal(string(raw), publicKey) + return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err + }, + PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil }, + DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil }, + Emit: func(event string, body map[string]any) error { + raw, _ := json.Marshal(body) + mm.events = append(mm.events, event+" "+string(raw)) + return nil + }, + Now: now, Log: func(string, ...any) {}, Holder: "test", Settings: Defaults, + } + return mm +} + +func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings { + h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"}, + Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)} + if rt != "" { + h.Refresh.Present, h.Refresh.Fingerprint = true, Fingerprint(rt) + } + return h +} + +func (mm *miniMesh) login(node, rt string, valid bool, at time.Time) Holdings { + mm.logins[node] = FullGrant{AccessToken: "local-" + node, RefreshToken: rt, ExpiresAt: mm.now.Add(time.Hour).UnixMilli()} + if valid { + mm.vendor.live[rt] = true + } + return mm.report(node, rt, at, "acct-1") +} + +const licence1 = "acct-1@example.org" + +func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchangesTheRest(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + older := mm.login("server", "rt-server", true, t0.Add(-time.Hour)) + newest := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute)) + adopted, err := mm.m.Consider(ctx, []Holdings{older, newest}) + if err != nil || len(adopted) != 1 || adopted[0] != licence1 { + t.Fatalf("adopted %v, %v", adopted, err) + } + if strings.Join(mm.vendor.exchanged, ",") != "rt-laptop" { + t.Fatalf("exchanged %v: an older login was exchanged although a newer one refreshed", mm.vendor.exchanged) + } + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-server")); o != Skipped { + t.Fatalf("the older login is %q, not skipped", o) + } + // A first binding follows the login: both nodes reported this account and were bound to nothing. + bs, _ := mm.store.Bindings(ctx) + if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { + t.Fatalf("bindings %v, state %v", bs, mm.state) + } + if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") { + t.Fatalf("events %v", mm.events) + } +} + +func TestALoginThatDoesNotRefreshAdoptsNothingAndIsNotTriedAgain(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + dead := mm.login("laptop", "rt-dead", false, t0) + if adopted, _ := mm.m.Consider(ctx, []Holdings{dead}); len(adopted) != 0 { + t.Fatalf("adopted %v", adopted) + } + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-dead")); o != Dead { + t.Fatalf("outcome %q", o) + } + if ls, _ := mm.store.Licences(ctx); len(ls) != 0 { + t.Fatalf("licences %v", ls) + } + if !strings.Contains(strings.Join(mm.events, "|"), "licence.refused") { + t.Fatalf("events %v", mm.events) + } + _, _ = mm.m.Consider(ctx, []Holdings{dead}) + if len(mm.vendor.exchanged) != 1 { + t.Fatalf("a dead refresh token was exchanged %d times", len(mm.vendor.exchanged)) + } +} + +func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))}) + before, _ := mm.store.Licence(ctx, licence1) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))}) + if strings.Join(mm.vendor.exchanged, ",") != "rt-a" { + t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged) + } + mm.now = t0.Add(10 * time.Minute) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("desktop", "rt-new", true, t0.Add(10*time.Minute))}) + after, _ := mm.store.Licence(ctx, licence1) + if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" { + t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged) + } + if ls, _ := mm.store.Licences(ctx); len(ls) != 1 { + t.Fatalf("one account became %d licences", len(ls)) + } +} + +func TestAReportNamingAnotherAccountThanTheVendorAnsweredForIsRefused(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + mm.vendor.account = "someone-else" + if adopted, _ := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); len(adopted) != 0 { + t.Fatalf("adopted %v", adopted) + } + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != Refused { + t.Fatalf("outcome %q", o) + } +} + +func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + mm.now = t0.Add(5 * time.Hour) + second := *mm.m + second.Holder = "another run" + var wg sync.WaitGroup + results := make([]map[string]any, 2) + for i, m := range []*Manager{mm.m, &second} { + wg.Add(1) + go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }() + } + wg.Wait() + n := 0 + for _, r := range results { + if r["refreshed"] == true { + n++ + } + } + if n != 1 { + t.Fatalf("rotated %d times: %v", n, results) + } + if r, _ := second.Rotate(ctx, licence1, false); r["reason"] != "not due" { + t.Fatalf("a run just after was %v", r) + } +} + +func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + g0 := mm.state["laptop"].Generation + _, _ = mm.m.Rotate(ctx, licence1, true) + g1 := mm.state["laptop"].Generation + if g1 <= g0 { + t.Fatalf("a rotation went from %d to %d", g0, g1) + } + file := filepath.Join(t.TempDir(), "key") + _ = os.WriteFile(file, []byte("sk-ant-api-key\n"), 0o600) + if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil { + t.Fatal(err) + } + if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil { + t.Fatal(err) + } + if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 { + t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"]) + } + if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil { + t.Fatal(err) + } + if _, ok := mm.state["laptop"]; ok { + t.Fatal("a released consumer still has a binding in the state") + } +} + +func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + node, _ := GenerateKeyPair() + answer, err := mm.m.Current(ctx, "laptop", node.PublicKey) + if err != nil || answer["kind"] != "subscription" { + t.Fatalf("%v %v", answer, err) + } + box := answer["sealed"].(SealedBox) + plain, err := Open(box, node.PrivateKey) + if err != nil { + t.Fatal(err) + } + var handed map[string]any + _ = json.Unmarshal([]byte(plain), &handed) + if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil { + t.Fatalf("handed %v", handed) + } + other, _ := GenerateKeyPair() + if _, err := Open(box, other.PrivateKey); err == nil { + t.Fatal("the hand-over opened with another key") + } + if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil { + t.Fatalf("a consumer bound to nothing was answered %v", a) + } +} + +func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)}) + _, _ = mm.m.Rotate(ctx, licence1, true) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-secret-dead", false, t0.Add(time.Hour))}) + _ = mm.m.ReadUsage(ctx) + ls, _ := mm.m.Licences(ctx) + bs, _ := mm.store.Bindings(ctx) + everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs}) + for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} { + if strings.Contains(string(everything), token) { + t.Fatalf("%s was published: %s", token, everything) + } + } + row, _ := mm.store.Licence(ctx, licence1) + if strings.Contains(row.Sealed, "rt-") { + t.Fatal("the grant is stored in the clear") + } +} + +func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + r := mm.login("laptop", "rt-a", true, t0) + mm.askDown = true + if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 0 { + t.Fatalf("adopted %v from a node that did not answer", adopted) + } + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != "" { + t.Fatalf("a node that was down was settled %q", o) + } + mm.askDown = false + if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 1 { + t.Fatalf("adopted %v on the next pass", adopted) + } + gone := mm.report("server", "rt-gone", t0.Add(time.Second), "acct-2") + _, _ = mm.m.Consider(ctx, []Holdings{gone}) + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-gone")); o != Gone { + t.Fatalf("outcome %q", o) + } +} + +func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) { + // The agent module's own report shape (claude-code's holdingsOf), parsed here. + raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription", + "refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null}, + "access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0, + "changedAt":"2026-10-04T11:00:00.000Z"}` + var h Holdings + if err := json.Unmarshal([]byte(raw), &h); err != nil { + t.Fatal(err) + } + if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" { + t.Fatalf("%+v", h) + } + if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil { + t.Fatalf("the report's time does not parse: %v", err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go new file mode 100644 index 0000000..dee0b97 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go @@ -0,0 +1,281 @@ +package main + +// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this +// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each +// statement idempotent. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Schema is what this version needs. +var Schema = []string{ + `create table if not exists licence ( + name text primary key, + kind text not null check (kind in ('subscription', 'api-key')), + account_uuid text unique, + email text, + organization_uuid text, + sealed text, + refresh_fingerprint text, + access_expires_at bigint, + refresh_expires_at bigint, + failures integer not null default 0, + notified_at bigint, + adopted_at bigint not null, + rotated_at bigint)`, + `create sequence if not exists binding_generation`, + `create table if not exists binding ( + consumer text primary key, + licence text not null references licence(name), + generation bigint not null)`, + `create table if not exists lease ( + key text primary key, + holder text not null, + until timestamptz not null)`, + `create table if not exists offered ( + fingerprint text primary key, + node text not null, + account_uuid text, + outcome text not null, + why text not null, + at timestamptz not null default now())`, + `create table if not exists usage ( + licence text not null, + at bigint not null, + reading jsonb not null, + raw jsonb not null)`, + `create index if not exists usage_by_licence on usage (licence, at desc)`, + `create table if not exists audit ( + at timestamptz not null default now(), + what text not null, + detail jsonb not null)`, +} + +// PgStore is the store on postgres. +type PgStore struct{ pool *pgxpool.Pool } + +// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names. +func PgStoreFromEnv(ctx context.Context) (*PgStore, error) { + file := os.Getenv("DATABASE_URL_FILE") + if file == "" { + return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves") + } + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + return OpenPgStore(ctx, strings.TrimSpace(string(raw))) +} + +// OpenPgStore opens a store at a URL. +func OpenPgStore(ctx context.Context, url string) (*PgStore, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, err + } + return &PgStore{pool: pool}, nil +} + +// Migrate brings the schema to this version's shape. +func (s *PgStore) Migrate(ctx context.Context) error { + for _, q := range Schema { + if _, err := s.pool.Exec(ctx, q); err != nil { + return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err) + } + } + return nil +} + +const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''), + coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0), + failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)` + +func scanLicence(row pgx.Row) (*Licence, error) { + var l Licence + err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint, + &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return &l, err +} + +func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) { + rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Licence + for rows.Next() { + l, err := scanLicence(rows) + if err != nil { + return nil, err + } + out = append(out, *l) + } + return out, rows.Err() +} + +func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) { + return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name)) +} + +func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) { + return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account)) +} + +func nullable(s string) any { + if s == "" { + return nil + } + return s +} + +func nullableInt(v int64) any { + if v == 0 { + return nil + } + return v +} + +func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error { + _, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint, + access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email, + organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint, + access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at, + failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at, + rotated_at = excluded.rotated_at`, + l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed), + nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures, + nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt)) + return err +} + +// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds +// it live and does nothing. +func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) { + tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3)) + on conflict (key) do update set holder = excluded.holder, until = excluded.until + where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds()) + if err != nil { + return false, err + } + return tag.RowsAffected() == 1, nil +} + +func (s *PgStore) Unlease(ctx context.Context, key, holder string) error { + _, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder) + return err +} + +func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) { + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Binding + for rows.Next() { + var b Binding + if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) { + return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`) +} + +func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) { + out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer) + if err != nil || len(out) == 0 { + return nil, err + } + return &out[0], nil +} + +func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) { + out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation')) + on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation + returning consumer, licence, generation`, consumer, licence) + if err != nil { + return Binding{}, err + } + return out[0], nil +} + +func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) { + tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer) + return err == nil && tag.RowsAffected() == 1, err +} + +func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) { + return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1 + returning consumer, licence, generation`, licence) +} + +func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) { + var o string + err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return Outcome(o), err +} + +func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error { + _, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5) + on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`, + fp, node, nullable(account), string(o), why) + return err +} + +func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error { + reading, _ := json.Marshal(r) + rawJSON, _ := json.Marshal(raw) + _, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON) + return err +} + +func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) { + rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []UsageRow + for rows.Next() { + var u UsageRow + var reading []byte + if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil { + return nil, err + } + _ = json.Unmarshal(reading, &u.Reading) + out = append(out, u) + } + return out, rows.Err() +} + +func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error { + raw, _ := json.Marshal(detail) + _, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw) + return err +} + +func (s *PgStore) Close() { s.pool.Close() } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal.go b/modules/claude-licence-manager/cmd/claude-licence-manager/seal.go new file mode 100644 index 0000000..b0f428f --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/seal.go @@ -0,0 +1,189 @@ +package main + +// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to +// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box +// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for +// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test +// reopens what this seals with the same derivation. +// +// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER, +// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed. + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" +) + +// SealedBox is a value sealed to one recipient. +type SealedBox struct { + V int `json:"v"` + Eph string `json:"eph"` + IV string `json:"iv"` + Tag string `json:"tag"` + Ct string `json:"ct"` +} + +// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as. +type KeyPair struct { + PublicKey string `json:"publicKey"` + PrivateKey string `json:"privateKey"` +} + +const sealInfo = "novox-mesh sealed box v1" + +// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are. +func GenerateKeyPair() (KeyPair, error) { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return KeyPair{}, err + } + pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey()) + if err != nil { + return KeyPair{}, err + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return KeyPair{}, err + } + return KeyPair{ + PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})), + PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})), + }, nil +} + +func publicFromPEM(p string) (*ecdh.PublicKey, error) { + block, _ := pem.Decode([]byte(p)) + if block == nil { + return nil, errors.New("not a PEM public key") + } + k, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + pub, ok := k.(*ecdh.PublicKey) + if !ok || pub.Curve() != ecdh.X25519() { + return nil, errors.New("not an X25519 public key") + } + return pub, nil +} + +func privateFromPEM(p string) (*ecdh.PrivateKey, error) { + block, _ := pem.Decode([]byte(p)) + if block == nil { + return nil, errors.New("not a PEM private key") + } + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + priv, ok := k.(*ecdh.PrivateKey) + if !ok || priv.Curve() != ecdh.X25519() { + return nil, errors.New("not an X25519 private key") + } + return priv, nil +} + +func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) { + salt := append(append([]byte{}, ephDER...), recipientRaw...) + return hkdf.Key(sha256.New, secret, salt, sealInfo, 32) +} + +// Seal seals plaintext to the recipient's public key. +func Seal(plaintext, recipientPEM string) (SealedBox, error) { + recipient, err := publicFromPEM(recipientPEM) + if err != nil { + return SealedBox{}, err + } + eph, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return SealedBox{}, err + } + secret, err := eph.ECDH(recipient) + if err != nil { + return SealedBox{}, err + } + ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey()) + if err != nil { + return SealedBox{}, err + } + key, err := boxKey(secret, ephDER, recipient.Bytes()) + if err != nil { + return SealedBox{}, err + } + gcm, err := newGCM(key) + if err != nil { + return SealedBox{}, err + } + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + return SealedBox{}, err + } + out := gcm.Seal(nil, iv, []byte(plaintext), nil) + ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():] + b64 := base64.StdEncoding.EncodeToString + return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil +} + +// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with. +func Open(box SealedBox, privatePEM string) (string, error) { + if box.V != 1 { + return "", errors.New("not a sealed box this module can open") + } + priv, err := privateFromPEM(privatePEM) + if err != nil { + return "", err + } + d := base64.StdEncoding.DecodeString + ephDER, err := d(box.Eph) + if err != nil { + return "", fmt.Errorf("the box's eph: %w", err) + } + ephKey, err := x509.ParsePKIXPublicKey(ephDER) + if err != nil { + return "", err + } + eph, ok := ephKey.(*ecdh.PublicKey) + if !ok { + return "", errors.New("the box's eph is not an X25519 key") + } + secret, err := priv.ECDH(eph) + if err != nil { + return "", err + } + key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes()) + if err != nil { + return "", err + } + iv, err1 := d(box.IV) + tag, err2 := d(box.Tag) + ct, err3 := d(box.Ct) + if err := errors.Join(err1, err2, err3); err != nil { + return "", err + } + gcm, err := newGCM(key) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, iv, append(ct, tag...), nil) + if err != nil { + return "", errors.New("the box does not open with this key") + } + return string(plain), nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go new file mode 100644 index 0000000..0b1f2fb --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// A box the agent module's TypeScript sealed opens here: the two implementations are one format. +func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) { + raw, err := os.ReadFile("testdata/sealed-by-typescript.json") + if err != nil { + t.Fatal(err) + } + var f struct { + PrivateKey string `json:"privateKey"` + Box SealedBox `json:"box"` + Plaintext string `json:"plaintext"` + } + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + got, err := Open(f.Box, f.PrivateKey) + if err != nil || got != f.Plaintext { + t.Fatalf("opened %q, %v", got, err) + } +} + +// What Go seals opens with its own key and no other. +func TestABoxOpensOnlyForItsRecipient(t *testing.T) { + a, _ := GenerateKeyPair() + b, _ := GenerateKeyPair() + box, err := Seal("a token", a.PublicKey) + if err != nil { + t.Fatal(err) + } + if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" { + t.Fatalf("opened %q, %v", got, err) + } + if _, err := Open(box, b.PrivateKey); err == nil { + t.Fatal("a box opened for another key") + } +} + +// And what Go seals opens in the agent module's TypeScript, where node is present to ask. +func TestABoxSealedInGoOpensInTypeScript(t *testing.T) { + dist, _ := filepath.Abs("../../../claude-code/dist/seal.js") + if _, err := os.Stat(dist); err != nil { + t.Skip("claude-code is not built beside this module") + } + if _, err := exec.LookPath("node"); err != nil { + t.Skip("no node") + } + k, _ := GenerateKeyPair() + box, _ := Seal("sealed in Go", k.PublicKey) + boxJSON, _ := json.Marshal(box) + script := `import { open } from "` + dist + `"; const [box, key] = process.argv.slice(1); process.stdout.write(open(JSON.parse(box), key));` + out, err := exec.Command("node", "--input-type=module", "-e", script, string(boxJSON), k.PrivateKey).CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) != "sealed in Go" { + t.Fatalf("TypeScript opened %q: %v", out, err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go new file mode 100644 index 0000000..2ff09da --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -0,0 +1,263 @@ +package main + +// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings +// with a generation, what became of each login it was offered, usage readings, and the audit. One +// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every +// rule is tested without a database, and the database is asked only to keep rows. + +import ( + "context" + "sort" + "sync" + "time" +) + +// Licence is one licence: an account, or an API key. +type Licence struct { + Name string `json:"name"` + Kind string `json:"kind"` // subscription | api-key + AccountUUID string `json:"accountUuid,omitempty"` + Email string `json:"email,omitempty"` + OrganizationUUID string `json:"organizationUuid,omitempty"` + Sealed string `json:"-"` // the grant or the key, encrypted at rest + RefreshFingerprint string `json:"-"` + AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` + RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` + Failures int `json:"failures"` + NotifiedAt int64 `json:"-"` + AdoptedAt int64 `json:"adoptedAt"` + RotatedAt int64 `json:"rotatedAt,omitempty"` +} + +// Binding is one consumer's binding and the generation it was last given (ADR 0206). +type Binding struct { + Consumer string `json:"consumer"` + Licence string `json:"licence"` + Generation int64 `json:"generation"` +} + +// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint. +type Outcome string + +const ( + Adopted Outcome = "adopted" + Dead Outcome = "dead" + Skipped Outcome = "skipped" + Refused Outcome = "refused" + Gone Outcome = "gone" +) + +// UsageRow is one usage reading. +type UsageRow struct { + Licence string `json:"licence"` + At int64 `json:"at"` + Reading UsageReading `json:"reading"` +} + +// Store is what the manager keeps. +type Store interface { + Licences(ctx context.Context) ([]Licence, error) + Licence(ctx context.Context, name string) (*Licence, error) + LicenceForAccount(ctx context.Context, account string) (*Licence, error) + SaveLicence(ctx context.Context, l Licence) error + // Lease takes a lease for d, or answers false while another holder's is live. + Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) + Unlease(ctx context.Context, key, holder string) error + Bindings(ctx context.Context) ([]Binding, error) + Binding(ctx context.Context, consumer string) (*Binding, error) + // Bind binds (or switches) a consumer at the next generation. + Bind(ctx context.Context, consumer, licence string) (Binding, error) + Unbind(ctx context.Context, consumer string) (bool, error) + // Advance gives every consumer of a licence a new generation: what a rotation is to them. + Advance(ctx context.Context, licence string) ([]Binding, error) + Outcome(ctx context.Context, fingerprint string) (Outcome, error) + RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error + RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error + Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) + Audit(ctx context.Context, what string, detail map[string]any) error + Close() +} + +// MemoryStore is the tests' store. +type MemoryStore struct { + mu sync.Mutex + now func() time.Time + rows map[string]Licence + binds map[string]Binding + leases map[string]struct { + holder string + until time.Time + } + outcomes map[string]Outcome + usage []UsageRow + Audits []map[string]any + generation int64 +} + +// NewMemoryStore is an empty store whose leases age by now. +func NewMemoryStore(now func() time.Time) *MemoryStore { + return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{}, + leases: map[string]struct { + holder string + until time.Time + }{}, outcomes: map[string]Outcome{}} +} + +func (m *MemoryStore) Licences(context.Context) ([]Licence, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Licence, 0, len(m.rows)) + for _, l := range m.rows { + out = append(out, l) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) { + m.mu.Lock() + defer m.mu.Unlock() + if l, ok := m.rows[name]; ok { + return &l, nil + } + return nil, nil +} + +func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, l := range m.rows { + if l.AccountUUID == account { + return &l, nil + } + } + return nil, nil +} + +func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[l.Name] = l + return nil +} + +func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder { + return false, nil + } + m.leases[key] = struct { + holder string + until time.Time + }{holder, m.now().Add(d)} + return true, nil +} + +func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.leases[key].holder == holder { + delete(m.leases, key) + } + return nil +} + +func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Binding, 0, len(m.binds)) + for _, b := range m.binds { + out = append(out, b) + } + sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) + return out, nil +} + +func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) { + m.mu.Lock() + defer m.mu.Unlock() + if b, ok := m.binds[consumer]; ok { + return &b, nil + } + return nil, nil +} + +func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.generation++ + b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation} + m.binds[consumer] = b + return b, nil +} + +func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.binds[consumer] + delete(m.binds, consumer) + return ok, nil +} + +func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []Binding + for c, b := range m.binds { + if b.Licence != licence { + continue + } + m.generation++ + b.Generation = m.generation + m.binds[c] = b + out = append(out, b) + } + sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) + return out, nil +} + +func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.outcomes[fp], nil +} + +func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.outcomes[fp] = o + return nil +} + +func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error { + m.mu.Lock() + defer m.mu.Unlock() + m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r}) + return nil +} + +func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []UsageRow + for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- { + if licence == "" || m.usage[i].Licence == licence { + out = append(out, m.usage[i]) + } + } + return out, nil +} + +func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error { + m.mu.Lock() + defer m.mu.Unlock() + d := map[string]any{"what": what} + for k, v := range detail { + d[k] = v + } + m.Audits = append(m.Audits, d) + return nil +} + +func (m *MemoryStore) Close() {} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go new file mode 100644 index 0000000..957fdbf --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// Against a real postgres, because the questions are the database's: does the schema apply twice, does a +// lease refuse a second holder, does a generation only grow. Skipped unless one is named: +// +// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine +// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./... +func TestTheStoreOnPostgres(t *testing.T) { + url := os.Getenv("MESH_TEST_POSTGRES") + if url == "" { + t.Skip("MESH_TEST_POSTGRES unset") + } + ctx := context.Background() + s, err := OpenPgStore(ctx, url) + if err != nil { + t.Fatal(err) + } + defer s.Close() + for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} { + _, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade") + } + _, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation") + for i := 0; i < 2; i++ { + if err := s.Migrate(ctx); err != nil { + t.Fatalf("migration %d: %v", i+1, err) + } + } + l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y", + RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3} + if err := s.SaveLicence(ctx, l); err != nil { + t.Fatal(err) + } + l.Failures = 2 + _ = s.SaveLicence(ctx, l) + if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" { + t.Fatalf("%+v", got) + } + if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil { + t.Fatalf("%v %v", none, err) + } + lease := func(holder string) bool { + ok, err := s.Lease(ctx, "licence:a", holder, time.Minute) + if err != nil { + t.Fatal(err) + } + return ok + } + if !lease("one") || lease("two") || !lease("one") { + t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it") + } + _ = s.Unlease(ctx, "licence:a", "one") + if !lease("two") { + t.Fatal("a released lease was not taken") + } + b1, _ := s.Bind(ctx, "laptop", l.Name) + adv, _ := s.Advance(ctx, l.Name) + b3, _ := s.Bind(ctx, "laptop", l.Name) + if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) { + t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation) + } + _ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400") + if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead { + t.Fatalf("outcome %q", o) + } + if o, _ := s.Outcome(ctx, "sha256:none"); o != "" { + t.Fatalf("an unknown login is %q", o) + } + pct := 1.0 + _ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{}) + if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 { + t.Fatalf("usage %v", u) + } + if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil { + t.Fatal(err) + } + if ok, _ := s.Unbind(ctx, "laptop"); !ok { + t.Fatal("unbind") + } + all, _ := s.Licences(ctx) + if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") { + t.Fatalf("%v", all) + } +} + +func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { + prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1} + in := int64(60) + kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000) + if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 { + t.Fatalf("%+v", kept) + } + rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0) + if rotated.Grant.RefreshToken != "r2" { + t.Fatalf("%+v", rotated) + } + if NextGrant(prev, tokenResponse{}, 0).OK { + t.Fatal("an answer with no access token was a grant") + } +} + +func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { + a, _ := NewCrypt("one key") + b, _ := NewCrypt("another key") + sealed := a.Seal(`{"refreshToken":"rt"}`) + if strings.Contains(sealed, "rt") { + t.Fatal("stored in the clear") + } + if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` { + t.Fatalf("%q %v", got, err) + } + if _, err := b.Open(sealed); err == nil { + t.Fatal("opened with another key") + } + if _, err := NewCrypt(" "); err == nil { + t.Fatal("an empty key was accepted") + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/sealed-by-typescript.json b/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/sealed-by-typescript.json new file mode 100644 index 0000000..ba811d7 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/sealed-by-typescript.json @@ -0,0 +1,12 @@ +{ + "privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=", + "iv": "ABqT/hMukn6+xpjh", + "tag": "POzmsWTPHSn9xLMMJJ9Akg==", + "ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC" + }, + "plaintext": "a grant sealed by the TypeScript agent module" +} \ No newline at end of file diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go new file mode 100644 index 0000000..522bd8f --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go @@ -0,0 +1,187 @@ +package main + +// The only file that talks to Anthropic (novox/hq ADR 0183): the token endpoint, which this module alone +// calls — one rotation source — and the usage endpoint. Ported from the predecessor's manager, whose +// client id and error handling were each earned by an incident. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place +// answers 400, which the predecessor once misdiagnosed as a dead grant. +const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + +func tokenEndpoint() string { + if v := os.Getenv("MESH_ANTHROPIC_TOKEN_ENDPOINT"); v != "" { + return v + } + return "https://platform.claude.com/v1/oauth/token" +} + +func usageEndpoint() string { + if v := os.Getenv("MESH_ANTHROPIC_USAGE_ENDPOINT"); v != "" { + return v + } + return "https://api.anthropic.com/api/oauth/usage" +} + +// FullGrant is a subscription's grant as this module keeps it: what the agent's credentials file calls +// `claudeAiOauth`. +type FullGrant struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"` + Scopes []string `json:"scopes,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` + RateLimitTier string `json:"rateLimitTier,omitempty"` +} + +// Refreshed is what a refresh came to: the next grant and the account the vendor answered for, or why not. +type Refreshed struct { + OK bool + Grant FullGrant + Account string // empty when the vendor named none + Status int + Reason string +} + +// Vendor is the vendor as the manager reaches it; a test stubs it. +type Vendor interface { + Refresh(ctx context.Context, g FullGrant) Refreshed + Usage(ctx context.Context, accessToken string) (map[string]any, error) +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn *int64 `json:"expires_in"` + RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"` + Scope string `json:"scope"` + Scopes []string `json:"scopes"` + SubscriptionType string `json:"subscription_type"` + Account *struct { + UUID string `json:"uuid"` + } `json:"account"` +} + +// NextGrant is the grant a refresh answered, laid over the one refreshed: a refresh token the vendor did +// not rotate is kept, so a rotating vendor and one that does not are both handled. +func NextGrant(prev FullGrant, r tokenResponse, nowMs int64) Refreshed { + if r.AccessToken == "" { + return Refreshed{Status: 200, Reason: "the vendor answered without an access token"} + } + g := prev + g.AccessToken = r.AccessToken + if r.RefreshToken != "" { + g.RefreshToken = r.RefreshToken + } + if r.ExpiresIn != nil { + g.ExpiresAt = nowMs + *r.ExpiresIn*1000 + } + if r.RefreshTokenExpiresIn != nil { + v := nowMs + *r.RefreshTokenExpiresIn*1000 + g.RefreshTokenExpiresAt = &v + } + if len(r.Scopes) > 0 { + g.Scopes = r.Scopes + } else if r.Scope != "" { + g.Scopes = strings.Fields(r.Scope) + } + if r.SubscriptionType != "" { + g.SubscriptionType = r.SubscriptionType + } + out := Refreshed{OK: true, Grant: g} + if r.Account != nil { + out.Account = r.Account.UUID + } + return out +} + +type liveVendor struct{ client *http.Client } + +// LiveVendor is the vendor over the network. +func LiveVendor() Vendor { return liveVendor{client: &http.Client{Timeout: 30 * time.Second}} } + +func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed { + form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {g.RefreshToken}, "client_id": {clientID}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint(), strings.NewReader(form.Encode())) + if err != nil { + return Refreshed{Reason: err.Error()} + } + req.Header.Set("content-type", "application/x-www-form-urlencoded") + resp, err := v.client.Do(req) + if err != nil { + return Refreshed{Reason: "the token endpoint did not answer: " + err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode/100 != 2 { + // The body, never only the status: a malformed request and a revoked grant both answer 400. + reason := string(body) + if len(reason) > 400 { + reason = reason[:400] + } + return Refreshed{Status: resp.StatusCode, Reason: reason} + } + var r tokenResponse + if err := json.Unmarshal(body, &r); err != nil { + return Refreshed{Status: resp.StatusCode, Reason: "the vendor's answer is not JSON"} + } + return NextGrant(g, r, time.Now().UnixMilli()) +} + +func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil) + if err != nil { + return nil, err + } + req.Header.Set("authorization", "Bearer "+accessToken) + resp, err := v.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode) + } + var out map[string]any + return out, json.NewDecoder(resp.Body).Decode(&out) +} + +// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows. +type UsageReading struct { + SessionPct *float64 `json:"sessionPct"` + SessionResetsAt string `json:"sessionResetsAt,omitempty"` + WeeklyPct *float64 `json:"weeklyPct"` + SonnetPct *float64 `json:"sonnetPct"` +} + +// FlattenUsage reads the windows the predecessor read. +func FlattenUsage(u map[string]any) UsageReading { + window := func(k string) (*float64, string) { + w, ok := u[k].(map[string]any) + if !ok { + return nil, "" + } + pct, ok := w["utilization"].(float64) + resets, _ := w["resets_at"].(string) + if !ok { + return nil, resets + } + return &pct, resets + } + s, resets := window("five_hour") + w, _ := window("seven_day") + so, _ := window("seven_day_sonnet") + return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so} +} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod new file mode 100644 index 0000000..dc914d5 --- /dev/null +++ b/modules/claude-licence-manager/go.mod @@ -0,0 +1,16 @@ +module claude-licence-manager + +go 1.25.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.7 + github.com/jackc/pgx/v5 v5.11.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/modules/claude-licence-manager/go.sum b/modules/claude-licence-manager/go.sum new file mode 100644 index 0000000..79d630d --- /dev/null +++ b/modules/claude-licence-manager/go.sum @@ -0,0 +1,28 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json new file mode 100644 index 0000000..9741e93 --- /dev/null +++ b/modules/claude-licence-manager/module.json @@ -0,0 +1,130 @@ +{ + "module": "claude-licence-manager", + "version": "1", + "slug": "licmgr", + "requires": [ + "postgres-database", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "claude_licences" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "grant-key": "${dir:state}/grant.key" + } + }, + "seats": [ + { + "name": "anthropic-licence-manager", + "scope": "mesh", + "serves": [ + "licences", + "bindings", + "bind", + "switch", + "release", + "refresh", + "usage", + "adopt", + "current" + ] + } + ], + "claims": [ + { + "name": "anthropic-licence-manager", + "scope": "mesh", + "serves": [ + "licences", + "bindings", + "bind", + "switch", + "release", + "refresh", + "usage", + "adopt", + "current" + ] + } + ], + "emits": [ + "licence.adopted", + "licence.refused", + "licence.failing", + "usage.read" + ], + "state": [ + "bindings" + ], + "reads": [ + "claude-code.holdings" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "merge": "json", + "content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n" + }, + { + "id": "prepare", + "type": "process", + "name": "claude-licence-manager-prepare", + "artifact": "code", + "run": [ + "./claude-licence-manager", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/claude-licence-manager", + "binary": "claude-licence-manager", + "loads": [ + "claude-licence-manager" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_LICENCE_STATE": "${dir:state}", + "MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key", + "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json" + } + } + ] + } +}