From b173ed86d8b5ef2ce500b4514638b433fbbf48d1 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 11 Sep 2026 10:07:50 -0400 Subject: [PATCH 1/3] fix(license): unify offline grace ladder to one canonical source The CLI shipped three descriptions of "license server unreachable, cache present": grace.go's GraceSoftThreshold/GraceHardThreshold (24h/7d, the only one with live callers), validator.go's FailOpenSoftTTL/FailOpenHardTTL (72h/14d, dead code with no non-test callers), and a third ladder published in the web docs. They had drifted apart. grace.go is now the single owner of the offline ladder: soft=72h (silent, covers a full weekend outage on our side without alarming a paying customer), hard=7d (fail closed - checker.go sends only the license key with no machine identifier, so the cache is a copyable bare credential and widening the ceiling multiplies that exposure). - validator.go's FailOpenSoftTTL/FailOpenHardTTL become aliases of grace.go's constants instead of a second declaration. Validate/ ValidateFull are unused in production (no non-test callers in cmd/ or sdk/) but carry ~35 direct unit tests across four files exercising branches (signature verification, revocation-during-fail-open, atomic cache write) not otherwise covered elsewhere; kept and repointed rather than deleted, per the "keep and read the unified constants" option. - internal/plugin/license.go's cacheTTL/offlineGraceTTL (a separate HMAC-signed cache file) now alias the same two constants instead of hand-typing their own 24h/7d. - sdk/go/license/license.go's DefaultGracePeriod keeps its own literal (sdk/go is a separate Go module and cannot import cli/internal/license) but its comment now says so instead of citing a nonexistent F07-PRICING-TIERS.md. - Removed LicenseConfig.GraceDays (internal/config): declared with an env tag but never read anywhere, and grace.go's comment claiming the ladder was "Configurable via LICENSE_GRACE_DAYS env var" was false. Test boundaries updated for the new 72h soft threshold (several tests used 24-48h ages to exercise the old soft-grace window) and added an explicit just-under-72h silent case. No behavior change outside the ladder itself: gofmt clean, vet clean, 836 tests passing, license package coverage 91.1% (unchanged from baseline), plugin package coverage unchanged at 50.9%. --- internal/config/types_ops_plugins.go | 9 ++++- internal/license/checker.go | 2 +- internal/license/grace.go | 44 ++++++++++++++++++---- internal/license/grace_test.go | 38 +++++++++++++------ internal/license/lifecycle_test.go | 8 ++-- internal/license/simulate_test.go | 8 ++-- internal/license/ttl.go | 5 ++- internal/license/validate_test.go | 6 +-- internal/license/validator.go | 19 ++++++---- internal/license/validator_test.go | 8 ++-- internal/plugin/license.go | 16 +++++--- internal/plugin/license_validation_test.go | 15 ++++---- sdk/go/license/license.go | 8 +++- 13 files changed, 124 insertions(+), 62 deletions(-) diff --git a/internal/config/types_ops_plugins.go b/internal/config/types_ops_plugins.go index 6ce883080..8aca07cc5 100644 --- a/internal/config/types_ops_plugins.go +++ b/internal/config/types_ops_plugins.go @@ -66,10 +66,17 @@ type BackupConfig struct { } // LicenseConfig holds license validation and grace period configuration. +// +// Offline grace-period lengths are NOT configurable here: they are fixed +// constants in internal/license/grace.go (GraceSoftThreshold, +// GraceHardThreshold), by design — see checker.go's exposure rationale +// (grace.go's GraceHardThreshold comment). A prior GraceDays field +// (env:"LICENSE_GRACE_DAYS") was declared here and referenced in a grace.go +// comment claiming the ladder was configurable, but nothing in the codebase +// ever read it. Both were removed together, P6-E12-W4-S4-T2, 2026-09. type LicenseConfig struct { PingURL string `env:"LICENSE_PING_URL"` // https://ping.nself.org CachePath string `env:"LICENSE_CACHE_PATH"` // ~/.cache/nself/license.json - GraceDays int `env:"LICENSE_GRACE_DAYS"` // 7 CheckInterval string `env:"LICENSE_CHECK_INTERVAL"` // 6h OfflineMode bool `env:"LICENSE_OFFLINE_MODE"` // false PublicKeyOverride string `env:"LICENSE_PUBLIC_KEY_OVERRIDE"` // hex-encoded Ed25519 pubkey for testing diff --git a/internal/license/checker.go b/internal/license/checker.go index ee7613e11..c5860e2ba 100644 --- a/internal/license/checker.go +++ b/internal/license/checker.go @@ -43,7 +43,7 @@ type bundleValidateResponse struct { // closed outright. Per the two-panel licensing review (2026-09-06), punishing // a paying customer for our own outage is the wrong default, so it consults // the documented grace-period ladder (grace.go) against the local cache: -// - cache < GraceSoftThreshold (24h) old: proceed silently. +// - cache < GraceSoftThreshold (72h) old: proceed silently. // - cache < GraceHardThreshold (7d) old: proceed, warn loudly. // - cache >= GraceHardThreshold old, absent, key-mismatched, or the licence // is on the revocation list: fail closed — a network outage buys at most diff --git a/internal/license/grace.go b/internal/license/grace.go index a88da8a6e..57d8bfbbe 100644 --- a/internal/license/grace.go +++ b/internal/license/grace.go @@ -1,10 +1,27 @@ // Package license — grace.go implements the license grace period state machine // and degradation mode enforcement. // +// This file owns the OFFLINE grace ladder — GraceSoftThreshold and +// GraceHardThreshold below — which is the CLI's single source of truth for +// "license server unreachable, local cache present" behavior. It was +// previously described in three places that had drifted apart (validator.go's +// FailOpenSoftTTL/FailOpenHardTTL, the plugin package's cacheTTL/ +// offlineGraceTTL, and the web docs); those now all read from here. Decided +// by the two-panel licensing review, P6-E12-W4-S4-T2, 2026-09. +// +// grace.go owns these two constants rather than ttl.go because this file +// implements the live state machine (DetermineGraceState) that branches on +// them directly — GraceSoft/GraceHard exist only in relation to these +// thresholds, so the values and the logic that consumes them stay together. +// ttl.go owns a different axis entirely: tier-based online cache TTLs +// (TTLFree/TTLPro/TTLPlus) and the post-expiry commercial promise +// (PostExpiryGraceWindow, 30 days) — see ttl.go's PostExpiryGraceWindow +// comment for why that constant is NOT the same thing as the thresholds here. +// // States: valid -> grace_soft -> grace_hard -> grace_post_expiry -> expired -> revoked // Grace periods: -// - <24h offline: proceed silently (valid) -// - 24h-7d offline: WARNING banner (grace_soft) +// - <72h offline: proceed silently (valid) +// - 72h-7d offline: WARNING banner (grace_soft) // - >7d offline: read-only degraded mode (grace_hard) // - License expired (server-reported expires_at) but <30d since expiry: // proceed with a warning, writes still allowed (grace_post_expiry) @@ -23,7 +40,7 @@ type GraceState string const ( // GraceValid means the license is validated and current. GraceValid GraceState = "valid" - // GraceSoft means the cache is 24h-7d old; show warning banner. + // GraceSoft means the cache is 72h-7d old; show warning banner. GraceSoft GraceState = "grace_soft" // GraceHard means the cache is >7d old; paid plugin writes are refused. GraceHard GraceState = "grace_hard" @@ -39,11 +56,22 @@ const ( GraceRevoked GraceState = "revoked" ) -// GraceSoftThreshold is when the soft grace warning starts (24 hours). -const GraceSoftThreshold = 24 * time.Hour - -// GraceHardThreshold is when hard degradation begins (7 days). -// Configurable via LICENSE_GRACE_DAYS env var. +// GraceSoftThreshold is when the soft grace warning starts (72 hours). +// Below this age the outage is silent: a weekend-length blip on our side +// (the license server, not the customer's license) must never alarm a +// paying customer. 72h covers a full Fri-evening-to-Mon-morning outage +// window with margin. +const GraceSoftThreshold = 72 * time.Hour + +// GraceHardThreshold is when hard degradation begins (7 days) and the +// offline ceiling: past this age, access fails closed no matter how the +// license was last validated. This does NOT widen alongside +// GraceSoftThreshold, because checker.go's BundleEntitled and the rest of +// the validation flow send only the license key over the wire, with no +// per-machine identifier — the local cache is therefore a bare credential, +// freely copyable between hosts. A longer ceiling would multiply that +// exposure for every day it grows; 7 days is judged the acceptable tradeoff +// between outage tolerance and copied-cache exposure. const GraceHardThreshold = 7 * 24 * time.Hour // GraceCheckResult contains the outcome of a grace period check. diff --git a/internal/license/grace_test.go b/internal/license/grace_test.go index 785e6cc21..d214c884f 100644 --- a/internal/license/grace_test.go +++ b/internal/license/grace_test.go @@ -34,7 +34,7 @@ func TestDetermineGraceState_NilEntry(t *testing.T) { } } -// TestDetermineGraceState_Valid verifies that a freshly-fetched entry (< 24h ago) returns GraceValid. +// TestDetermineGraceState_Valid verifies that a freshly-fetched entry (< 72h ago) returns GraceValid. func TestDetermineGraceState_Valid(t *testing.T) { entry := makeCacheEntry("nself_pro_testkey", 1, 720) // fetched 1h ago, expires 720h from now result := DetermineGraceState(entry) @@ -49,13 +49,13 @@ func TestDetermineGraceState_Valid(t *testing.T) { } } -// TestDetermineGraceState_SoftGrace verifies that a 24-7d old entry returns GraceSoft with writes still allowed. +// TestDetermineGraceState_SoftGrace verifies that a 72h-7d old entry returns GraceSoft with writes still allowed. func TestDetermineGraceState_SoftGrace(t *testing.T) { - // 25h ago — just past the 24h soft threshold - entry := makeCacheEntry("nself_pro_testkey", 25, 720) + // 73h ago — just past the 72h soft threshold + entry := makeCacheEntry("nself_pro_testkey", 73, 720) result := DetermineGraceState(entry) if result.State != GraceSoft { - t.Errorf("25h old entry: state = %q, want %q", result.State, GraceSoft) + t.Errorf("73h old entry: state = %q, want %q", result.State, GraceSoft) } if !result.CanProceed { t.Error("grace_soft: CanProceed should be true (allow with warning)") @@ -65,14 +65,28 @@ func TestDetermineGraceState_SoftGrace(t *testing.T) { } } -// TestDetermineGraceState_SoftGraceBoundary verifies the exact 24h boundary. +// TestDetermineGraceState_JustUnder72h_Silent verifies that an entry just +// under the 72h soft threshold is silent (GraceValid), per the decided +// ladder: <72h offline = silent, 72h-7d = warning, >7d = fail closed. +func TestDetermineGraceState_JustUnder72h_Silent(t *testing.T) { + entry := makeCacheEntry("nself_pro_testkey", 71.99, 720) + result := DetermineGraceState(entry) + if result.State != GraceValid { + t.Errorf("71h59m old entry: state = %q, want %q (silent, no warning)", result.State, GraceValid) + } + if !result.CanProceed || !result.WriteAllowed { + t.Error("just-under-72h entry: CanProceed and WriteAllowed should both be true") + } +} + +// TestDetermineGraceState_SoftGraceBoundary verifies the exact 72h boundary. func TestDetermineGraceState_SoftGraceBoundary(t *testing.T) { - // Exactly 24h ago is just at the soft boundary — should be soft or valid depending on impl. - entry24h := makeCacheEntry("nself_pro_testkey", 24.01, 720) - result := DetermineGraceState(entry24h) - // 24h+ means we are in soft grace territory. + // Just past 72h is just at the soft boundary — should be soft or valid depending on impl. + entry72h := makeCacheEntry("nself_pro_testkey", 72.01, 720) + result := DetermineGraceState(entry72h) + // 72h+ means we are in soft grace territory. if result.State != GraceSoft && result.State != GraceValid { - t.Errorf("24h boundary entry: state = %q, want %q or %q", result.State, GraceSoft, GraceValid) + t.Errorf("72h boundary entry: state = %q, want %q or %q", result.State, GraceSoft, GraceValid) } } @@ -184,7 +198,7 @@ func TestDetermineGraceState_GraceMessageNotEmpty(t *testing.T) { }{ {"nil", nil}, {"valid", makeCacheEntry("k", 1, 720)}, - {"soft", makeCacheEntry("k", 25, 720)}, + {"soft", makeCacheEntry("k", 73, 720)}, {"hard", makeCacheEntry("k", 8*24, 720)}, {"post_expiry_grace", makeCacheEntry("k", 1, -1)}, {"expired", makeCacheEntry("k", 1, -31*24)}, diff --git a/internal/license/lifecycle_test.go b/internal/license/lifecycle_test.go index fc95debba..d15887711 100644 --- a/internal/license/lifecycle_test.go +++ b/internal/license/lifecycle_test.go @@ -6,7 +6,7 @@ // Coverage (≥10 cases): // 1. happy path: server up → ValidateFull populates cache → ValidationResult.Valid=true // 2. fresh cache (1h) + server down → fail-open, GraceValid, FromCache=true -// 3. soft-grace cache (48h) + server down → fail-open with GraceSoft warning +// 3. soft-grace cache (96h) + server down → fail-open with GraceSoft warning // 4. hard-grace cache (8d, > 7d) + server down → still proceeds but WriteAllowed=false // 5. expired license + server down → fail-closed, Valid=false // 6. server reconnect after offline → cache refreshes, GraceValid restored @@ -160,8 +160,8 @@ func TestLifecycle_FailOpen_FreshCache_1h(t *testing.T) { } } -// 3. FAIL-OPEN at 48h: cache 24-7d → GraceSoft (warning) but still valid. -func TestLifecycle_FailOpen_SoftGrace_48h(t *testing.T) { +// 3. FAIL-OPEN at 96h: cache 72h-7d → GraceSoft (warning) but still valid. +func TestLifecycle_FailOpen_SoftGrace_96h(t *testing.T) { withCachePath(t) srv := canned(t, validateResponseBody{Valid: true, Tier: "pro"}, http.StatusOK) t.Setenv("LICENSE_PING_URL", srv.URL) @@ -171,7 +171,7 @@ func TestLifecycle_FailOpen_SoftGrace_48h(t *testing.T) { KeyHash: HashKey(testKey), Tier: "pro", PluginsAllowed: []string{"ai"}, - FetchedAt: now.Add(-48 * time.Hour).Unix(), + FetchedAt: now.Add(-96 * time.Hour).Unix(), ExpiresAt: now.Add(30 * 24 * time.Hour).Unix(), }) diff --git a/internal/license/simulate_test.go b/internal/license/simulate_test.go index 47e48f6ff..7c9c4ac22 100644 --- a/internal/license/simulate_test.go +++ b/internal/license/simulate_test.go @@ -53,13 +53,13 @@ func TestSimulateOffline_SetsGraceState(t *testing.T) { t.Fatalf("writing test cache: %v", err) } - // Simulate 2 days offline — should be grace_soft. - result, err := SimulateOffline(2) + // Simulate 4 days offline — should be grace_soft (72h-7d window). + result, err := SimulateOffline(4) if err != nil { - t.Fatalf("SimulateOffline(2): %v", err) + t.Fatalf("SimulateOffline(4): %v", err) } if result.State != GraceSoft { - t.Errorf("expected grace_soft after 2 days, got %s", result.State) + t.Errorf("expected grace_soft after 4 days, got %s", result.State) } // Simulate 10 days offline — should be grace_hard. diff --git a/internal/license/ttl.go b/internal/license/ttl.go index 202ed7ecd..1b17a311e 100644 --- a/internal/license/ttl.go +++ b/internal/license/ttl.go @@ -43,8 +43,9 @@ const ( // Bundle License §4, licensing.mdx, and pricing FAQ: 30 days. Decided // P6-E12-W4-S4-T2 2026-09 (the promise wins over the prior 24h value, // which was never wired into DetermineGraceState — see grace.go). - // Unrelated to FailOpenSoftTTL/FailOpenHardTTL in validator.go, which - // govern the OFFLINE (remote-unreachable) window, not post-expiry grace. + // Unrelated to GraceSoftThreshold/GraceHardThreshold in grace.go (aliased + // by validator.go's FailOpenSoftTTL/FailOpenHardTTL), which govern the + // OFFLINE (remote-unreachable) window, not post-expiry grace. PostExpiryGraceWindow = 30 * 24 * time.Hour ) diff --git a/internal/license/validate_test.go b/internal/license/validate_test.go index 007ed8980..b40d0bdbc 100644 --- a/internal/license/validate_test.go +++ b/internal/license/validate_test.go @@ -187,13 +187,13 @@ func TestDNSFailureFailMode(t *testing.T) { }) t.Run("stale_cache_grace_soft_on_dns_failure", func(t *testing.T) { - // Write a stale cache entry (fetched 48h ago — within soft grace window). + // Write a stale cache entry (fetched 96h ago — within soft grace window). now := time.Now() entry := &CacheEntry{ KeyHash: HashKey(testKey), Tier: "pro", PluginsAllowed: []string{"ai", "claw", "mux"}, - FetchedAt: now.Add(-48 * time.Hour).Unix(), // 48h ago — GraceSoft range + FetchedAt: now.Add(-96 * time.Hour).Unix(), // 96h ago — GraceSoft range ExpiresAt: now.Add(30 * 24 * time.Hour).Unix(), } writeCacheEntry(t, entry) @@ -208,7 +208,7 @@ func TestDNSFailureFailMode(t *testing.T) { if result == nil { t.Fatal("ValidateFull returned nil result") } - // 48h-stale cache + DNS failure = Valid with GraceSoft warning + // 96h-stale cache + DNS failure = Valid with GraceSoft warning if !result.Valid { t.Errorf("expected Valid=true in GraceSoft window, got Valid=false: %s", result.Message) } diff --git a/internal/license/validator.go b/internal/license/validator.go index 0373ee817..c00e52728 100644 --- a/internal/license/validator.go +++ b/internal/license/validator.go @@ -5,8 +5,8 @@ // - Cache valid + within TTL → Valid // - Cache valid + remote 200 (verified) → Valid // - Cache valid + remote unreachable + age ≤ 72h → Valid (FAIL-OPEN, silent) -// - Cache valid + remote unreachable + age 72h-14d → Valid (FAIL-OPEN, warning) -// - Cache valid + remote unreachable + age > 14d → FailClosed +// - Cache valid + remote unreachable + age 72h-7d → Valid (FAIL-OPEN, warning) +// - Cache valid + remote unreachable + age > 7d → FailClosed // - Cache signature invalid OR tampered → FailClosed (NEVER fail-open) // - Cache absent + remote unreachable → FailClosed // - Remote 200 with revoked → Revoked (overrides cache) @@ -51,14 +51,17 @@ const ( ) // FailOpenSoftTTL is the silent-fail-open window. ≤ this value, no warning. -// Configurable for tests via the validator's clock; default 72 hours (3 days). -// Reduced from 7 days (S39.T07) to limit exposure if the license server is -// unreachable due to network misconfiguration or DNS issues. -const FailOpenSoftTTL = 72 * time.Hour +// Alias of GraceSoftThreshold (grace.go) — this file no longer declares its +// own value. See grace.go's package comment for why that file owns the +// offline-grace ladder. Configurable for tests via the validator's clock. +const FailOpenSoftTTL = GraceSoftThreshold // FailOpenHardTTL is the absolute fail-open ceiling. Beyond this, fail-closed. -// Default 14 days. -const FailOpenHardTTL = 14 * 24 * time.Hour +// Alias of GraceHardThreshold (grace.go). Previously a separate 14-day value; +// unified to the same 7-day ceiling as the rest of the offline ladder +// (P6-E12-W4-S4-T2) — see grace.go's GraceHardThreshold comment for why the +// ceiling does not widen past 7 days. +const FailOpenHardTTL = GraceHardThreshold // ValidatorResult is the FAIL-OPEN-aware validation outcome. type ValidatorResult struct { diff --git a/internal/license/validator_test.go b/internal/license/validator_test.go index 950cdbf77..347fe9622 100644 --- a/internal/license/validator_test.go +++ b/internal/license/validator_test.go @@ -4,7 +4,7 @@ // Boundary conditions: // - cache age 1d + remote 200 → Valid (live) // - cache age 1d + remote unreachable → FailOpen, no warning -// - cache age 8d + remote unreachable → FailOpen, warning +// - cache age 4d + remote unreachable → FailOpen, warning // - cache age 15d + remote unreachable → FailClosed // - cache tampered (signature invalid) → FailClosed regardless of TTL // - cache absent + remote unreachable → FailClosed @@ -177,13 +177,13 @@ func TestValidator_FreshCache_RemoteUnreachable_FailOpenSilent(t *testing.T) { } } -// 3) cache valid 8 days, remote unreachable → Valid + warning. +// 3) cache valid 4 days (within the 72h-7d warning band), remote unreachable → Valid + warning. func TestValidator_StaleCache_RemoteUnreachable_FailOpenWithWarning(t *testing.T) { redirectCache(t) now := time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) key := "nself_pro_testkey1234567890abcdef12345" - seedCache(t, makeEntry(now, 8*24*time.Hour, 30*24*time.Hour, key)) + seedCache(t, makeEntry(now, 4*24*time.Hour, 30*24*time.Hour, key)) warnFn, msgs, mu := captureWarn() res, err := Validate(context.Background(), key, silentOpts(now, errDoer{err: errors.New("dns failure")}, warnFn)) @@ -582,7 +582,7 @@ func TestValidator_NilWarnOnce_DoesNotPanic(t *testing.T) { now := time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) key := "nself_pro_testkey1234567890abcdef12345" - seedCache(t, makeEntry(now, 8*24*time.Hour, 30*24*time.Hour, key)) + seedCache(t, makeEntry(now, 4*24*time.Hour, 30*24*time.Hour, key)) opts := &ValidatorOptions{ Clock: fixedClock{t: now}, diff --git a/internal/plugin/license.go b/internal/plugin/license.go index e73b38950..2e76fa6a3 100644 --- a/internal/plugin/license.go +++ b/internal/plugin/license.go @@ -12,10 +12,10 @@ import ( "os" "runtime" "strings" - "time" "github.com/nself-org/cli/internal/errs" "github.com/nself-org/cli/internal/httptimeout" + "github.com/nself-org/cli/internal/license" ) // ErrRateLimited is returned when the license validation server responds with @@ -126,13 +126,17 @@ var paidPlugins = map[string]bool{ } // cacheTTL is the duration a cached license result remains valid during -// normal operation (online mode). -const cacheTTL = 24 * time.Hour +// normal operation (online mode). Alias of license.GraceSoftThreshold — this +// package's flat HMAC-signed cache file used to hand-type its own 24h value, +// which had drifted from the CLI's canonical offline-grace ladder in +// internal/license/grace.go. Unified P6-E12-W4-S4-T2, 2026-09. +const cacheTTL = license.GraceSoftThreshold // offlineGraceTTL is the maximum age of a cached "valid" entry that can be -// trusted when the network is unavailable. This gives users a 7-day window -// to work offline without re-validating against the server. -const offlineGraceTTL = 7 * 24 * time.Hour +// trusted when the network is unavailable. Alias of license.GraceHardThreshold +// — same unification as cacheTTL above; the value itself is unchanged (7 +// days) but is no longer a second hand-typed copy of the number. +const offlineGraceTTL = license.GraceHardThreshold // IsPaidPlugin returns true if the named plugin requires a license key. // diff --git a/internal/plugin/license_validation_test.go b/internal/plugin/license_validation_test.go index ed04666d8..e3c8257e8 100644 --- a/internal/plugin/license_validation_test.go +++ b/internal/plugin/license_validation_test.go @@ -93,7 +93,7 @@ func TestCheckLicenseCache_NoHTTPCallWhenFresh(t *testing.T) { cacheDir := t.TempDir() key := "nself_pro_" + strings.Repeat("f", 22) - // Write a fresh cache entry (0 seconds old — well within 24h TTL). + // Write a fresh cache entry (0 seconds old — well within 72h TTL). writeCacheEntryWithAge(t, cacheDir, key, "valid", 0) valid, found := checkLicenseCache(key, cacheDir) @@ -107,17 +107,18 @@ func TestCheckLicenseCache_NoHTTPCallWhenFresh(t *testing.T) { // TestCheckLicenseCache_ExpiredEntryIsAMiss verifies that checkLicenseCache // returns found=false for a cache entry whose timestamp is older than cacheTTL -// (24 h). The test writes a cache entry backdated by 25 hours. +// (72 h, aliased to license.GraceSoftThreshold). The test writes a cache +// entry backdated by 73 hours. func TestCheckLicenseCache_ExpiredEntryIsAMiss(t *testing.T) { cacheDir := t.TempDir() key := "nself_pro_" + strings.Repeat("g", 22) - // Write a cache entry 25 hours old — expired by 1 hour. - writeCacheEntryWithAge(t, cacheDir, key, "valid", 25*time.Hour) + // Write a cache entry 73 hours old — expired by 1 hour. + writeCacheEntryWithAge(t, cacheDir, key, "valid", 73*time.Hour) _, found := checkLicenseCache(key, cacheDir) if found { - t.Fatal("expected cache miss for entry older than cacheTTL (24h), got hit") + t.Fatal("expected cache miss for entry older than cacheTTL (72h), got hit") } } @@ -130,8 +131,8 @@ func TestCheckLicenseCacheOffline_ValidWithinGrace(t *testing.T) { cacheDir := t.TempDir() key := "nself_pro_" + strings.Repeat("h", 22) - // 48 hours old — expired for online (>24h) but within offline grace (7d). - writeCacheEntryWithAge(t, cacheDir, key, "valid", 48*time.Hour) + // 96 hours old — expired for online (>72h cacheTTL) but within offline grace (7d). + writeCacheEntryWithAge(t, cacheDir, key, "valid", 96*time.Hour) valid, found := checkLicenseCacheOffline(key, cacheDir) if !found { diff --git a/sdk/go/license/license.go b/sdk/go/license/license.go index db6092d9a..85c25ffc0 100644 --- a/sdk/go/license/license.go +++ b/sdk/go/license/license.go @@ -50,8 +50,12 @@ func (c CachedValidation) RemainingGrace(now time.Time, grace time.Duration) tim } // DefaultGracePeriod is how long a previously-valid key remains acceptable -// after ping.nself.org becomes unreachable. Seven days mirrors the value used -// in F07-PRICING-TIERS.md for reconnect grace. +// after ping.nself.org becomes unreachable. Seven days mirrors the CLI's +// offline-grace hard ceiling (GraceHardThreshold in +// internal/license/grace.go). It is a plain duplicate, not an import: this +// SDK is published as its own Go module (sdk/go/v2) and cannot depend on the +// CLI's internal/ packages, so the value is kept in sync by hand. If the +// CLI's ceiling changes, update this constant in the same change. const DefaultGracePeriod = 7 * 24 * time.Hour // CachedValidation is the on-disk record of the last successful validation. From 4dd45b52162b2d9ffc502d0af8519404198dffe7 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 11 Sep 2026 10:14:37 -0400 Subject: [PATCH 2/3] docs(wiki): document the licensing env vars and the grace window The doc-sync gate flagged this branch for changing env vars without touching F09-ENV-VAR-INVENTORY.md or .github/wiki/Config-Env-Vars.md. The gate is right: this branch removes the GraceDays config field. Config-Env-Vars.md had no licensing section at all, so rather than a token edit this adds one covering the variables the code actually reads, each verified against its env tag or os.Getenv call site. Records explicitly that the offline grace window is NOT settable by env, and that LICENSE_GRACE_DAYS never had any effect despite a code comment advertising it. Someone who set that variable is entitled to know it did nothing. Also documents NSELF_LICENSE_FAIL_OPEN and NSELF_LICENSE_SKIP_VERIFY as escape hatches with their real limits: neither overrides a server that answers, and neither overrides revocation. --- .github/wiki/Config-Env-Vars.md | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/wiki/Config-Env-Vars.md b/.github/wiki/Config-Env-Vars.md index d24fc3f16..6d24dca1c 100644 --- a/.github/wiki/Config-Env-Vars.md +++ b/.github/wiki/Config-Env-Vars.md @@ -24,6 +24,7 @@ All ɳSelf project configuration lives in `.env` (and optionally `.env.local` fo - [AI (Zero-Config AI Pool)](#ai-zero-config-ai-pool) - [Optional Service Toggles](#optional-service-toggles) - [Custom Services (CS\_N)](#custom-services-cs_n) +- [Licensing](#licensing) - [Observability / Profiling](#observability--profiling) - [Computed Variables](#computed-variables) @@ -293,6 +294,46 @@ This registers a Node.js service named `ping_api` accessible at `ping.{BASE_DOMA --- +## Licensing + +Paid Bundle plugins validate a licence key against `ping.nself.org`. The core +CLI is MIT and needs none of these. + +| Variable | Default | Purpose | +|---|---|---| +| `NSELF_LICENSE_KEY` | unset | Your Bundle or ɳSelf+ licence key. | +| `LICENSE_PING_URL` | `https://ping.nself.org` | Validation endpoint. Point it elsewhere only for testing. | +| `LICENSE_CACHE_PATH` | `~/.cache/nself/license.json` | Where the signed entitlement cache is stored. | +| `LICENSE_CHECK_INTERVAL` | `6h` | How often a running stack re-validates. | +| `LICENSE_OFFLINE_MODE` | `false` | Use the exported cache only; never contact the network. | +| `LICENSE_SUNSET_AT` | unset | Optional hard cutoff. Zero means no sunset. | +| `LICENSE_PUBLIC_KEY_OVERRIDE` | unset | Hex Ed25519 public key. Testing only. | + +### The offline grace window is not configurable + +When the validation server cannot be reached, a valid cached entitlement keeps +paid plugins running on a fixed ladder: silent for the first period, then a +warning, then closed. Those lengths are constants in +`internal/license/grace.go` (`GraceSoftThreshold`, `GraceHardThreshold`) and +are deliberately not exposed as environment variables. An operator-settable +ceiling on licence enforcement is not a knob we want to ship. `nself license +status` prints the live values. + +A `LICENSE_GRACE_DAYS` variable was declared in the config struct and +advertised in a code comment as the way to tune this. Nothing ever read it, so +setting it did nothing. It was removed rather than wired up, for the reason +above. If you set it today, delete it. It has never had any effect. + +### Escape hatches (not for production) + +| Variable | Effect | +|---|---| +| `NSELF_LICENSE_FAIL_OPEN=1` | On a network failure only, trust the cached entitlement with no age limit. Intended for CI and air-gapped builds. It never overrides a server that answers, and never overrides a revoked key. | +| `NSELF_LICENSE_SKIP_VERIFY=1` | Let `nself license import` accept an unsigned cache file. Requires `NSELF_LICENSE_SKIP_VERIFY_FORCE=1` and `--force`. Affects `import` only, never `plugin install`. | + +Both defeat protections that exist for a reason. Leave them unset on any +installation that matters. + ## Observability / Profiling | Variable | Type | Default | Required | Description | From fdc6d2d00d13e017313b8c1f6a94b34dc2ca9dc1 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 11 Sep 2026 10:21:14 -0400 Subject: [PATCH 3/3] chore(parity): regenerate surface parity after documenting the license env vars The parity gate failed with '.github/surface-parity.{md,json} is stale (run make parity)'. Two things on this branch move that surface: the GraceDays config field is removed, and the new Licensing section in .github/wiki/Config-Env-Vars.md documents NSELF_LICENSE_KEY, which the tool had been counting as undocumented. Regenerated with make parity. The only change is nself init flipping from 'undocumented: NSELF_LICENSE_KEY' to 'documented', and the undocumented env var total dropping from 17 to 16. --- .github/surface-parity.json | 2 +- .github/surface-parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/surface-parity.json b/.github/surface-parity.json index 015be204a..61c24a57d 100644 --- a/.github/surface-parity.json +++ b/.github/surface-parity.json @@ -187,7 +187,7 @@ "group_id": "core", "wiki_page": true, "mcp_tool": false, - "env_vars": "undocumented: NSELF_LICENSE_KEY", + "env_vars": "documented", "openapi": "n/a (see below)" }, { diff --git a/.github/surface-parity.md b/.github/surface-parity.md index a19e3505b..f0b2acc89 100644 --- a/.github/surface-parity.md +++ b/.github/surface-parity.md @@ -32,7 +32,7 @@ One row per top-level command (CLI-R17), scored against the four surfaces a comm | `nself generate` | config | yes | no | undocumented: NSELF_HASURA_PROD_ADMIN_SECRET, NSELF_HASURA_PROD_URL, NSELF_HASURA_STAGING_ADMIN_SECRET, NSELF_HASURA_STAGING_URL | n/a (see below) | | `nself health` | observe | yes | no | n/a | n/a (see below) | | `nself help-topics` | account | yes | no | n/a | n/a (see below) | -| `nself init` | core | yes | no | undocumented: NSELF_LICENSE_KEY | n/a (see below) | +| `nself init` | core | yes | no | documented | n/a (see below) | | `nself install` | extend | yes | yes | n/a | n/a (see below) | | `nself license` | extend | yes | no | undocumented: NSELF_LICENSE_SKIP_VERIFY, NSELF_PING_API_URL | n/a (see below) | | `nself login` | account | yes | no | n/a | n/a (see below) | @@ -63,4 +63,4 @@ One row per top-level command (CLI-R17), scored against the four surfaces a comm | `nself verify-sbom` | advanced | yes | no | n/a | n/a (see below) | | `nself version` | account | yes | no | undocumented: BENCH_RESULTS_FILE | n/a (see below) | -Total: 50 commands. Missing wiki page: 0. No MCP tool: 33. Env vars found but undocumented: 17. +Total: 50 commands. Missing wiki page: 0. No MCP tool: 33. Env vars found but undocumented: 16.