eleven / messenger Public

server+home: storage meter — per-space bytes on the ledger, shown to members (#669)

#668 slice 1. controld's stats endpoint gains storage_bytes (SQLite files
+ blob dir; pure filesystem metadata, never wakes a hibernating space);
the cloud service sweeps it into the ledger every 6h; the space listing
carries it; home renders it under each created space. Transparency only —
enforcement is #561, whose quota numbers want this data first.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a642989029838d96e2224b9341917a05a40b6db9 parent e919d91
10 files changed, +305 −21
  • changelog/2026-07-11-storage-meter.md +4 −0
  • cloud.go +2 −0
  • cloud_db.go +47 −3
  • controld.go +7 −2
  • docs/cloud-provisioner.md +12 −0
  • home/web/pages/index.html +2 −0
  • home/web/static/home.js +18 −0
  • meter.go +102 −0
  • meter_test.go +91 −0
  • reaper.go +20 −16
diff --git a/changelog/2026-07-11-storage-meter.md b/changelog/2026-07-11-storage-meter.md
new file mode 100644
index 0000000..7be9311
--- /dev/null
+++ b/changelog/2026-07-11-storage-meter.md
@@ -0,0 +1,4 @@
+🤖 Your Eleven home page now shows how much each space you created is
+storing — photos, videos, and files, measured as the encrypted bytes we hold
+(we can't see what's in them, only how much there is). Nothing is limited;
+this is just your own usage, visible to you.
diff --git a/cloud.go b/cloud.go
index 9242e95..77f9e83 100644
--- a/cloud.go
+++ b/cloud.go
@@ -379,6 +379,7 @@ func runCloud(args []string) {
reapIntervalHours := fs.Int("reap-interval-hours", envInt("LCHAT_CLOUD_REAP_INTERVAL_HOURS", 6), "how often the reaper sweeps")
reapDryRun := fs.Bool("reap-dry-run", os.Getenv("LCHAT_CLOUD_REAP_DRY_RUN") != "", "log what the reaper would reclaim without deleting anything")
reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)")
+ meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)")
_ = fs.Parse(args)
if *baseDomain == "" || !validHost("x."+*baseDomain) {
@@ -422,6 +423,7 @@ func runCloud(args []string) {
log.Printf("cloud: billing on (%d tiers)", len(billing.tiers))
}
go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour)
+ go s.meterLoop(time.Duration(*meterIntervalHours) * time.Hour)
ln, where, err := listen(*addr, *socketPath)
if err != nil {
die("cloud: listen: %v", err)
diff --git a/cloud_db.go b/cloud_db.go
index ae47b68..efc871d 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -65,7 +65,10 @@ CREATE TABLE IF NOT EXISTS spaces (
if err := db.migrateSpacesID(); err != nil {
return err
}
- return db.migrateBilling()
+ if err := db.migrateBilling(); err != nil {
+ return err
+ }
+ return db.migrateMeter()
}
// migrateSpacesID rebuilds a spaces table created under the original host-PK
@@ -160,6 +163,19 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n
return nil
}
+// migrateMeter adds the storage meter's columns (#668 slice 1): the byte count
+// controld last measured and when. Bytes of ciphertext — content-blind, like
+// everything in this ledger.
+func (db *cloudDB) migrateMeter() error {
+ if has, err := db.spacesHasColumn("storage_bytes"); err != nil || has {
+ return err
+ }
+ _, err := db.sql.Exec(`
+ALTER TABLE spaces ADD COLUMN storage_bytes INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE spaces ADD COLUMN metered_at INTEGER NOT NULL DEFAULT 0;`)
+ return err
+}
+
// mintCallerToken creates a caller token, stores only its hash, and returns the
// plaintext (shown once).
func (db *cloudDB) mintCallerToken(label string) (id, token string, err error) {
@@ -337,13 +353,17 @@ type Space struct {
Entitlement string `json:"entitlement"`
Tier string `json:"tier,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"`
+ // The storage meter's last reading (bytes of ciphertext; 0 = not yet
+ // metered) and when it was taken.
+ StorageBytes int64 `json:"storage_bytes,omitempty"`
+ MeteredAt int64 `json:"metered_at,omitempty"`
}
// listSpaces returns a requester's finalized spaces (a NULL host is a create
// still in flight or one that failed before rollback — not shown).
func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
rows, err := db.sql.Query(
- `SELECT host, created_at, entitlement, tier, paid_until FROM spaces
+ `SELECT host, created_at, entitlement, tier, paid_until, storage_bytes, metered_at FROM spaces
WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`,
callerID, requester,
)
@@ -354,7 +374,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
out := []Space{}
for rows.Next() {
var s Space
- if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil); err != nil {
+ if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.StorageBytes, &s.MeteredAt); err != nil {
return nil, err
}
s.URL = "https://" + s.Host
@@ -408,6 +428,30 @@ func (db *cloudDB) setEntitlement(id int64, ent string, paidUntil int64) error {
return nil
}
+// allFinalizedSpaces is the meter's sweep set: every space with a hostname.
+func (db *cloudDB) allFinalizedSpaces() ([]reapCandidate, error) {
+ rows, err := db.sql.Query(`SELECT id, host, created_at FROM spaces WHERE host IS NOT NULL ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []reapCandidate
+ for rows.Next() {
+ var c reapCandidate
+ if err := rows.Scan(&c.ID, &c.Host, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+// setStorageBytes records one meter reading.
+func (db *cloudDB) setStorageBytes(id, bytes, at int64) error {
+ _, err := db.sql.Exec(`UPDATE spaces SET storage_bytes = ?, metered_at = ? WHERE id = ?`, bytes, at, id)
+ return err
+}
+
// findSpaceBySubscription maps a Stripe subscription id back to its space — the
// fallback for subscription events that arrive without space_id metadata.
func (db *cloudDB) findSpaceBySubscription(subscriptionID string) (int64, bool, error) {
diff --git a/controld.go b/controld.go
index 1641203..05451b1 100644
--- a/controld.go
+++ b/controld.go
@@ -128,7 +128,7 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) {
// skips those, as before.)
if c.reg != nil {
if rt, rerr := c.reg.get(host); rerr == nil && rt != nil && rt.Kind == "instance" && rt.Hibernate {
- writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": 0, "setup_complete": false, "has_content": false})
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": 0, "setup_complete": false, "has_content": false, "storage_bytes": 0})
return
}
}
@@ -139,7 +139,12 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) {
writeControlErr(w, http.StatusInternalServerError, "stats_failed", err.Error())
return
}
- writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": members, "setup_complete": setupComplete, "has_content": hasContent})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "host": host, "members": members, "setup_complete": setupComplete, "has_content": hasContent,
+ // The storage meter's read (meter.go) — pure filesystem metadata,
+ // measured alongside the DB stats so one endpoint serves both sweeps.
+ "storage_bytes": instanceStorageBytes(host),
+ })
}
// deleteHandler deprovisions one instance (self-serve space deletion, reached
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index d3927e4..e594436 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -286,6 +286,18 @@ window: `LCHAT_TRASH_KEEP_DAYS` (opt-in — 0/unset keeps trash forever) removes
`/data/trash/<host>-<unix>` entries older than that, swept daily. Keep it well
above the reap threshold so a mistaken reap stays recoverable for a while.
+## The storage meter (#668 slice 1)
+
+Every sweep interval (`LCHAT_CLOUD_METER_INTERVAL_HOURS`, default 6, 0
+disables), the cloud service asks controld for each ledger space's on-disk
+footprint — the SQLite files plus the attachment blob dir, **pure filesystem
+metadata: nothing is read, and a hibernating space is never woken**. The
+reading lands on the ledger row (`storage_bytes`, `metered_at`), rides the
+space listing, and home shows it to the member ("2.1 GB stored"). Transparency
+only — quota enforcement is #561, and its numbers should come from this data.
+Bytes of ciphertext are exactly the kind of thing this ledger is allowed to
+know (#562: price only what ciphertext exposes).
+
## Billing (Stripe) — tiers, quota exemption, no metering
Optional space upgrades (`billing.go`): a person who created a space can pay a
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 4d5a8ab..3735aaa 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -315,6 +315,8 @@
ul.servers li a.name { font-weight: 600; line-height: 1.25; color: var(--ink); text-decoration: none; }
ul.servers li a.name:hover { text-decoration: underline; }
ul.servers li .url { color: var(--muted); font-size: .8rem; line-height: 1.3; overflow-wrap: anywhere; }
+ /* The storage meter's reading, under the url — quieter still. */
+ ul.servers li .srv-usage { color: var(--muted); opacity: .75; font-size: .72rem; line-height: 1.3; }
/* Star toggle: a quiet outline star that fills gold when on. */
.star {
flex: none; background: none; border: none; padding: 2px; cursor: pointer;
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 1866a67..04d1876 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -71,6 +71,14 @@ function archiveStatus(msg, kind) {
function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } }
function labelOf(s) { return s.name || hostOf(s.url); }
+// fmtBytes renders a storage reading for members: decimal units (matching how
+// storage is priced), one decimal for GB, whole numbers below.
+function fmtBytes(n) {
+ if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + " GB";
+ if (n >= 1e6) return Math.round(n / 1e6) + " MB";
+ return "<1 MB";
+}
+
// Whether this home server offers self-serve space creation (set from
// /api/features). The create-a-space controls only appear when it's both
// enabled here and you're signed in.
@@ -458,6 +466,16 @@ function serverRow(s, sortable) {
main.append(badge);
}
+ // The storage meter's reading (spaces this account created; 0 = not yet
+ // metered). Transparency first — quotas come later (#561).
+ const metered = provisioned ? provisioned[hostOf(s.url)] : null;
+ if (metered && metered.storage_bytes > 0) {
+ const usage = document.createElement("span");
+ usage.className = "srv-usage";
+ usage.textContent = fmtBytes(metered.storage_bytes) + " stored";
+ main.append(usage);
+ }
+
li.append(star, main);
if (ledger && ledger.entitlement !== "paid") {
diff --git a/meter.go b/meter.go
new file mode 100644
index 0000000..6eb7b33
--- /dev/null
+++ b/meter.go
@@ -0,0 +1,102 @@
+package main
+
+// The storage meter (#668 slice 1): how many bytes each cloud-provisioned
+// space occupies, measured server-side and shown to members — no enforcement
+// (that's #561, and its quota numbers want this data first).
+//
+// Metering only what ciphertext exposes: an instance's bytes are its DB files
+// plus its attachment blobs, all opaque. Nothing here reads content, and
+// nothing here ever WAKES a space — measurement is pure filesystem metadata,
+// so a sweep over a fleet of hibernating instances stays free.
+//
+// Split like the reaper: the measuring half runs in controld (root, owns
+// /data), the sweeping half in the cloud service (unprivileged, owns the
+// ledger), joined by the existing stats endpoint.
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// ---- measuring (controld-side, root) -------------------------------------------
+
+// storageBytesForDB sums the on-disk footprint of an instance rooted at its DB
+// path: the SQLite files (db, -wal, -shm) plus the attachment blob dir
+// (<db-sans-ext>-blobs/, the disk blobStore's layout — db.go). Pure but for
+// the filesystem, so it's directly testable.
+//
+// Slice-1 honesty: a hibernating space whose local copy has been cleaned away
+// measures as only what remains on disk; measuring the S3 prefix instead is
+// follow-up work in #668. Today the activator keeps local copies, so this is
+// the fleet's truth in practice.
+func storageBytesForDB(dbPath string) int64 {
+ var total int64
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ if info, err := os.Stat(dbPath + suffix); err == nil {
+ total += info.Size()
+ }
+ }
+ blobDir := strings.TrimSuffix(dbPath, filepath.Ext(dbPath)) + "-blobs"
+ _ = filepath.WalkDir(blobDir, func(_ string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return nil // unreadable entries just don't count; never fail a sweep
+ }
+ if info, err := d.Info(); err == nil {
+ total += info.Size()
+ }
+ return nil
+ })
+ return total
+}
+
+// instanceStorageBytes measures one hosted instance.
+func instanceStorageBytes(host string) int64 {
+ return storageBytesForDB(filepath.Join(instanceDataDir, host+".db"))
+}
+
+// ---- sweeping (cloud-side) ------------------------------------------------------
+
+// meterLoop refreshes every ledger space's storage_bytes on an interval.
+// Enabled by default (interval <= 0 disables): the meter is harmless metadata
+// and members should see their usage without an operator opting in.
+func (s *cloudServer) meterLoop(interval time.Duration) {
+ if interval <= 0 {
+ return
+ }
+ log.Printf("cloud: storage meter on — sweeping every %s", interval)
+ for {
+ s.meterOnce()
+ time.Sleep(interval)
+ }
+}
+
+// meterOnce asks controld for each finalized space's byte count and records
+// it. A stats error skips that space (its last reading stands, metered_at
+// shows the staleness); the sweep never fails as a whole.
+func (s *cloudServer) meterOnce() (metered int) {
+ spaces, err := s.db.allFinalizedSpaces()
+ if err != nil {
+ log.Printf("cloud: meter: ledger query: %v", err)
+ return 0
+ }
+ now := time.Now().Unix()
+ for _, sp := range spaces {
+ st, err := s.controlStats(sp.Host)
+ if err != nil {
+ log.Printf("cloud: meter: skip %s (stats unavailable): %v", sp.Host, err)
+ continue
+ }
+ if err := s.db.setStorageBytes(sp.ID, st.StorageBytes, now); err != nil {
+ log.Printf("cloud: meter: record %s: %v", sp.Host, err)
+ continue
+ }
+ metered++
+ }
+ if metered > 0 {
+ log.Printf("cloud: meter: refreshed %d space(s)", metered)
+ }
+ return metered
+}
diff --git a/meter_test.go b/meter_test.go
new file mode 100644
index 0000000..3232853
--- /dev/null
+++ b/meter_test.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestStorageBytesForDB(t *testing.T) {
+ dir := t.TempDir()
+ db := filepath.Join(dir, "space.11ch.at.db")
+ write := func(path string, n int) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, make([]byte, n), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write(db, 1000)
+ write(db+"-wal", 200)
+ write(filepath.Join(dir, "space.11ch.at-blobs", "aa"), 5000)
+ write(filepath.Join(dir, "space.11ch.at-blobs", "nested", "bb"), 300)
+ // A sibling instance's files must not leak into the count.
+ write(filepath.Join(dir, "other.11ch.at.db"), 9999)
+
+ if got := storageBytesForDB(db); got != 6500 {
+ t.Errorf("storageBytesForDB = %d, want 6500 (db+wal+blobs)", got)
+ }
+ // Missing everything → 0, no error.
+ if got := storageBytesForDB(filepath.Join(dir, "absent.db")); got != 0 {
+ t.Errorf("absent instance = %d, want 0", got)
+ }
+}
+
+func TestMeterOnceRecordsReadings(t *testing.T) {
+ db := openTestCloudDB(t)
+ idA := insertSpaceRow(t, db, "a.11ch.at", 1000)
+ idB := insertSpaceRow(t, db, "b.11ch.at", 1000)
+ // A pending create (NULL host) is never swept.
+ if _, err := db.sql.Exec(`INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL,'c','r',1)`); err != nil {
+ t.Fatal(err)
+ }
+
+ bytesByHost := map[string]int64{"a.11ch.at": 12345, "b.11ch.at": 0}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ h := r.PathValue("host")
+ if h == "b.11ch.at" {
+ w.WriteHeader(http.StatusInternalServerError) // stats down for b
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[h]})
+ })
+ sock := fakeControl(t, mux.ServeHTTP)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"}
+
+ if metered := s.meterOnce(); metered != 1 {
+ t.Fatalf("metered = %d, want 1 (a recorded, b skipped)", metered)
+ }
+ spaces, err := db.listSpaces("c", "r")
+ if err != nil {
+ t.Fatal(err)
+ }
+ byHost := map[string]Space{}
+ for _, sp := range spaces {
+ byHost[sp.Host] = sp
+ }
+ if got := byHost["a.11ch.at"]; got.StorageBytes != 12345 || got.MeteredAt == 0 {
+ t.Errorf("a = %+v, want 12345 bytes with a timestamp", got)
+ }
+ if got := byHost["b.11ch.at"]; got.StorageBytes != 0 || got.MeteredAt != 0 {
+ t.Errorf("b = %+v, want untouched (stats error is a skip)", got)
+ }
+ _ = idA
+ _ = idB
+
+ // A later sweep refreshes the reading, never accumulates.
+ bytesByHost["a.11ch.at"] = 200
+ before := time.Now().Unix()
+ s.meterOnce()
+ spaces, _ = db.listSpaces("c", "r")
+ for _, sp := range spaces {
+ if sp.Host == "a.11ch.at" && (sp.StorageBytes != 200 || sp.MeteredAt < before) {
+ t.Errorf("refresh: %+v, want 200 bytes, fresh timestamp", sp)
+ }
+ }
+}
diff --git a/reaper.go b/reaper.go
index 1636cb9..b4741da 100644
--- a/reaper.go
+++ b/reaper.go
@@ -148,31 +148,35 @@ func runTrashPurge(keep time.Duration) {
// ---- the reaper (cloud-side) ---------------------------------------------------
-// controlStatus asks the control daemon whether a space was ever set up, plus
-// the member count and whether it has any content — the reaper's guards.
-func (s *cloudServer) controlStatus(host string) (members int, setupComplete, hasContent bool, err error) {
+// instanceStats is what the control daemon reports about one space: the
+// reaper's guards plus the storage meter's byte count (meter.go).
+type instanceStats struct {
+ Members int `json:"members"`
+ SetupComplete bool `json:"setup_complete"`
+ HasContent bool `json:"has_content"`
+ StorageBytes int64 `json:"storage_bytes"`
+}
+
+// controlStats asks the control daemon for a space's stats.
+func (s *cloudServer) controlStats(host string) (instanceStats, error) {
+ var st instanceStats
req, err := http.NewRequest("GET", "http://control/instances/"+host+"/stats", nil)
if err != nil {
- return 0, false, false, err
+ return st, err
}
req.Header.Set("Authorization", "Bearer "+s.controlToken)
resp, err := s.control.Do(req)
if err != nil {
- return 0, false, false, err
+ return st, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return 0, false, false, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode)
- }
- var body struct {
- Members int `json:"members"`
- SetupComplete bool `json:"setup_complete"`
- HasContent bool `json:"has_content"`
+ return st, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode)
}
- if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
- return 0, false, false, err
+ if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
+ return st, err
}
- return body.Members, body.SetupComplete, body.HasContent, nil
+ return st, nil
}
// reapLoop sweeps every interval; enabled only when reapAfter>0.
@@ -207,12 +211,12 @@ func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) {
return 0, 0
}
for _, c := range cands {
- members, setupComplete, hasContent, err := s.controlStatus(c.Host)
+ st, err := s.controlStats(c.Host)
if err != nil {
log.Printf("cloud: reaper: skip %s (stats unavailable): %v", c.Host, err)
continue
}
- if setupComplete || members > 1 || hasContent {
+ if st.SetupComplete || st.Members > 1 || st.HasContent {
kept++
continue // set up, someone else joined, or has content — never reap
}