server+web: Upgrade in the space menu, gated by a provisioner-pushed entitlement flag (#839)
The cloud ledger's plan flag ("free"/"paid") now mirrors into each provisioned
instance's env: written as LCHAT_ENTITLEMENT at create (every provisioned
space is born free) and re-pushed on billing flips through the existing quota
path (controld upserts it alongside LCHAT_BLOB_QUOTA_BYTES in one write).
Operator/self-hosted instances have no flag and are untouched.
The chat page templates it as window.__entitlement, and the space ⋮ menu
gains an Upgrade entry revealed only when the space is free, a home server is
configured, and we're in a real browser — the native wrappers stay quiet (App
Store rules forbid linking out to external purchases; the Mac app follows for
now, a deliberate web-only scope). The entry opens home's #upgrade=<host>
deep link, which home.js consumes once the provisioner ledger is loaded,
opening the tier picker for that space directly.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+105
−25
changelog/2026-07-13-upgrade-in-space-menu.md+3 −0cloud.go+5 −2control.go+7 −1controld.go+14 −6home/web/static/home.js+20 −0main.go+12 −2meter.go+11 −6meter_test.go+13 −8web/pages/index.html+5 −0web/static/shell.js+15 −0
diff --git a/changelog/2026-07-13-upgrade-in-space-menu.md b/changelog/2026-07-13-upgrade-in-space-menu.md| new file mode 100644 |
| index 0000000..e1d4490 |
| --- /dev/null |
| +++ b/changelog/2026-07-13-upgrade-in-space-menu.md |
| @@ -0,0 +1,3 @@ |
| +🤖 Upgrading your space is now one tap closer: on a free space, the ⋮ menu |
| +has an Upgrade option that takes you straight to your plans page. Paid spaces |
| +and self-hosted servers don't show it — there's nothing to upsell you there. |
diff --git a/cloud.go b/cloud.go| index ec3ee81..a011b64 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -234,9 +234,12 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| } |
| } |
| -// controlCreate posts one creation request to the control daemon. |
| +// controlCreate posts one creation request to the control daemon. Every |
| +// provisioned space is born free — the entitlement lands in the instance env |
| +// so its UI can offer Upgrade from day one; billing flips re-push via the |
| +// quota path. |
| func (s *cloudServer) controlCreate(host string) (int, map[string]any, error) { |
| - payload, _ := json.Marshal(controlCreateReq{Host: host}) |
| + payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entFree}) |
| req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload)) |
| if err != nil { |
| return 0, nil, err |
diff --git a/control.go b/control.go| index 9054101..8894013 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -117,7 +117,12 @@ type addInstanceOpts struct { |
| HomeOrigin string |
| PushRelayURL string |
| PushRelayToken string |
| - HomeRegion string // geo seam (#550 Part 7): "" = the single implicit region |
| + // Entitlement mirrors the cloud ledger's plan flag ("free"/"paid") into the |
| + // instance env (LCHAT_ENTITLEMENT) so its UI can gate billing affordances — |
| + // the space menu's Upgrade. Empty for operator-created instances, which have |
| + // no billing surface and show nothing. |
| + Entitlement string |
| + HomeRegion string // geo seam (#550 Part 7): "" = the single implicit region |
| Provider string // co-op seam (#550 Part 10): "" = the single implicit provider |
| // Hibernate registers the instance as activator-managed (#550 Part 4 v2): |
| // no systemd unit is created — the router wakes it on first request. Needs |
| @@ -223,6 +228,7 @@ func materializeInstance(reg *Registry, route Route, token string, opts addInsta |
| "LCHAT_HOME_ORIGIN": opts.HomeOrigin, |
| "LCHAT_PUSH_RELAY_URL": opts.PushRelayURL, |
| "LCHAT_PUSH_RELAY_TOKEN": opts.PushRelayToken, |
| + "LCHAT_ENTITLEMENT": opts.Entitlement, |
| } { |
| if v != "" { |
| env[k] = v |
diff --git a/controld.go b/controld.go| index 59bddc5..f2172ef 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -83,6 +83,7 @@ type controlCreateReq struct { |
| HomeOrigin string `json:"home_origin,omitempty"` |
| PushRelayURL string `json:"push_relay_url,omitempty"` |
| PushRelayToken string `json:"push_relay_token,omitempty"` |
| + Entitlement string `json:"entitlement,omitempty"` |
| } |
| // controlErr is the wire error shape: a stable machine code plus a human |
| @@ -106,10 +107,11 @@ func (c *controld) handler() http.Handler { |
| } |
| // quotaHandler applies a storage cap to one instance: upsert |
| -// LCHAT_BLOB_QUOTA_BYTES in its env file (0 = back to the compiled default) |
| -// and bounce a unit-managed instance so it applies now. A hibernating |
| -// instance's env is read at spawn, so its cap simply applies on next wake — |
| -// never wake a space to limit it. |
| +// LCHAT_BLOB_QUOTA_BYTES in its env file (0 = back to the compiled default), |
| +// mirror the billing entitlement alongside it when the caller sends one, and |
| +// bounce a unit-managed instance so it applies now. A hibernating instance's |
| +// env is read at spawn, so its cap simply applies on next wake — never wake a |
| +// space to limit it. |
| func (c *controld) quotaHandler(w http.ResponseWriter, r *http.Request) { |
| host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| if !validHost(host) { |
| @@ -117,14 +119,19 @@ func (c *controld) quotaHandler(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| var req struct { |
| - Bytes int64 `json:"bytes"` |
| + Bytes int64 `json:"bytes"` |
| + Entitlement string `json:"entitlement"` |
| } |
| if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil || req.Bytes < 0 { |
| writeControlErr(w, http.StatusBadRequest, "bad_request", "bytes required (>= 0; 0 restores the default)") |
| return |
| } |
| + if req.Entitlement != "" && req.Entitlement != "free" && req.Entitlement != "paid" { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", `entitlement must be "free" or "paid" (or omitted)`) |
| + return |
| + } |
| envPath := filepath.Join(instanceDataDir, host+".env") |
| - if err := setInstanceQuota(envPath, req.Bytes); err != nil { |
| + if err := setInstanceQuota(envPath, req.Bytes, req.Entitlement); err != nil { |
| if os.IsNotExist(err) { |
| writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance") |
| return |
| @@ -314,6 +321,7 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) { |
| HomeOrigin: req.HomeOrigin, |
| PushRelayURL: req.PushRelayURL, |
| PushRelayToken: req.PushRelayToken, |
| + Entitlement: req.Entitlement, |
| // Hibernate-by-default (#550): with LCHAT_CONTROL_HIBERNATE_CREATES=1 |
| // every provisioner-created space is born activator-managed — the |
| // create's public-readiness probe is its one (first) wake, minting the |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 4c6e656..0933539 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -97,6 +97,9 @@ let billingTiers = []; // the provisioner's price book: [{key, name, label}] |
| // by the first showVault after return. |
| let upgradedHost = new URLSearchParams(location.search).get("upgraded") || ""; |
| if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash); |
| +// A space's own menu deep-links here as #upgrade=<host>; consumed once the |
| +// provisioner ledger is in hand (before sign-in it just waits in the fragment). |
| +let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase(); |
| // Ceremony options are pre-fetched so the button click reaches |
| // navigator.credentials.* synchronously — Safari drops the user activation |
| @@ -341,10 +344,27 @@ function refreshProvisioned() { |
| provisioned = {}; |
| for (const sp of r.spaces || []) provisioned[sp.host] = sp; |
| if (vault && !$("vault").classList.contains("hidden")) showVault(); |
| + consumeUpgradeLink(); |
| }) |
| .catch(() => { /* ledger unreachable — render without the affordance */ }); |
| } |
| +// consumeUpgradeLink honours a #upgrade=<host> deep link (the space menu's |
| +// Upgrade entry) once the ledger is in: opens the tier picker for that space |
| +// if it's this account's and still free. Cleared either way, so a stale |
| +// fragment can't re-fire the sheet on later renders. |
| +function consumeUpgradeLink() { |
| + if (!upgradeLinkHost || !vault) return; |
| + const target = upgradeLinkHost; |
| + upgradeLinkHost = ""; |
| + history.replaceState(null, "", location.pathname + location.search); |
| + const s = (vault.obj.servers || []).find((sv) => { |
| + try { return new URL(sv.url).host.toLowerCase() === target; } catch { return false; } |
| + }); |
| + const ledger = provisioned && provisioned[target]; |
| + if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s); |
| +} |
| + |
| // ---- archive: undo banner + entry point -------------------------------------- |
| // pendingUndo holds the pre-archive servers snapshot so a single tap fully |
| // restores the entry (its star + exact position), not just the archived flag. |
diff --git a/main.go b/main.go| index 4697956..0ead4ed 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -82,8 +82,13 @@ type Server struct { |
| // <homeOrigin>/enroll as a user-initiated popup to back this instance up (see |
| // the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment. |
| homeOrigin string |
| - vapidPub string |
| - vapidPriv string |
| + // The cloud ledger's plan flag, mirrored into this instance's env by the |
| + // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"). Gates the space menu's |
| + // Upgrade entry; empty on self-hosted/operator instances, which have no |
| + // billing surface and show nothing. |
| + entitlement string |
| + vapidPub string |
| + vapidPriv string |
| apns apnsSender // nil unless APNs (direct p8 or relay) is configured |
| maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES) |
| blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES) |
| @@ -263,6 +268,7 @@ func main() { |
| } |
| s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(), |
| appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin, |
| + entitlement: strings.TrimSpace(os.Getenv("LCHAT_ENTITLEMENT")), |
| maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes), |
| blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes), |
| appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))} |
| @@ -696,6 +702,10 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL)) |
| // The home server the chat page enrols into for passkey backup. |
| page = strings.ReplaceAll(page, "__HOME_ORIGIN__", html.EscapeString(s.homeOrigin)) |
| + // The provisioner-pushed plan flag ("free"/"paid"; empty when self-hosted) |
| + // — gates the space menu's Upgrade entry, enforced by nothing here: the |
| + // checkout itself lives on the home server. |
| + page = strings.ReplaceAll(page, "__ENTITLEMENT__", html.EscapeString(s.entitlement)) |
| // Link-preview (OpenGraph) fields for the join page. Absolute URLs, since |
| // crawlers fetch with no base. The image is the server branding avatar when |
| // set, else the app logo — both plaintext branding, never user content, so |
diff --git a/meter.go b/meter.go| index 2b85d3c..83a0267 100644 |
| --- a/meter.go |
| +++ b/meter.go |
| @@ -177,8 +177,8 @@ func (s *cloudServer) capFor(entitlement, tier string) int64 { |
| } |
| // controlSetQuota pushes one instance's storage ceiling to controld. |
| -func (s *cloudServer) controlSetQuota(host string, quota int64) error { |
| - payload, _ := json.Marshal(map[string]int64{"bytes": quota}) |
| +func (s *cloudServer) controlSetQuota(host string, quota int64, entitlement string) error { |
| + payload, _ := json.Marshal(map[string]any{"bytes": quota, "entitlement": entitlement}) |
| req, err := http.NewRequest("POST", "http://control/instances/"+host+"/quota", bytes.NewReader(payload)) |
| if err != nil { |
| return err |
| @@ -204,7 +204,7 @@ func (s *cloudServer) pushQuota(id int64, host, entitlement, tier string) { |
| if cap <= 0 { |
| return |
| } |
| - if err := s.controlSetQuota(host, cap); err != nil { |
| + if err := s.controlSetQuota(host, cap, entitlement); err != nil { |
| log.Printf("cloud: quota push %s: %v (sweep will retry)", host, err) |
| return |
| } |
| @@ -225,9 +225,11 @@ func entitlementLabel(entitlement, tier string) string { |
| // ---- quota apply (controld-side, root) ------------------------------------------- |
| // setInstanceQuota upserts LCHAT_BLOB_QUOTA_BYTES in an instance's env file |
| -// (0 removes it — back to the compiled default), preserving every other var. |
| -// Pure but for the filesystem, so it's directly testable. |
| -func setInstanceQuota(envPath string, quota int64) error { |
| +// (0 removes it — back to the compiled default) and, when entitlement is |
| +// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid" — the UI's billing- |
| +// affordance gate) in the same write, preserving every other var. Pure but |
| +// for the filesystem, so it's directly testable. |
| +func setInstanceQuota(envPath string, quota int64, entitlement string) error { |
| env, err := readEnvFile(envPath) |
| if err != nil { |
| return err |
| @@ -237,6 +239,9 @@ func setInstanceQuota(envPath string, quota int64) error { |
| } else { |
| env["LCHAT_BLOB_QUOTA_BYTES"] = strconv.FormatInt(quota, 10) |
| } |
| + if entitlement != "" { |
| + env["LCHAT_ENTITLEMENT"] = entitlement |
| + } |
| return writeEnvFile(envPath, env) |
| } |
diff --git a/meter_test.go b/meter_test.go| index 923dbed..f0fb416 100644 |
| --- a/meter_test.go |
| +++ b/meter_test.go |
| @@ -122,34 +122,39 @@ func TestSetInstanceQuota(t *testing.T) { |
| if err := os.WriteFile(path, []byte("LCHAT_ADMIN_TOKEN=secret\nLCHAT_DB=/data/x.db\n"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| - if err := setInstanceQuota(path, 25e9); err != nil { |
| + if err := setInstanceQuota(path, 25e9, "free"); err != nil { |
| t.Fatal(err) |
| } |
| env, err := readEnvFile(path) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - // The cap is set and every other var survived. |
| - if env["LCHAT_BLOB_QUOTA_BYTES"] != "25000000000" || env["LCHAT_ADMIN_TOKEN"] != "secret" || env["LCHAT_DB"] != "/data/x.db" { |
| + // The cap and entitlement are set and every other var survived. |
| + if env["LCHAT_BLOB_QUOTA_BYTES"] != "25000000000" || env["LCHAT_ENTITLEMENT"] != "free" || |
| + env["LCHAT_ADMIN_TOKEN"] != "secret" || env["LCHAT_DB"] != "/data/x.db" { |
| t.Fatalf("env after set = %v", env) |
| } |
| - // Idempotent update, then removal at 0. |
| - if err := setInstanceQuota(path, 10e9); err != nil { |
| + // Idempotent update; an empty entitlement leaves the flag alone. |
| + if err := setInstanceQuota(path, 10e9, ""); err != nil { |
| t.Fatal(err) |
| } |
| env, _ = readEnvFile(path) |
| - if env["LCHAT_BLOB_QUOTA_BYTES"] != "10000000000" { |
| + if env["LCHAT_BLOB_QUOTA_BYTES"] != "10000000000" || env["LCHAT_ENTITLEMENT"] != "free" { |
| t.Fatalf("env after update = %v", env) |
| } |
| - if err := setInstanceQuota(path, 0); err != nil { |
| + // A billing flip rides the same write; removal at 0 spares the flag. |
| + if err := setInstanceQuota(path, 0, "paid"); err != nil { |
| t.Fatal(err) |
| } |
| env, _ = readEnvFile(path) |
| if _, ok := env["LCHAT_BLOB_QUOTA_BYTES"]; ok { |
| t.Fatalf("cap not removed: %v", env) |
| } |
| + if env["LCHAT_ENTITLEMENT"] != "paid" { |
| + t.Fatalf("entitlement not flipped: %v", env) |
| + } |
| // A missing instance surfaces not-exist (the handler's 404). |
| - if err := setInstanceQuota(filepath.Join(t.TempDir(), "absent.env"), 1); !os.IsNotExist(err) { |
| + if err := setInstanceQuota(filepath.Join(t.TempDir(), "absent.env"), 1, ""); !os.IsNotExist(err) { |
| t.Errorf("absent env: err = %v, want not-exist", err) |
| } |
| } |
diff --git a/web/pages/index.html b/web/pages/index.html| index 0400510..b552964 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -22,6 +22,10 @@ |
| is public, so exposing it here is fine. --> |
| <script nonce="__CSP_NONCE__"> |
| window.__homeOrigin = "__HOME_ORIGIN__"; |
| + // The provisioner-pushed plan flag ("free"/"paid"; empty when self-hosted). |
| + // shell.js reveals the menu's Upgrade entry from it — display only, nothing |
| + // here enforces billing. |
| + window.__entitlement = "__ENTITLEMENT__"; |
| </script> |
| <!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in |
| page. This must be client-side — the session lives in localStorage, which |
| @@ -132,6 +136,7 @@ |
| <button class="menu-item" id="menu-theme" role="menuitem">Theme</button> |
| <button class="menu-item" id="menu-add-device" role="menuitem">Add a device / back up</button> |
| <button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button> |
| + <button class="menu-item" id="menu-upgrade" role="menuitem" hidden>Upgrade</button> |
| <button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button> |
| </div> |
| </div> |
diff --git a/web/static/shell.js b/web/static/shell.js| index 98cf871..ab82b7a 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -3113,6 +3113,21 @@ function renderApp() { |
| wireItem("menu-rename", editProfile); |
| wireItem("menu-leave", leave); |
| + // Upgrade — revealed only on a free cloud-provisioned space in a real |
| + // browser. The checkout lives on the home page (this server has no billing |
| + // surface), and the native wrappers must not link out to external purchases |
| + // (App Store rules), so they stay quiet. Self-hosted instances have no |
| + // __entitlement and never see this. |
| + const inNativeWrapper = |
| + document.documentElement.classList.contains("native") || |
| + !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat); |
| + if (window.__entitlement === "free" && window.__homeOrigin && !inNativeWrapper) { |
| + document.getElementById("menu-upgrade").hidden = false; |
| + wireItem("menu-upgrade", () => |
| + window.open(window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host), "_blank"), |
| + ); |
| + } |
| + |
| // Notifications toggle (hidden where push isn't usable, e.g. iOS before install). |
| const notif = document.getElementById("menu-notif"); |
| const refreshNotif = async () => { |