mesh/delivery merged: source.main moved to novox/mesh-catalog@6d000647de2d
mesh/check being carried to cff1005422c1 on 6d000647de2d | from f9dcbc295723
The one bundle that did everything becomes the manager bundle, which runs long and answers the seat's ten verbs and the three acting tools, and a tools bundle of its own that answers the four reading tools and writes nothing, so they answer while the manager fails to load or stops. Both import the module's one package for the layouts of the kept data and the log entries, the key form, the entries' names and the settings with their validity; the settings file and the account with its directory stay as they are. Builds: design 39 parts §1, §2, §3, §4, §5, §6, §7, §8
1001 lines
38 KiB
Go
1001 lines
38 KiB
Go
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"
|
|
"sync"
|
|
"time"
|
|
|
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
|
|
|
. "claude-licence-manager/internal/licences"
|
|
)
|
|
|
|
// 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"`
|
|
// Generation is the binding generation the node last applied (novox/hq issue 243).
|
|
Generation int64 `json:"generation"`
|
|
}
|
|
|
|
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
|
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
|
type Cause struct {
|
|
Why string
|
|
By string
|
|
}
|
|
|
|
// Itself names this module where it moved a binding by a rule of its own, with no caller.
|
|
const Itself = "claude-licence-manager"
|
|
|
|
type causeKey struct{}
|
|
|
|
// WithCause is ctx carrying why what runs in it changes a binding, and who asked.
|
|
func WithCause(ctx context.Context, c Cause) context.Context {
|
|
return context.WithValue(ctx, causeKey{}, c)
|
|
}
|
|
|
|
// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always
|
|
// says something, and a change nobody named a cause for reads as this module's own.
|
|
func CauseOf(ctx context.Context) Cause {
|
|
if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" {
|
|
return c
|
|
}
|
|
return Cause{Why: "a rule of this module's own", By: Itself}
|
|
}
|
|
|
|
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
|
|
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
|
|
// `rotated` are said no more (B16). Never a token.
|
|
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
|
|
if err := m.Store.Audit(ctx, what, detail); err != nil {
|
|
m.Log("%s, but %v", line, err)
|
|
return err
|
|
}
|
|
m.Log("%s", line)
|
|
return nil
|
|
}
|
|
|
|
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
|
|
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
|
|
func (m *Manager) changed(b Binding, from string) {
|
|
key := ""
|
|
if b.Licence != "" {
|
|
key = KeyFor(b.Licence)
|
|
}
|
|
_ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
|
|
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
|
|
"at": m.Now().UTC().Format(time.RFC3339Nano)})
|
|
}
|
|
|
|
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
|
|
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
|
|
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
|
|
}
|
|
|
|
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
|
|
// §27's fields), and as binding.changed.
|
|
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
|
|
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
|
|
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
|
|
"why": c.Why, "by": c.By}
|
|
if from != "" && from != b.Licence {
|
|
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
|
|
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
|
|
}
|
|
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
|
|
m.changed(b, from)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
|
|
Grants Grants
|
|
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)
|
|
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
|
|
Reported func(node string) int64
|
|
Emit func(event string, body map[string]any) error
|
|
// Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28).
|
|
Tell func(ctx context.Context, headline string, lines []string) 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 are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
|
|
// keeps them.
|
|
Settings Settings
|
|
SettingsFile string
|
|
settingsMu sync.RWMutex
|
|
// Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator
|
|
// channel; either nil says none.
|
|
Conditions State
|
|
Say func(c oc.Condition) error
|
|
conditionsMu sync.Mutex
|
|
}
|
|
|
|
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) {
|
|
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
|
|
// and moves past the number its node reported applying.
|
|
floor := m.floorIn(reports)
|
|
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)
|
|
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
|
|
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
|
|
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
|
|
if err := m.bindReporters(ctx, reports, floor); err != nil {
|
|
return nil, err
|
|
}
|
|
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, floor)
|
|
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
|
|
}
|
|
|
|
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
|
|
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error {
|
|
for _, rep := range reports {
|
|
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
|
continue
|
|
}
|
|
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
l, err := m.Store.LicenceForAccount(ctx, rep.Identity.AccountUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if l == nil {
|
|
continue
|
|
}
|
|
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
|
b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.moved(ctx, b, "", c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (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
|
|
}
|
|
|
|
// A new licence whose key another licence already has is refused before its grant is exchanged
|
|
// (design 39 bus-and-switch §10).
|
|
if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil {
|
|
return "", err
|
|
} else if held == nil {
|
|
taken, err := m.keyTaken(ctx, NameFor(c.Identity))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if taken != "" {
|
|
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
|
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
|
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
|
|
return "", err
|
|
}
|
|
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
|
|
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))
|
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
|
|
return "", err
|
|
}
|
|
_ = 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)
|
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
|
|
return "", err
|
|
}
|
|
_ = 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, AdoptedFrom: c.Node}
|
|
if held != nil {
|
|
// A newer login keeps what the key holds beside its grant: its latest reading (§2, §24).
|
|
l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage
|
|
if l.Email == "" {
|
|
l.Email = held.Email
|
|
}
|
|
if l.OrganizationUUID == "" {
|
|
l.OrganizationUUID = held.OrganizationUUID
|
|
}
|
|
}
|
|
if err := m.keep(ctx, &l, r.Grant); err != nil {
|
|
return "", err
|
|
}
|
|
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)
|
|
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
|
"vendorNamedAccount": r.Account != ""}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
|
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
|
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
|
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
|
ctx = WithCause(ctx, cause)
|
|
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
|
|
// (§21).
|
|
if err := m.publishAdvance(ctx, l, floor); err != nil {
|
|
return "", err
|
|
}
|
|
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
|
return "", err
|
|
} else if b == nil || b.Licence != l.Name {
|
|
moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
from := ""
|
|
if b != nil {
|
|
from = b.Licence
|
|
}
|
|
if err := m.moved(ctx, moved, from, cause); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
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
|
|
}
|
|
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
|
|
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
|
|
if err := m.moved(ctx, b, "", bc); 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 hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
|
|
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
|
|
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
|
|
grant, _ := json.Marshal(g)
|
|
if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil {
|
|
return err
|
|
}
|
|
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()
|
|
return nil
|
|
}
|
|
|
|
// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15).
|
|
func accessOf(g FullGrant) string {
|
|
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
|
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
|
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
|
return string(access)
|
|
}
|
|
|
|
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
|
|
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
|
|
generation := l.Generation + 1
|
|
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
|
|
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
|
|
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
|
|
}
|
|
}
|
|
l.Generation = generation
|
|
return nil
|
|
}
|
|
|
|
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
|
|
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
|
|
var g FullGrant
|
|
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
|
|
if err != nil {
|
|
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
|
|
}
|
|
return g, json.Unmarshal([]byte(plain), &g)
|
|
}
|
|
|
|
// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why
|
|
// it fetched again (novox/hq issue 625).
|
|
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
|
|
c := CauseOf(ctx)
|
|
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
|
for _, b := range advanced {
|
|
m.changed(b, b.Licence)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what
|
|
// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19).
|
|
func (m *Manager) floorIn(reports []Holdings) func(string) int64 {
|
|
return func(consumer string) int64 {
|
|
node, _, _ := strings.Cut(consumer, ".")
|
|
var f int64
|
|
for _, r := range reports {
|
|
if r.Node == node {
|
|
f = max(f, r.Generation)
|
|
}
|
|
}
|
|
if m.Reported != nil {
|
|
f = max(f, m.Reported(node))
|
|
}
|
|
return f
|
|
}
|
|
}
|
|
|
|
// ---- 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.Generation == 0 {
|
|
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." + KeyFor(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.Generation == 0 {
|
|
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
|
|
}
|
|
g, err := m.grantOf(ctx, *l)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := m.Vendor.Refresh(ctx, g)
|
|
g = FullGrant{}
|
|
if !r.OK {
|
|
l.Failures++
|
|
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
|
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
if err := m.keep(ctx, l, r.Grant); err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
c := CauseOf(ctx)
|
|
if force {
|
|
c.Why = "a refresh asked for: " + c.Why
|
|
} else {
|
|
c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself}
|
|
if by := CauseOf(ctx); by.By != Itself {
|
|
c.By = by.By
|
|
}
|
|
}
|
|
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
|
if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
|
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
|
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); 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 every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied
|
|
// after it (§26), and the conditions said (§9).
|
|
func (m *Manager) ReadUsage(ctx context.Context) error {
|
|
_, err := m.ReadUsageOf(ctx, "", false)
|
|
return err
|
|
}
|
|
|
|
// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26
|
|
// did or, with dry, would do.
|
|
type Read struct {
|
|
Licence string `json:"licence"`
|
|
Reading *UsageReading `json:"reading,omitempty"`
|
|
Error string `json:"readError,omitempty"`
|
|
Switches []Switch `json:"switches"`
|
|
}
|
|
|
|
// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26
|
|
// after each reading unless dry; dry answers what §26 would do (§32, §38).
|
|
func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) {
|
|
if err := m.ReadSettings(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
all, err := m.Store.Licences(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Read{}
|
|
for _, l := range all {
|
|
if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) {
|
|
continue
|
|
}
|
|
// The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4,
|
|
// §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31).
|
|
key := "licence." + KeyFor(l.Name)
|
|
leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if !leased {
|
|
out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}})
|
|
continue
|
|
}
|
|
unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) }
|
|
if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil {
|
|
l = *fresh
|
|
}
|
|
var raw map[string]any
|
|
g, err := m.grantOf(ctx, l)
|
|
if err == nil {
|
|
raw, err = m.Vendor.Usage(ctx, g.AccessToken)
|
|
}
|
|
g = FullGrant{}
|
|
if err == nil && raw == nil {
|
|
err = errors.New("the usage endpoint answered nothing")
|
|
}
|
|
if err != nil {
|
|
// A failed reading keeps the last percentages and writes the vendor's status and reason (§24).
|
|
kept := UsageReading{}
|
|
if l.Usage != nil {
|
|
kept = *l.Usage
|
|
}
|
|
kept.ReadError = err.Error()
|
|
l.Usage = &kept
|
|
err := m.Store.SaveLicence(ctx, l)
|
|
unlease()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
m.Log("reading the usage of %s failed: %v", l.Name, err)
|
|
out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}})
|
|
continue
|
|
}
|
|
reading := FlattenUsage(raw)
|
|
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
|
unlease()
|
|
return out, err
|
|
}
|
|
// The latest reading beside its licence, so licences answers from one key (§2).
|
|
latest := reading
|
|
latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano)
|
|
l.Usage = &latest
|
|
err = m.Store.SaveLicence(ctx, l)
|
|
unlease()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
|
"sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct,
|
|
"weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct,
|
|
"sonnetResetsAt": reading.SonnetResetsAt})
|
|
// On each reading, a licence at its threshold moves every consumer bound to it (§26).
|
|
acts, err := m.ApplyRule(ctx, l.Name, false, dry)
|
|
if acts == nil {
|
|
acts = []Switch{}
|
|
}
|
|
out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts})
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
}
|
|
// What the readings and the settings now hold is said, each condition only when what it says changed (§9).
|
|
return out, m.SayConditions(ctx, false)
|
|
}
|
|
|
|
// ---- the seat's verbs --------------------------------------------------------------------------------
|
|
|
|
// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7,
|
|
// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and
|
|
// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to
|
|
// nothing.
|
|
func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) {
|
|
b, err := m.Store.Binding(ctx, consumer)
|
|
if err != nil || b == nil {
|
|
return nil, err
|
|
}
|
|
l, err := m.Store.Licence(ctx, b.Licence)
|
|
if err != nil || l == nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
|
|
"health": Health(*l, m.Now(), m.settings())}
|
|
if l.AccessExpiresAt != 0 {
|
|
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
|
|
}
|
|
if l.AccountUUID != "" {
|
|
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring`
|
|
// when its refresh token expires within refresh_warn_days, else `ok`.
|
|
func Health(l Licence, now time.Time, s Settings) string {
|
|
switch {
|
|
case l.Failures >= s.FailuresToNotify:
|
|
return "failing"
|
|
case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds():
|
|
return "expiring"
|
|
}
|
|
return "ok"
|
|
}
|
|
|
|
// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with
|
|
// its licence's key beside the §3 fields (B25).
|
|
func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) {
|
|
all, err := m.Store.Bindings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Binding{}
|
|
for _, b := range all {
|
|
if consumer == "" || b.Consumer == consumer {
|
|
b.Key = KeyFor(b.Licence)
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
|
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
|
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
|
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
|
|
}
|
|
c := CauseOf(ctx)
|
|
b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
from := ""
|
|
if before != nil {
|
|
from = before.Licence
|
|
}
|
|
if err := m.moved(ctx, b, from, c); err != nil {
|
|
return nil, err
|
|
}
|
|
if from != "" && from != licence {
|
|
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
|
|
}
|
|
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 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
|
|
}
|
|
c := CauseOf(ctx)
|
|
if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
|
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
|
|
return nil, err
|
|
}
|
|
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
|
|
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
|
}
|
|
|
|
var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
|
|
|
|
// apiKeyShape is what an Anthropic API key looks like. A key of another shape is refused before it is kept,
|
|
// so a file that is not a key (a credential, a private key) is never adopted as one (novox/hq ADR 0266).
|
|
var apiKeyShape = regexp.MustCompile(`^sk-ant-[A-Za-z0-9_-]{20,300}$`)
|
|
|
|
// 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) {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.adoptKey(ctx, name, strings.TrimSpace(string(raw)), "a file on "+m.Holder)
|
|
}
|
|
|
|
// AdoptSealedKey adopts an API key sealed to this module's public key by a node's agent module (ADR 0209):
|
|
// the key crosses the bus only sealed, on request/reply.
|
|
func (m *Manager) AdoptSealedKey(ctx context.Context, name string, box SealedBox, from string) (map[string]any, error) {
|
|
key, err := Open(box, m.Keys.PrivateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.adoptKey(ctx, name, strings.TrimSpace(key), from)
|
|
}
|
|
|
|
func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[string]any, error) {
|
|
if !licenceName.MatchString(name) {
|
|
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
|
|
}
|
|
if key == "" {
|
|
return nil, errors.New("the key is empty")
|
|
}
|
|
if !apiKeyShape.MatchString(key) {
|
|
// Never the content: what was handed may be a secret of another kind.
|
|
return nil, errors.New("what was handed over is not an Anthropic API key (sk-ant-…): nothing adopted")
|
|
}
|
|
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)
|
|
}
|
|
taken, err := m.keyTaken(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if taken != "" {
|
|
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
|
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
|
|
return nil, fmt.Errorf("%s is refused: %s", name, why)
|
|
}
|
|
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from}
|
|
if held != nil {
|
|
l.Generation, l.Usage = held.Generation, held.Usage
|
|
}
|
|
if err := m.handOver(ctx, &l, key, key); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
|
return nil, err
|
|
}
|
|
c := CauseOf(ctx)
|
|
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
|
|
return nil, err
|
|
}
|
|
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
|
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
|
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "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 (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its
|
|
// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's
|
|
// consumers move to next, when and from where it was adopted, and the conditions open about it. 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
|
|
}
|
|
open, err := KeptConditions(m.Conditions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := m.settings()
|
|
stamp := func(ms int64) any {
|
|
if ms == 0 {
|
|
return nil
|
|
}
|
|
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
|
}
|
|
targets := map[string]bool{}
|
|
for _, l := range all {
|
|
if len(consumersOf(l.Name, bindings)) > 0 {
|
|
if to, _ := m.targetFor(l, all, s); to != "" {
|
|
targets[to] = true
|
|
}
|
|
}
|
|
}
|
|
out := []map[string]any{}
|
|
for _, l := range all {
|
|
bound := consumersOf(l.Name, bindings)
|
|
if bound == nil {
|
|
bound = []string{}
|
|
}
|
|
account := l.Email
|
|
if account == "" {
|
|
account = l.AccountUUID
|
|
}
|
|
var room any = m.roomWords(l, s)
|
|
if r, known := Room(l, m.Now(), s); known {
|
|
room = r
|
|
}
|
|
conditions := []map[string]any{}
|
|
prefix := "licence." + KeyFor(l.Name) + "."
|
|
for _, c := range open {
|
|
if strings.HasPrefix(c.Body.Key, prefix) {
|
|
conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity,
|
|
"headline": c.Body.Headline, "since": c.Since})
|
|
}
|
|
}
|
|
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
|
"organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt),
|
|
"refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures,
|
|
"bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt),
|
|
"adoptedFrom": l.AdoptedFrom, "conditions": conditions})
|
|
}
|
|
return out, nil
|
|
}
|