Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/surface-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
},
{
Expand Down
4 changes: 2 additions & 2 deletions .github/surface-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
41 changes: 41 additions & 0 deletions .github/wiki/Config-Env-Vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion internal/config/types_ops_plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/license/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 36 additions & 8 deletions internal/license/grace.go
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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"
Expand All @@ -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.
Expand Down
38 changes: 26 additions & 12 deletions internal/license/grace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)")
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)},
Expand Down
8 changes: 4 additions & 4 deletions internal/license/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(),
})

Expand Down
8 changes: 4 additions & 4 deletions internal/license/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions internal/license/ttl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
6 changes: 3 additions & 3 deletions internal/license/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
19 changes: 11 additions & 8 deletions internal/license/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading