diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 5ca869a5f..1bb5cfe0c 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -259,8 +259,12 @@ func tools() []stdio.Tool { 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("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.", + map[string]any{"consumer": str("a consumer; absent for every one")}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + c, _ := a["consumer"].(string) + return m.BindingsOf(ctx, strings.TrimSpace(c)) + }), 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) { @@ -334,14 +338,14 @@ func tools() []stdio.Tool { nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { return map[string]any{"public_key": m.Keys.PublicKey}, nil }), - 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")}, + verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.", + map[string]any{"consumer": consumer}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - c, k, err := two(a, "consumer", "public_key") + c, err := text(a, "consumer") if err != nil { return nil, err } - return m.Current(ctx, c, k) + return m.Current(ctx, c) }), } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 1fa28f126..98a960d8d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -636,47 +636,57 @@ func (m *Manager) ReadUsage(ctx context.Context) error { // ---- 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 +// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7, +// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and +// for a subscription the account's identity the agent module points its account file at. 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") - } +func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) { 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 == "" { + if err != nil || l == nil { return nil, err } - plain, err := m.Crypt.Open(l.Sealed) - if err != nil { - return nil, err + out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation, + "health": Health(*l, m.Now(), m.Settings)} + if l.AccessExpiresAt != 0 { + out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339) } - 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 } +// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring` +// when its refresh token expires within refresh_warn_days, else `ok`. +func Health(l Licence, now time.Time, s Settings) string { + switch { + case l.Failures >= s.FailuresToNotify: + return "failing" + case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds(): + return "expiring" + } + return "ok" +} + +// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged. +func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) { + all, err := m.Store.Bindings(ctx) + if err != nil || consumer == "" { + return all, err + } + out := []Binding{} + for _, b := range all { + if b.Consumer == consumer { + out = append(out, b) + } + } + return out, nil +} + // Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and // recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625). func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) { 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 index 4f8103fba..e8b808d7a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -253,32 +253,39 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { } } -func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) { +// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health. +func TestCurrentHoldsNoToken(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" { + answer, err := mm.m.Current(ctx, "laptop") + if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" || + answer["accessExpiresAt"] == nil || answer["identity"] == nil { t.Fatalf("%v %v", answer, err) } - box := answer["sealed"].(SealedBox) - plain, err := Open(box, node.PrivateKey) - if err != nil { - t.Fatal(err) + raw, _ := json.Marshal(answer) + for _, token := range []string{"rt-", "at-", "sealed"} { + if strings.Contains(string(raw), token) { + t.Fatalf("current answered %s: %s", token, raw) + } } - 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) + l, _ := mm.store.Licence(ctx, licence1) + for failures, health := range map[int]string{0: "ok", 3: "failing"} { + l.Failures = failures + if got := Health(*l, mm.now, mm.m.Settings); got != health { + t.Errorf("%d failures: %s, want %s", failures, got, health) + } } - other, _ := GenerateKeyPair() - if _, err := Open(box, other.PrivateKey); err == nil { - t.Fatal("the hand-over opened with another key") + l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli() + if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" { + t.Errorf("a refresh token expiring in two days: %s", got) } - if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil { + if a, _ := mm.m.Current(ctx, "nobody"); a != nil { t.Fatalf("a consumer bound to nothing was answered %v", a) } + if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" { + t.Fatalf("bindings of laptop: %v", bs) + } } func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {