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