eleven / ocho Public

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>
Paul Campbell 1586e1de6d17cea4e1de9d0912addece9b205836 parent 50a5852
10 files changed, +105 −25
  • changelog/2026-07-13-upgrade-in-space-menu.md +3 −0
  • cloud.go +5 −2
  • control.go +7 −1
  • controld.go +14 −6
  • home/web/static/home.js +20 −0
  • main.go +12 −2
  • meter.go +11 −6
  • meter_test.go +13 −8
  • web/pages/index.html +5 −0
  • web/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 () => {