// Package license provides a thin DB-mirror of the in-memory // license.Result. The actual verify-against-license-server logic // lives in internal/license; this package only persists the latest // result into the licenses table so the UI can show it without a // live network call. package license import ( "context" "encoding/json" "errors" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Repo struct { Pool *pgxpool.Pool } func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } // State is what we persist + what the UI reads. type State struct { LicenseKey string `json:"license_key"` Status string `json:"status"` ValidUntil *time.Time `json:"valid_until,omitempty"` LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"` LastVerifiedNode *string `json:"last_verified_node,omitempty"` ActiveDomainsAtVerify *int `json:"active_domains_at_verify,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` LastError *string `json:"last_error,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Get returns the most-recent license row (we keep one row per key // but typically the operator only has one key — so this returns the // last-updated). Returns sql ErrNoRows-equivalent if none exists. func (r *Repo) Get(ctx context.Context) (*State, error) { row := r.Pool.QueryRow(ctx, ` SELECT license_key, status, valid_until, last_verified_at, last_verified_node, active_domains_at_verify, payload, last_error, created_at, updated_at FROM licenses ORDER BY updated_at DESC LIMIT 1`) var s State if err := row.Scan(&s.LicenseKey, &s.Status, &s.ValidUntil, &s.LastVerifiedAt, &s.LastVerifiedNode, &s.ActiveDomainsAtVerify, &s.Payload, &s.LastError, &s.CreatedAt, &s.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &s, nil } // Upsert persists a fresh verify-result. Empty error string clears // any previous error. func (r *Repo) Upsert(ctx context.Context, key, status string, validUntil *time.Time, nodeID string, activeDomains int, payload []byte, lastErr string) error { var nodeArg any = nodeID if nodeID == "" { nodeArg = nil } var errArg any = lastErr if lastErr == "" { errArg = nil } now := time.Now().UTC() _, err := r.Pool.Exec(ctx, ` INSERT INTO licenses (license_key, status, valid_until, last_verified_at, last_verified_node, active_domains_at_verify, payload, last_error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (license_key) DO UPDATE SET status = EXCLUDED.status, valid_until = EXCLUDED.valid_until, last_verified_at = EXCLUDED.last_verified_at, last_verified_node = EXCLUDED.last_verified_node, active_domains_at_verify = EXCLUDED.active_domains_at_verify, payload = EXCLUDED.payload, last_error = EXCLUDED.last_error, updated_at = NOW()`, key, status, validUntil, now, nodeArg, activeDomains, payload, errArg) return err } // MarkError records a verify failure WITHOUT touching status — the // previous valid status stays so a transient server failure doesn't // flip the box into "expired" mid-session. func (r *Repo) MarkError(ctx context.Context, key, errMsg string) error { _, err := r.Pool.Exec(ctx, `UPDATE licenses SET last_error = $1, updated_at = NOW() WHERE license_key = $2`, errMsg, key) return err } // Delete removes the row when the operator clears the key. func (r *Repo) Delete(ctx context.Context, key string) error { _, err := r.Pool.Exec(ctx, `DELETE FROM licenses WHERE license_key = $1`, key) return err }