mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
fix(licensing): clear activation only on explicit revocation codes, not any 401
Go half of the licensing-policy client work from the v6 upgrade sweep
(license findings 5 and 6); the copy and docs landed in 3a51429a5 and
the license-server side lands in pulse-pro.
The grant refresh loop treated every HTTP 401 as revocation and wiped
the licence and activation file immediately, but the server returns 401
for token-not-found and token-expired as well as revocation, so a
single spurious 401 dropped a paying customer to Community until a
successful re-exchange. Activation state is now cleared only when the
401 carries an explicit revocation code (TOKEN_REVOKED,
INSTALLATION_REVOKED, LICENSE_REVOKED); any other 401 keeps the licence
and retries, letting the grant's 72h expiry plus the 7-day grace window
govern degradation. The bump_license_version immediate-refresh path in
the revocation poller had the same any-401 wipe and gets the same
guard.
ClassifyLegacyExchangeError now maps a 409 MAX_INSTALLATIONS exchange
error to failed/exchange_installation_limit with the
free_installation_slot action instead of the retryable
pending/exchange_conflict state, since retrying can never succeed until
a slot is freed server-side. Other 409s keep the pending conflict
semantics.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e8455e84b7
commit
6929c1c8fb
5 changed files with 202 additions and 23 deletions
|
|
@ -16,18 +16,20 @@ const (
|
|||
CommercialMigrationStatePending CommercialMigrationState = "pending"
|
||||
CommercialMigrationStateFailed CommercialMigrationState = "failed"
|
||||
|
||||
CommercialMigrationReasonExchangeUnavailable CommercialMigrationReason = "exchange_unavailable"
|
||||
CommercialMigrationReasonExchangeRateLimited CommercialMigrationReason = "exchange_rate_limited"
|
||||
CommercialMigrationReasonExchangeConflict CommercialMigrationReason = "exchange_conflict"
|
||||
CommercialMigrationReasonExchangeInvalid CommercialMigrationReason = "exchange_invalid"
|
||||
CommercialMigrationReasonExchangeMalformed CommercialMigrationReason = "exchange_malformed"
|
||||
CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked"
|
||||
CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable"
|
||||
CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported"
|
||||
CommercialMigrationReasonExchangeUnavailable CommercialMigrationReason = "exchange_unavailable"
|
||||
CommercialMigrationReasonExchangeRateLimited CommercialMigrationReason = "exchange_rate_limited"
|
||||
CommercialMigrationReasonExchangeConflict CommercialMigrationReason = "exchange_conflict"
|
||||
CommercialMigrationReasonExchangeInstallationLimit CommercialMigrationReason = "exchange_installation_limit"
|
||||
CommercialMigrationReasonExchangeInvalid CommercialMigrationReason = "exchange_invalid"
|
||||
CommercialMigrationReasonExchangeMalformed CommercialMigrationReason = "exchange_malformed"
|
||||
CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked"
|
||||
CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable"
|
||||
CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported"
|
||||
|
||||
CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation"
|
||||
CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key"
|
||||
CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key"
|
||||
CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation"
|
||||
CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key"
|
||||
CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key"
|
||||
CommercialMigrationActionFreeInstallationSlot CommercialMigrationAction = "free_installation_slot"
|
||||
)
|
||||
|
||||
// CommercialMigrationStatus is the explicit v6-owned contract for unresolved
|
||||
|
|
@ -113,8 +115,17 @@ func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus {
|
|||
status.Reason = CommercialMigrationReasonExchangeRevoked
|
||||
status.RecommendedAction = CommercialMigrationActionUseV6Activation
|
||||
case 409:
|
||||
status.State = CommercialMigrationStatePending
|
||||
status.Reason = CommercialMigrationReasonExchangeConflict
|
||||
if strings.EqualFold(strings.TrimSpace(serverErr.Code), "MAX_INSTALLATIONS") {
|
||||
// The key has used up its installation slots server-side.
|
||||
// Retrying from this instance can never succeed until a slot
|
||||
// is freed, so this is terminal, not pending.
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeInstallationLimit
|
||||
status.RecommendedAction = CommercialMigrationActionFreeInstallationSlot
|
||||
} else {
|
||||
status.State = CommercialMigrationStatePending
|
||||
status.Reason = CommercialMigrationReasonExchangeConflict
|
||||
}
|
||||
case 410:
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeNonMigratable
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
|
|||
err error
|
||||
wantState CommercialMigrationState
|
||||
wantReason CommercialMigrationReason
|
||||
wantAction CommercialMigrationAction
|
||||
}{
|
||||
{
|
||||
name: "retryable server error stays pending",
|
||||
|
|
@ -36,6 +37,19 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
|
|||
wantState: CommercialMigrationStateFailed,
|
||||
wantReason: CommercialMigrationReasonExchangeUnsupportedKey,
|
||||
},
|
||||
{
|
||||
name: "installation limit conflict is terminal with slot guidance",
|
||||
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 409, Code: "MAX_INSTALLATIONS"}),
|
||||
wantState: CommercialMigrationStateFailed,
|
||||
wantReason: CommercialMigrationReasonExchangeInstallationLimit,
|
||||
wantAction: CommercialMigrationActionFreeInstallationSlot,
|
||||
},
|
||||
{
|
||||
name: "other conflict stays pending",
|
||||
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 409, Code: "EXCHANGE_IN_PROGRESS"}),
|
||||
wantState: CommercialMigrationStatePending,
|
||||
wantReason: CommercialMigrationReasonExchangeConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -50,6 +64,9 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
|
|||
if got.Reason != tt.wantReason {
|
||||
t.Fatalf("reason=%q, want %q", got.Reason, tt.wantReason)
|
||||
}
|
||||
if tt.wantAction != "" && got.RecommendedAction != tt.wantAction {
|
||||
t.Fatalf("action=%q, want %q", got.RecommendedAction, tt.wantAction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -156,15 +157,26 @@ func (s *Service) runGrantRefreshLoop(ctx context.Context) {
|
|||
if err := s.refreshGrantOnce(ctx); err != nil {
|
||||
consecutiveFailures++
|
||||
|
||||
var apiErr *LicenseServerError
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == 401 {
|
||||
// 401 means the installation token is revoked or expired.
|
||||
// Clear activation state and revert to free tier.
|
||||
log.Warn().Msg("Grant refresh received 401 (revoked/expired), clearing activation")
|
||||
if isRevokedActivationError(err) {
|
||||
// The server explicitly says this installation or licence is
|
||||
// revoked. Clear activation state and revert to free tier.
|
||||
log.Warn().Msg("Grant refresh rejected as revoked, clearing activation")
|
||||
s.clearActivationState()
|
||||
return
|
||||
}
|
||||
|
||||
var apiErr *LicenseServerError
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == 401 {
|
||||
// A 401 without an explicit revocation code also covers
|
||||
// token-not-found, token-expired, and transient server-side
|
||||
// auth issues. Keep the licence: the grant's own expiry plus
|
||||
// the grace window governs degradation, and recovery happens
|
||||
// via a later successful refresh or startup re-exchange.
|
||||
log.Warn().
|
||||
Str("code", apiErr.Code).
|
||||
Msg("Grant refresh got 401 without revocation code, keeping licence and retrying")
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Int("consecutive_failures", consecutiveFailures).
|
||||
|
|
@ -273,6 +285,23 @@ func (s *Service) refreshGrantOnce(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// isRevokedActivationError reports whether err is a license-server 401 that
|
||||
// explicitly indicates the installation or licence has been revoked. Only
|
||||
// these codes justify wiping local activation state; any other 401 (token
|
||||
// not found, token expired, malformed body) keeps the licence and relies on
|
||||
// grant-expiry grace instead.
|
||||
func isRevokedActivationError(err error) bool {
|
||||
var apiErr *LicenseServerError
|
||||
if !errors.As(err, &apiErr) || apiErr.StatusCode != 401 {
|
||||
return false
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(apiErr.Code)) {
|
||||
case "TOKEN_REVOKED", "INSTALLATION_REVOKED", "LICENSE_REVOKED":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clearActivationState clears the activation state and reverts to free tier.
|
||||
// Called when the license server indicates the installation is revoked.
|
||||
func (s *Service) clearActivationState() {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package licensing
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
|
@ -117,11 +118,134 @@ func TestGrantRefreshLoop_RefreshesGrant(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsRevokedActivationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"revoked token", &LicenseServerError{StatusCode: 401, Code: "TOKEN_REVOKED"}, true},
|
||||
{"revoked token lowercase", &LicenseServerError{StatusCode: 401, Code: "token_revoked"}, true},
|
||||
{"revoked installation", &LicenseServerError{StatusCode: 401, Code: "INSTALLATION_REVOKED"}, true},
|
||||
{"revoked license", &LicenseServerError{StatusCode: 401, Code: "LICENSE_REVOKED"}, true},
|
||||
{"wrapped revoked token", fmt.Errorf("refresh grant: %w", &LicenseServerError{StatusCode: 401, Code: "TOKEN_REVOKED"}), true},
|
||||
{"expired token", &LicenseServerError{StatusCode: 401, Code: "TOKEN_EXPIRED"}, false},
|
||||
{"token not found", &LicenseServerError{StatusCode: 401, Code: "INVALID_TOKEN"}, false},
|
||||
{"401 without structured code", &LicenseServerError{StatusCode: 401, Code: "http_401"}, false},
|
||||
{"revocation code on non-401", &LicenseServerError{StatusCode: 403, Code: "TOKEN_REVOKED"}, false},
|
||||
{"plain error", fmt.Errorf("connection refused"), false},
|
||||
{"nil error", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRevokedActivationError(tt.err); got != tt.want {
|
||||
t.Fatalf("isRevokedActivationError() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantRefreshLoop_NonRevocation401KeepsActivation(t *testing.T) {
|
||||
setupTestPublicKey(t)
|
||||
|
||||
// A 401 without an explicit revocation code (token not found, expired,
|
||||
// transient server auth issues) must NOT wipe the licence — the grant's
|
||||
// own expiry plus grace governs degradation instead.
|
||||
var refreshCount atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
refreshCount.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "Installation token not found",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
||||
LicenseID: "lic_spurious_401",
|
||||
Tier: "pro",
|
||||
State: "active",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
||||
})
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "pulse-refresh-spurious-401-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
p, err := NewPersistence(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create persistence: %v", err)
|
||||
}
|
||||
|
||||
svc := NewService()
|
||||
svc.SetLicenseServerClient(NewLicenseServerClient(server.URL))
|
||||
svc.SetPersistence(p)
|
||||
|
||||
state := &ActivationState{
|
||||
InstallationID: "inst_spurious",
|
||||
InstallationToken: "pit_live_spurious",
|
||||
LicenseID: "lic_spurious_401",
|
||||
GrantJWT: initialGrantJWT,
|
||||
GrantJTI: "grant_old",
|
||||
InstanceFingerprint: "fp-spurious",
|
||||
}
|
||||
if err := p.SaveActivationState(state); err != nil {
|
||||
t.Fatalf("save state: %v", err)
|
||||
}
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.activationState = state
|
||||
svc.mu.Unlock()
|
||||
if err := svc.RestoreActivation(state); err != nil {
|
||||
t.Fatalf("RestoreActivation: %v", err)
|
||||
}
|
||||
|
||||
svc.StartGrantRefresh(context.Background())
|
||||
svc.mu.RLock()
|
||||
loop := svc.grantRefresh
|
||||
svc.mu.RUnlock()
|
||||
loop.mu.Lock()
|
||||
loop.refreshInterval = time.Millisecond
|
||||
loop.jitterPercent = 0
|
||||
loop.mu.Unlock()
|
||||
|
||||
// Wait for at least one failed refresh attempt.
|
||||
deadline := time.After(5 * time.Second)
|
||||
for refreshCount.Load() == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
svc.StopGrantRefresh()
|
||||
t.Fatal("timed out waiting for refresh attempt")
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
// Give the loop a moment to (incorrectly) clear state if it were going to.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
svc.StopGrantRefresh()
|
||||
|
||||
if svc.Current() == nil {
|
||||
t.Fatal("expected licence to survive a non-revocation 401")
|
||||
}
|
||||
if !svc.IsActivated() {
|
||||
t.Fatal("expected activation to survive a non-revocation 401")
|
||||
}
|
||||
if !p.ActivationStateExists() {
|
||||
t.Fatal("expected persisted activation state to survive a non-revocation 401")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantRefreshLoop_401ClearsActivation(t *testing.T) {
|
||||
setupTestPublicKey(t)
|
||||
|
||||
// This test exercises the full runGrantRefreshLoop 401 branch, which calls
|
||||
// clearActivationState to revert to free tier and exit the loop.
|
||||
// This test exercises the runGrantRefreshLoop revocation branch: a 401
|
||||
// carrying an explicit revocation code calls clearActivationState to
|
||||
// revert to free tier and exit the loop.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package licensing
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
|
|
@ -225,8 +224,7 @@ func (s *Service) handleRevocationEvent(event RevocationEvent) {
|
|||
Msg("License version bumped — triggering immediate grant refresh")
|
||||
go func() {
|
||||
if err := s.refreshGrantOnce(context.Background()); err != nil {
|
||||
var apiErr *LicenseServerError
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == 401 {
|
||||
if isRevokedActivationError(err) {
|
||||
s.clearActivationState()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue