Skip to content

Commit c9b23ac

Browse files
author
Paul C
committed
v22.8.0: four-tier pricing — Homelab / Pro / Enterprise on Stripe
* compat::PlatformManifest gains `tier` field. New helpers `license_manifest`, `resolve_tier`, `effective_cap`, `has_feature`. * `/api/platform/status` returns tier, current_nodes, over_cap. Drops email field — operators no longer leak it via this endpoint. * `/api/nodes` (add_node) enforces a SOFT host cap: over-cap joins succeed with a warning attached to the response. Never blocks usage. Legacy enterprise licences (per-installation max_nodes) are zeroed by effective_cap so they're not retroactively capped. * Plugin gates now require has_feature(\"plugins\") instead of any valid licence — so Homelab can't unlock Pro features. * Heartbeat client: TLS verification re-enabled (danger_accept_invalid_certs removed). * Dashboard header badge shows tier (Homelab / Pro / Enterprise) + current/max host count, paints amber when over cap, links to the Stripe billing portal.
1 parent c45da17 commit c9b23ac

4 files changed

Lines changed: 176 additions & 33 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "22.7.3"
3+
version = "22.8.0"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2260,6 +2260,39 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
22602260
}));
22612261
}
22622262

2263+
// Licence host-cap policy: SOFT cap, never hard block.
2264+
//
2265+
// Going over your tier's host count gets you a warning attached to
2266+
// the response (and a banner in the dashboard) but the join still
2267+
// succeeds. Reconciliation happens server-side via the daily licence
2268+
// heartbeat. We never block someone from using WolfStack because
2269+
// they outgrew their tier — that's a sales conversation, not an
2270+
// outage.
2271+
let cap_warning: Option<serde_json::Value> = if let Some(dm) = crate::compat::license_manifest() {
2272+
let tier = crate::compat::resolve_tier(&dm);
2273+
let cap = crate::compat::effective_cap(tier, dm.max_nodes);
2274+
if cap > 0 {
2275+
let current = state.cluster.get_all_nodes().len() as u32;
2276+
if current + 1 > cap {
2277+
let upgrade = match tier {
2278+
"homelab" => "Upgrade to Pro (£49/mo, 25 hosts) at https://wolfstack.org/enterprise.php",
2279+
"pro" => "Upgrade to Enterprise (unlimited hosts) at https://wolfstack.org/enterprise.php",
2280+
_ => "Contact sales@wolf.uk.com to raise the cap",
2281+
};
2282+
Some(serde_json::json!({
2283+
"message": format!(
2284+
"Host added — but your cluster now exceeds the {} tier ({} of {} hosts). {}",
2285+
tier, current + 1, cap, upgrade
2286+
),
2287+
"tier": tier,
2288+
"max_nodes": cap,
2289+
"current_nodes": current + 1,
2290+
"feature": "host_cap",
2291+
}))
2292+
} else { None }
2293+
} else { None }
2294+
} else { None };
2295+
22632296
let node_type = body.node_type.as_deref().unwrap_or("wolfstack");
22642297

22652298
if node_type == "proxmox" {
@@ -2416,13 +2449,19 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
24162449
});
24172450
}
24182451

2419-
HttpResponse::Ok().json(serde_json::json!({
2452+
let mut response = serde_json::json!({
24202453
"id": id,
24212454
"address": body.address,
24222455
"port": port,
24232456
"node_type": "wolfstack",
24242457
"cluster_name": cluster_name,
2425-
}))
2458+
});
2459+
if let Some(warning) = cap_warning {
2460+
if let Some(obj) = response.as_object_mut() {
2461+
obj.insert("warning".to_string(), warning);
2462+
}
2463+
}
2464+
HttpResponse::Ok().json(response)
24262465
}
24272466

24282467
/// DELETE /api/nodes/{id} — remove a server
@@ -20898,12 +20937,12 @@ pub async fn plugins_reload(req: HttpRequest, state: web::Data<AppState>) -> Htt
2089820937

2089920938
const PLUGIN_INDEX_URL: &str = "https://raw.githubusercontent.com/wolfsoftwaresystemsltd/WolfStack/master/pkg/index.json";
2090020939

20901-
/// GET /api/plugins/store — fetch available plugins from the plugin store (Enterprise only)
20940+
/// GET /api/plugins/store — fetch available plugins from the plugin store (Pro+ only)
2090220941
pub async fn plugins_store(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
2090320942
if let Err(resp) = require_auth(&req, &state) { return resp; }
20904-
if !crate::compat::platform_ready() {
20943+
if !crate::compat::has_feature("plugins") {
2090520944
return HttpResponse::Forbidden().json(serde_json::json!({
20906-
"error": "Plugin store requires an Enterprise license",
20945+
"error": "Plugins require a Pro or Enterprise licence — upgrade at https://wolfstack.org/enterprise.php",
2090720946
"feature": "plugins"
2090820947
}));
2090920948
}
@@ -20961,12 +21000,12 @@ pub struct PluginInstallRequest {
2096121000
pub url: String,
2096221001
}
2096321002

20964-
/// POST /api/plugins/install — install a plugin from URL (Enterprise only)
21003+
/// POST /api/plugins/install — install a plugin from URL (Pro+ only)
2096521004
pub async fn plugins_install(req: HttpRequest, state: web::Data<AppState>, body: web::Json<PluginInstallRequest>) -> HttpResponse {
2096621005
if let Err(resp) = require_auth(&req, &state) { return resp; }
20967-
if !crate::compat::platform_ready() {
21006+
if !crate::compat::has_feature("plugins") {
2096821007
return HttpResponse::Forbidden().json(serde_json::json!({
20969-
"error": "Plugins require an Enterprise license",
21008+
"error": "Plugins require a Pro or Enterprise licence — upgrade at https://wolfstack.org/enterprise.php",
2097021009
"feature": "plugins"
2097121010
}));
2097221011
}
@@ -21002,11 +21041,11 @@ pub async fn plugins_toggle(req: HttpRequest, state: web::Data<AppState>, path:
2100221041
}
2100321042
}
2100421043

21005-
/// GET /api/plugins/{id}/file/{path} — serve plugin web assets (JS/CSS) (Enterprise only)
21044+
/// GET /api/plugins/{id}/file/{path} — serve plugin web assets (JS/CSS) (Pro+ only)
2100621045
pub async fn plugins_file(_req: HttpRequest, path: web::Path<(String, String)>) -> HttpResponse {
2100721046
// No auth required for static assets (they're loaded by the browser)
21008-
// But plugins are an Enterprise feature — don't serve assets without a license
21009-
if !crate::compat::platform_ready() {
21047+
// But plugins are gated to Pro+ — don't serve assets without that feature
21048+
if !crate::compat::has_feature("plugins") {
2101021049
return HttpResponse::Forbidden().finish();
2101121050
}
2101221051
let (plugin_id, file_path) = path.into_inner();
@@ -21129,10 +21168,21 @@ pub async fn plugin_data_file(req: HttpRequest, state: web::Data<AppState>, path
2112921168

2113021169
// ─── Access Token Management ───
2113121170

21132-
/// GET /api/platform/status
21171+
/// GET /api/platform/status — licence status + current host count.
21172+
/// Used by the dashboard tier badge and the Settings → License view.
2113321173
pub async fn platform_status(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
2113421174
if let Err(resp) = require_auth(&req, &state) { return resp; }
21135-
HttpResponse::Ok().json(crate::compat::runtime_status())
21175+
21176+
let mut status = crate::compat::runtime_status();
21177+
let current_nodes = state.cluster.get_all_nodes().len() as u32;
21178+
if let Some(obj) = status.as_object_mut() {
21179+
obj.insert("current_nodes".to_string(), serde_json::json!(current_nodes));
21180+
// over_cap is informational — usage is never hard-blocked.
21181+
let cap = obj.get("max_nodes").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
21182+
let over = cap > 0 && current_nodes > cap;
21183+
obj.insert("over_cap".to_string(), serde_json::json!(over));
21184+
}
21185+
HttpResponse::Ok().json(status)
2113621186
}
2113721187

2113821188
#[derive(Deserialize)]

src/compat/mod.rs

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ pub struct PlatformManifest {
5858
pub max_nodes: u32,
5959
pub expires: String,
6060
pub features: Vec<String>,
61+
#[serde(default)]
62+
pub tier: String,
6163
}
6264

6365
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -111,22 +113,74 @@ pub fn probe_runtime() -> bool {
111113
}
112114

113115
pub fn runtime_status() -> serde_json::Value {
116+
// The dashboard badge needs the customer name and tier; it does NOT
117+
// need the email. Omitting the email keeps it out of any
118+
// authenticated session that isn't explicitly admin.
114119
match load_dm() {
115-
Some(dm) => serde_json::json!({
116-
"valid": true,
117-
"customer": dm.customer,
118-
"email": dm.email,
119-
"max_nodes": dm.max_nodes,
120-
"expires": dm.expires,
121-
"features": dm.features,
122-
}),
120+
Some(dm) => {
121+
let tier = resolve_tier(&dm);
122+
let cap = effective_cap(tier, dm.max_nodes);
123+
serde_json::json!({
124+
"valid": true,
125+
"tier": tier,
126+
"customer": dm.customer,
127+
"max_nodes": cap,
128+
"expires": dm.expires,
129+
"features": dm.features,
130+
})
131+
}
123132
None => serde_json::json!({
124133
"valid": false,
134+
"tier": "community",
135+
"max_nodes": 0,
136+
"features": [],
125137
"message": rt_msg(4),
126138
}),
127139
}
128140
}
129141

142+
/// Normalise the host cap reported to UIs and gates. Enterprise is
143+
/// always unlimited, regardless of what a legacy per-installation
144+
/// licence stored in max_nodes.
145+
pub fn effective_cap(tier: &str, raw_max: u32) -> u32 {
146+
match tier {
147+
"homelab" | "pro" => raw_max,
148+
_ => 0,
149+
}
150+
}
151+
152+
/// Public read-only view of the active licence — None when unlicensed
153+
/// (Community tier). Used by `/api/nodes` to enforce host caps.
154+
pub fn license_manifest() -> Option<PlatformManifest> {
155+
load_dm()
156+
}
157+
158+
/// Resolve the licence tier name. Newer licences include `tier` in the
159+
/// signed payload; older licences are inferred from the `features` list
160+
/// (the webhook always sets the first feature to the tier slug).
161+
pub fn resolve_tier(dm: &PlatformManifest) -> &'static str {
162+
if !dm.tier.is_empty() {
163+
return match dm.tier.as_str() {
164+
"homelab" => "homelab",
165+
"pro" => "pro",
166+
"enterprise" => "enterprise",
167+
_ => "enterprise",
168+
};
169+
}
170+
if dm.features.iter().any(|f| f == "homelab") { "homelab" }
171+
else if dm.features.iter().any(|f| f == "pro") { "pro" }
172+
else { "enterprise" }
173+
}
174+
175+
/// True when the licence grants access to a named feature
176+
/// (e.g. "sso", "api_keys", "plugins", "wolfcustom", "wolfhost").
177+
pub fn has_feature(name: &str) -> bool {
178+
match load_dm() {
179+
Some(dm) => dm.features.iter().any(|f| f == name),
180+
None => false,
181+
}
182+
}
183+
130184
fn ts_ymd() -> String {
131185
let s = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
132186
let (y, m, d) = cd(s / 86400);
@@ -207,12 +261,13 @@ pub async fn report_license_heartbeat(cluster: &crate::agent::ClusterState) {
207261
}
208262

209263
/// Shared HTTP client for the daily license heartbeat. One pool for
210-
/// the lifetime of the process.
264+
/// the lifetime of the process. wolfstack.org has a valid public cert,
265+
/// so cert verification stays on — the heartbeat carries the licence
266+
/// record and isn't something we want intercepted.
211267
static HEARTBEAT_CLIENT: std::sync::LazyLock<reqwest::Client> =
212268
std::sync::LazyLock::new(|| {
213269
crate::api::ipv4_only_client_builder()
214270
.timeout(std::time::Duration::from_secs(15))
215-
.danger_accept_invalid_certs(true)
216271
.build()
217272
.unwrap_or_else(|_| reqwest::Client::new())
218273
});

web/js/app.js

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28818,21 +28818,59 @@ async function loadSponsorHeaderBadge() {
2881828818
var badgeEl = document.getElementById('sponsor-header-badge');
2881928819
if (!badgeEl) return;
2882028820

28821-
// Check enterprise license first — takes priority over sponsor tier
28821+
// Check WolfStack licence first — takes priority over sponsor tier
2882228822
try {
2882328823
var licResp = await fetch(apiUrl('/api/platform/status'));
2882428824
if (licResp.ok) {
2882528825
var lic = await licResp.json();
2882628826
if (lic.valid) {
28827-
badgeEl.style.background = 'linear-gradient(135deg, #dc2626, #ef4444)';
28828-
badgeEl.textContent = 'Enterprise';
28829-
if (textEl) textEl.textContent = lic.customer || 'Enterprise License';
28827+
var tierLabels = { homelab: 'Homelab', pro: 'Pro', enterprise: 'Enterprise' };
28828+
var tierGradients = {
28829+
homelab: 'linear-gradient(135deg, #2563eb, #3b82f6)',
28830+
pro: 'linear-gradient(135deg, #7c3aed, #a855f7)',
28831+
enterprise: 'linear-gradient(135deg, #dc2626, #ef4444)'
28832+
};
28833+
var tierBg = {
28834+
homelab: 'linear-gradient(135deg, rgba(37,99,235,0.12), rgba(59,130,246,0.08))',
28835+
pro: 'linear-gradient(135deg, rgba(124,58,237,0.12), rgba(168,85,247,0.08))',
28836+
enterprise: 'linear-gradient(135deg, rgba(220,38,38,0.12), rgba(239,68,68,0.08))'
28837+
};
28838+
var tierBorder = {
28839+
homelab: 'rgba(37,99,235,0.25)',
28840+
pro: 'rgba(124,58,237,0.25)',
28841+
enterprise: 'rgba(220,38,38,0.25)'
28842+
};
28843+
var tier = lic.tier || 'enterprise';
28844+
var label = tierLabels[tier] || 'Licensed';
28845+
// When over the host cap we paint the badge amber to flag
28846+
// it without breaking the operator's flow — usage is never
28847+
// hard-blocked, this is just a visible "you owe us a chat"
28848+
// signal.
28849+
var amberGrad = 'linear-gradient(135deg, #d97706, #f59e0b)';
28850+
var amberBg = 'linear-gradient(135deg, rgba(217,119,6,0.14), rgba(245,158,11,0.10))';
28851+
var amberBorder = 'rgba(217,119,6,0.35)';
28852+
var overCap = !!lic.over_cap;
28853+
badgeEl.style.background = overCap ? amberGrad : (tierGradients[tier] || tierGradients.enterprise);
28854+
badgeEl.textContent = overCap ? (label + ' · over cap') : label;
28855+
var detail = lic.customer || 'Licensed';
28856+
if (typeof lic.current_nodes === 'number') {
28857+
if (lic.max_nodes && lic.max_nodes > 0) {
28858+
detail += ' — ' + lic.current_nodes + '/' + lic.max_nodes + ' hosts';
28859+
} else {
28860+
detail += ' — ' + lic.current_nodes + ' host' + (lic.current_nodes === 1 ? '' : 's');
28861+
}
28862+
}
28863+
if (textEl) textEl.textContent = detail;
2883028864
if (linkEl) {
28831-
linkEl.style.background = 'linear-gradient(135deg, rgba(220,38,38,0.12), rgba(239,68,68,0.08))';
28832-
linkEl.style.borderColor = 'rgba(220,38,38,0.25)';
28833-
linkEl.removeAttribute('href');
28834-
linkEl.removeAttribute('target');
28835-
linkEl.style.cursor = 'default';
28865+
linkEl.style.background = overCap ? amberBg : (tierBg[tier] || tierBg.enterprise);
28866+
linkEl.style.borderColor = overCap ? amberBorder : (tierBorder[tier] || tierBorder.enterprise);
28867+
linkEl.style.cursor = 'pointer';
28868+
linkEl.setAttribute('href', 'https://wolfstack.org/enterprise-portal.php');
28869+
linkEl.setAttribute('target', '_blank');
28870+
linkEl.setAttribute('rel', 'noopener');
28871+
linkEl.title = overCap
28872+
? 'Cluster exceeds the ' + tier + ' tier — click to upgrade'
28873+
: 'Click to manage your WolfStack subscription';
2883628874
}
2883728875
return; // Skip sponsor check
2883828876
}

0 commit comments

Comments
 (0)