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:
rcourtman 2026-06-11 08:34:26 +01:00
parent e8455e84b7
commit 6929c1c8fb
5 changed files with 202 additions and 23 deletions

View file

@ -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

View file

@ -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)
}
})
}
}

View file

@ -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() {

View file

@ -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{

View file

@ -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
}