The licence manager, in Go, and claude-code's half of ADR 0206
claude-licence-manager holds the anthropic-licence-manager seat: it reads every node's holdings state, adopts a login it does not hold by refreshing it (newest first, once per account), keeps each grant alive under a lease, publishes what each consumer should hold as its bindings state with a generation, and answers current sealed to the consumer's key. Postgres store prepared by a run-once step; grants encrypted with the vault's key. claude-code reports what its node holds (fingerprints and account, never a token), hands its grant over only when the manager asks, watches its binding and fetches the token on a newer generation, and writes access-token-only. Its ask now reads the runtime's answer as a value and addresses seats as seats.
This commit is contained in:
@@ -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.<iv>.<ciphertext and tag>`.
|
||||
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
|
||||
}
|
||||
@@ -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 `<seat>.<verb>`, 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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user