eleven / messenger Public

server: storage caps — per-tier ceilings pushed into instance quotas (#561, insurance) (#672)

LCHAT_CLOUD_STORAGE_CAPS maps tier keys (+ 'free') to caps; the cloud
pushes each ledger space's cap into its instance env (controld POST
/instances/{host}/quota upserts LCHAT_BLOB_QUOTA_BYTES, try-restarts a
unit-managed instance; a hibernating one applies at next wake). The
instance's existing 507 upload guard enforces. Pushes on tier-change
webhooks + creation; the meter sweep reconciles drift. Unset = off;
operator instances are never in the ledger, never touched. Home shows
'X of Y stored'; caps recorded in tier-economics.md (box-sized until
attachments move to R2).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 87af98bfb91e2e180b1e5c97aa4ab59fa1815284 parent a642989
10 files changed, +461 −33
  • billing.go +2 −0
  • changelog/2026-07-11-storage-allowance.md +5 −0
  • cloud.go +17 −7
  • cloud_db.go +50 −13
  • controld.go +54 −0
  • docs/cloud-provisioner.md +17 −4
  • docs/tier-economics.md +19 −3
  • home/web/static/home.js +4 −2
  • meter.go +156 −4
  • meter_test.go +137 −0
diff --git a/billing.go b/billing.go
index cc53133..e829015 100644
--- a/billing.go
+++ b/billing.go
@@ -303,6 +303,7 @@ func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error {
switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription, obj.Metadata["tier"]); err {
case nil:
log.Printf("cloud: space id %d upgraded (checkout complete, tier %s)", id, obj.Metadata["tier"])
+ s.pushQuotaByID(id) // raise the space's storage cap to its new tier
return nil
case sql.ErrNoRows:
log.Printf("cloud: webhook checkout for unknown space id %d — ignoring", id)
@@ -365,6 +366,7 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage
switch err := s.db.setEntitlement(id, ent, paidUntil); err {
case nil:
log.Printf("cloud: space id %d entitlement → %s (subscription %s)", id, ent, obj.Status)
+ s.pushQuotaByID(id) // entitlement moved — re-apply the matching storage cap
return nil
case sql.ErrNoRows:
log.Printf("cloud: webhook %s for unknown space id %d — ignoring", evType, id)
diff --git a/changelog/2026-07-11-storage-allowance.md b/changelog/2026-07-11-storage-allowance.md
new file mode 100644
index 0000000..848729c
--- /dev/null
+++ b/changelog/2026-07-11-storage-allowance.md
@@ -0,0 +1,5 @@
+🤖 Spaces now have a storage allowance, shown next to your usage on your
+Eleven home page ("1.2 GB of 5 GB stored"). If a space fills up, new photo
+and file uploads pause — nothing is ever deleted, and messages keep working —
+and upgrading the space raises its allowance. Today's allowances are
+deliberately roomy for how spaces are actually used, and they'll grow.
diff --git a/cloud.go b/cloud.go
index 77f9e83..eb89b2c 100644
--- a/cloud.go
+++ b/cloud.go
@@ -75,13 +75,14 @@ type cloudServer struct {
db *cloudDB
adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke
controlToken string
- control *http.Client // dials the control daemon's unix socket
- baseDomain string // spaces land at <slug>.<baseDomain>
- quota int // max spaces per (caller, requester)
- reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
- reapDryRun bool // log what the reaper would reclaim, delete nothing
- reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
- billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
+ control *http.Client // dials the control daemon's unix socket
+ baseDomain string // spaces land at <slug>.<baseDomain>
+ quota int // max spaces per (caller, requester)
+ reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
+ reapDryRun bool // log what the reaper would reclaim, delete nothing
+ reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
+ billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
+ storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement
}
func (s *cloudServer) handler() http.Handler {
@@ -184,6 +185,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
log.Printf("cloud: ledger finalize for %s (id %d) failed: %v", host, id, err)
}
log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID)
+ s.pushQuota(id, host, entFree, "") // insurance cap for a fresh free space (no-op unless caps configured)
writeJSON(w, http.StatusCreated, map[string]any{
"host": host,
"url": body["url"],
@@ -397,6 +399,10 @@ func runCloud(args []string) {
if err != nil {
die("cloud: open db: %v", err)
}
+ storageCaps, err := parseStorageCaps(os.Getenv("LCHAT_CLOUD_STORAGE_CAPS"))
+ if err != nil {
+ die("cloud: LCHAT_CLOUD_STORAGE_CAPS: %v", err)
+ }
billing, err := newStripeBilling(
os.Getenv("LCHAT_STRIPE_SECRET_KEY"),
os.Getenv("LCHAT_STRIPE_WEBHOOK_SECRET"),
@@ -418,6 +424,10 @@ func runCloud(args []string) {
reapDryRun: *reapDryRun,
reapMax: *reapMax,
billing: billing,
+ storageCaps: storageCaps,
+ }
+ if len(storageCaps) > 0 {
+ log.Printf("cloud: storage caps on (%d entries)", len(storageCaps))
}
if billing != nil {
log.Printf("cloud: billing on (%d tiers)", len(billing.tiers))
diff --git a/cloud_db.go b/cloud_db.go
index efc871d..fb55964 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -167,12 +167,21 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n
// 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 {
+ if has, err := db.spacesHasColumn("storage_bytes"); err != nil {
return err
- }
- _, err := db.sql.Exec(`
+ } else if !has {
+ if _, 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;`)
+ALTER TABLE spaces ADD COLUMN metered_at INTEGER NOT NULL DEFAULT 0;`); err != nil {
+ return err
+ }
+ }
+ // quota_bytes landed one change later (enforcement): what the cloud last
+ // pushed into the instance's LCHAT_BLOB_QUOTA_BYTES. 0 = never pushed.
+ if has, err := db.spacesHasColumn("quota_bytes"); err != nil || has {
+ return err
+ }
+ _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN quota_bytes INTEGER NOT NULL DEFAULT 0`)
return err
}
@@ -354,16 +363,18 @@ type Space struct {
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.
+ // metered), when it was taken, and the cap last pushed to the instance
+ // (0 = none — the instance's compiled default applies).
StorageBytes int64 `json:"storage_bytes,omitempty"`
MeteredAt int64 `json:"metered_at,omitempty"`
+ StorageCap int64 `json:"storage_cap,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, storage_bytes, metered_at FROM spaces
+ `SELECT host, created_at, entitlement, tier, paid_until, storage_bytes, metered_at, quota_bytes FROM spaces
WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`,
callerID, requester,
)
@@ -374,7 +385,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, &s.StorageBytes, &s.MeteredAt); err != nil {
+ if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.StorageBytes, &s.MeteredAt, &s.StorageCap); err != nil {
return nil, err
}
s.URL = "https://" + s.Host
@@ -428,24 +439,50 @@ func (db *cloudDB) setEntitlement(id int64, ent string, paidUntil int64) error {
return nil
}
+// meterRow is one space in the meter's sweep set — enough billing state to
+// reconcile its quota alongside the byte reading.
+type meterRow struct {
+ ID int64
+ Host string
+ Entitlement string
+ Tier string
+ QuotaBytes int64
+}
+
// 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`)
+func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) {
+ rows, err := db.sql.Query(
+ `SELECT id, host, entitlement, tier, quota_bytes FROM spaces WHERE host IS NOT NULL ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
- var out []reapCandidate
+ var out []meterRow
for rows.Next() {
- var c reapCandidate
- if err := rows.Scan(&c.ID, &c.Host, &c.CreatedAt); err != nil {
+ var m meterRow
+ if err := rows.Scan(&m.ID, &m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes); err != nil {
return nil, err
}
- out = append(out, c)
+ out = append(out, m)
}
return out, rows.Err()
}
+// spaceBillingByID reads the billing state a quota push needs.
+func (db *cloudDB) spaceBillingByID(id int64) (m meterRow, err error) {
+ m.ID = id
+ err = db.sql.QueryRow(
+ `SELECT COALESCE(host,''), entitlement, tier, quota_bytes FROM spaces WHERE id = ?`, id,
+ ).Scan(&m.Host, &m.Entitlement, &m.Tier, &m.QuotaBytes)
+ return m, err
+}
+
+// setQuotaBytes records what the cloud last pushed into an instance's env.
+func (db *cloudDB) setQuotaBytes(id, quota int64) error {
+ _, err := db.sql.Exec(`UPDATE spaces SET quota_bytes = ? WHERE id = ?`, quota, id)
+ return 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)
diff --git a/controld.go b/controld.go
index 05451b1..59bddc5 100644
--- a/controld.go
+++ b/controld.go
@@ -26,6 +26,7 @@ import (
"net/http"
"os"
"os/user"
+ "path/filepath"
"strconv"
"strings"
"sync"
@@ -100,9 +101,62 @@ func (c *controld) handler() http.Handler {
mux.HandleFunc("POST /instances", c.auth(c.createHandler))
mux.HandleFunc("DELETE /instances/{host}", c.auth(c.deleteHandler))
mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler))
+ mux.HandleFunc("POST /instances/{host}/quota", c.auth(c.quotaHandler))
return mux
}
+// 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.
+func (c *controld) quotaHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ if !validHost(host) {
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname")
+ return
+ }
+ var req struct {
+ Bytes int64 `json:"bytes"`
+ }
+ 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
+ }
+ envPath := filepath.Join(instanceDataDir, host+".env")
+ if err := setInstanceQuota(envPath, req.Bytes); err != nil {
+ if os.IsNotExist(err) {
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
+ return
+ }
+ log.Printf("controld: quota %s: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "quota_failed", err.Error())
+ return
+ }
+ // The router (service user) reads a hibernating instance's env at wake; the
+ // file was just rewritten as root, so hand it back (same rule as creation).
+ if err := matchOwner(envPath, instanceDataDir); err != nil {
+ log.Printf("controld: quota %s: env ownership: %v", host, err)
+ }
+ restarted := false
+ hibernating := false
+ if c.reg != nil {
+ if rt, err := c.reg.get(host); err == nil && rt != nil && rt.Hibernate {
+ hibernating = true
+ }
+ }
+ if !hibernating {
+ // try-restart: bounce only if it's running (data-safe; seconds).
+ if err := systemctl("try-restart", "lchat@"+host); err == nil {
+ restarted = true
+ } else {
+ log.Printf("controld: quota %s: restart: %v (cap applies on next start)", host, err)
+ }
+ }
+ log.Printf("controld: quota %s → %d bytes (restarted=%v)", host, req.Bytes, restarted)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": restarted})
+}
+
// statsHandler reports the reaper's guards for a space — setup_complete, member
// count, and has_content (any message or thread). Read-only against the live
// instance DB. validHost guards the DB path against traversal before it's joined
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index e594436..39a0b01 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -293,10 +293,23 @@ 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).
+space listing, and home shows it to the member ("2.1 GB stored"). Bytes of
+ciphertext are exactly the kind of thing this ledger is allowed to know
+(#562: price only what ciphertext exposes).
+
+**Storage caps (#561, insurance-grade).** With `LCHAT_CLOUD_STORAGE_CAPS` set
+("free=2GB,family=5GB,…" — tier keys from the price book plus `free`), the
+cloud pushes each ledger space's cap into its instance env
+(`LCHAT_BLOB_QUOTA_BYTES`, via controld `POST /instances/{host}/quota`), where
+the instance's own upload guard enforces it: uploads past the cap answer 507,
+nothing is ever deleted, and text messaging is never gated. Pushes happen on
+tier changes (checkout/subscription webhooks), at creation, and the meter
+sweep reconciles drift — self-healing, best-effort at every step. A
+unit-managed instance is bounced (`try-restart`, seconds) so the cap applies
+now; a hibernating one picks it up at next wake — never wake a space to limit
+it. Unset = no enforcement, and operator-run instances (not in this ledger)
+are never touched. Caps are sized to the box today (see
+`tier-economics.md`); they rise when attachments move off-box (#550).
## Billing (Stripe) — tiers, quota exemption, no metering
diff --git a/docs/tier-economics.md b/docs/tier-economics.md
index 838ef4c..8cf6bec 100644
--- a/docs/tier-economics.md
+++ b/docs/tier-economics.md
@@ -33,9 +33,25 @@ Living doc: the *formulas* are the contract; the *numbers* are a snapshot
| Business | €99/month or €999/year | toggle |
What paying buys today: the space stops counting toward the account's
-three-space creation limit, and it is never reclaimed as unused. Per-tier
-powers (storage quotas, member counts) arrive with #561 and will be recorded
-here when they do.
+three-space creation limit, it is never reclaimed as unused, and it carries a
+larger storage allowance (below).
+
+## Storage allowances (as of 2026-07-11 — insurance-grade)
+
+| Tier | Included storage |
+|---|---|
+| Free | 2 GB |
+| Family | 5 GB |
+| Community & Friends | 10 GB |
+| Business | 25 GB |
+
+These are deliberately **conservative, box-sized caps** — the shared machine
+currently holds ~38 GB of disk, so today's allowances are insurance against
+any one space exhausting it, not the product's ambition. The steady-state
+allowances this document's break-evens support (~20 GB Family, ~100–200 GB
+C&F, ~1–2 TB Business) arrive when attachment storage moves off-box to R2
+(#550); raising a cap is one config change. Uploads past an allowance are
+refused (nothing is ever deleted, and messages always keep working).
## Input rates (snapshot, 2026-07-11)
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 04d1876..ccef510 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -467,12 +467,14 @@ function serverRow(s, sortable) {
}
// The storage meter's reading (spaces this account created; 0 = not yet
- // metered). Transparency first — quotas come later (#561).
+ // metered), with the space's storage allowance when one is set.
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";
+ usage.textContent = metered.storage_cap > 0
+ ? fmtBytes(metered.storage_bytes) + " of " + fmtBytes(metered.storage_cap) + " stored"
+ : fmtBytes(metered.storage_bytes) + " stored";
main.append(usage);
}
diff --git a/meter.go b/meter.go
index 6eb7b33..626b652 100644
--- a/meter.go
+++ b/meter.go
@@ -1,8 +1,9 @@
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).
+// The storage meter (#668 slice 1) and its enforcement (#561, insurance-grade):
+// how many bytes each cloud-provisioned space occupies, measured server-side
+// and shown to members — plus, when caps are configured, per-tier storage
+// ceilings pushed into each instance's own LCHAT_BLOB_QUOTA_BYTES guard.
//
// Metering only what ciphertext exposes: an instance's bytes are its DB files
// plus its attachment blobs, all opaque. Nothing here reads content, and
@@ -14,9 +15,14 @@ package main
// ledger), joined by the existing stats endpoint.
import (
+ "bytes"
+ "encoding/json"
+ "fmt"
"log"
+ "net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
)
@@ -75,7 +81,10 @@ func (s *cloudServer) meterLoop(interval time.Duration) {
// 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.
+// shows the staleness); the sweep never fails as a whole. When storage caps
+// are configured, the sweep also RECONCILES quotas: any space whose pushed
+// quota doesn't match its tier's cap gets a fresh push — the self-healing
+// backstop behind the event-driven pushes (webhooks, creation).
func (s *cloudServer) meterOnce() (metered int) {
spaces, err := s.db.allFinalizedSpaces()
if err != nil {
@@ -94,9 +103,152 @@ func (s *cloudServer) meterOnce() (metered int) {
continue
}
metered++
+ if want := s.capFor(sp.Entitlement, sp.Tier); want > 0 && want != sp.QuotaBytes {
+ s.pushQuota(sp.ID, sp.Host, sp.Entitlement, sp.Tier)
+ }
}
if metered > 0 {
log.Printf("cloud: meter: refreshed %d space(s)", metered)
}
return metered
}
+
+// ---- storage caps (#561 via #668): enforcement as insurance ---------------------
+//
+// The instance already refuses uploads past LCHAT_BLOB_QUOTA_BYTES (507,
+// nothing deleted, text messaging never gated). Enforcement is therefore pure
+// wiring: map each tier to a cap, and push the cap into the instance's env
+// when its tier changes. Caps are config (LCHAT_CLOUD_STORAGE_CAPS); no
+// config = no enforcement anywhere, and self-hosted or operator-run instances
+// are never touched (they're not in this ledger).
+
+// parseByteSize reads "25GB" / "2TB" / "500MB" / plain bytes. Decimal units —
+// matching how storage is priced and displayed.
+func parseByteSize(v string) (int64, error) {
+ v = strings.TrimSpace(strings.ToUpper(v))
+ mult := int64(1)
+ for _, u := range []struct {
+ suffix string
+ mult int64
+ }{{"TB", 1e12}, {"GB", 1e9}, {"MB", 1e6}, {"KB", 1e3}} {
+ if strings.HasSuffix(v, u.suffix) {
+ v, mult = strings.TrimSpace(strings.TrimSuffix(v, u.suffix)), u.mult
+ break
+ }
+ }
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || n <= 0 {
+ return 0, fmt.Errorf("bad size %q", v)
+ }
+ return n * mult, nil
+}
+
+// parseStorageCaps reads LCHAT_CLOUD_STORAGE_CAPS: "free=10GB,family=25GB,…" —
+// keys are tier keys from the price book, plus "free" for unpaid spaces.
+func parseStorageCaps(v string) (map[string]int64, error) {
+ if strings.TrimSpace(v) == "" {
+ return nil, nil
+ }
+ caps := map[string]int64{}
+ for _, pair := range strings.Split(v, ",") {
+ k, val, ok := strings.Cut(strings.TrimSpace(pair), "=")
+ if !ok || strings.TrimSpace(k) == "" {
+ return nil, fmt.Errorf("bad entry %q (want key=size)", pair)
+ }
+ n, err := parseByteSize(val)
+ if err != nil {
+ return nil, fmt.Errorf("cap %q: %v", k, err)
+ }
+ caps[strings.TrimSpace(k)] = n
+ }
+ return caps, nil
+}
+
+// capFor maps a space's billing state to its storage cap. 0 = no cap to push
+// (caps unconfigured, or a paid tier with no entry — never guess a limit).
+func (s *cloudServer) capFor(entitlement, tier string) int64 {
+ if len(s.storageCaps) == 0 {
+ return 0
+ }
+ if entitlement == entPaid {
+ return s.storageCaps[tier]
+ }
+ return s.storageCaps["free"]
+}
+
+// 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})
+ req, err := http.NewRequest("POST", "http://control/instances/"+host+"/quota", bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("control quota %s: HTTP %d", host, resp.StatusCode)
+ }
+ return nil
+}
+
+// pushQuota applies a space's tier cap to its instance, recording what was
+// pushed. Best-effort by design: a failed push logs and stands down — the
+// meter sweep reconciles it on the next pass.
+func (s *cloudServer) pushQuota(id int64, host, entitlement, tier string) {
+ cap := s.capFor(entitlement, tier)
+ if cap <= 0 {
+ return
+ }
+ if err := s.controlSetQuota(host, cap); err != nil {
+ log.Printf("cloud: quota push %s: %v (sweep will retry)", host, err)
+ return
+ }
+ if err := s.db.setQuotaBytes(id, cap); err != nil {
+ log.Printf("cloud: quota record %s: %v", host, err)
+ return
+ }
+ log.Printf("cloud: quota %s → %d bytes (%s)", host, cap, entitlementLabel(entitlement, tier))
+}
+
+func entitlementLabel(entitlement, tier string) string {
+ if entitlement == entPaid {
+ return "tier " + tier
+ }
+ return "free"
+}
+
+// ---- 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 {
+ env, err := readEnvFile(envPath)
+ if err != nil {
+ return err
+ }
+ if quota <= 0 {
+ delete(env, "LCHAT_BLOB_QUOTA_BYTES")
+ } else {
+ env["LCHAT_BLOB_QUOTA_BYTES"] = strconv.FormatInt(quota, 10)
+ }
+ return writeEnvFile(envPath, env)
+}
+
+// pushQuotaByID looks a space's billing state up and applies its cap —
+// the webhook-side entry point (events carry ids, not rows).
+func (s *cloudServer) pushQuotaByID(id int64) {
+ if len(s.storageCaps) == 0 {
+ return
+ }
+ m, err := s.db.spaceBillingByID(id)
+ if err != nil || m.Host == "" {
+ return // pending create or gone — the sweep reconciles survivors
+ }
+ s.pushQuota(id, m.Host, m.Entitlement, m.Tier)
+}
diff --git a/meter_test.go b/meter_test.go
index 3232853..a245f80 100644
--- a/meter_test.go
+++ b/meter_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "encoding/json"
"net/http"
"os"
"path/filepath"
@@ -89,3 +90,139 @@ func TestMeterOnceRecordsReadings(t *testing.T) {
}
}
}
+
+func TestParseByteSizeAndCaps(t *testing.T) {
+ for in, want := range map[string]int64{
+ "25GB": 25e9, "2TB": 2e12, "500MB": 5e8, "1024": 1024, "10 GB": 10e9, "1kb": 1000,
+ } {
+ if got, err := parseByteSize(in); err != nil || got != want {
+ t.Errorf("parseByteSize(%q) = %d, %v; want %d", in, got, err, want)
+ }
+ }
+ for _, bad := range []string{"", "-5GB", "0", "GB", "ten GB"} {
+ if _, err := parseByteSize(bad); err == nil {
+ t.Errorf("parseByteSize(%q) accepted, want error", bad)
+ }
+ }
+
+ caps, err := parseStorageCaps("free=10GB, family=25GB,business=2TB")
+ if err != nil || caps["free"] != 10e9 || caps["family"] != 25e9 || caps["business"] != 2e12 {
+ t.Fatalf("caps = %v, %v", caps, err)
+ }
+ if c, err := parseStorageCaps(""); c != nil || err != nil {
+ t.Errorf("empty caps = %v, %v; want nil, nil (enforcement off)", c, err)
+ }
+ if _, err := parseStorageCaps("free=banana"); err == nil {
+ t.Error("garbage cap accepted")
+ }
+}
+
+func TestSetInstanceQuota(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "space.env")
+ 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 {
+ 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" {
+ t.Fatalf("env after set = %v", env)
+ }
+ // Idempotent update, then removal at 0.
+ if err := setInstanceQuota(path, 10e9); err != nil {
+ t.Fatal(err)
+ }
+ env, _ = readEnvFile(path)
+ if env["LCHAT_BLOB_QUOTA_BYTES"] != "10000000000" {
+ t.Fatalf("env after update = %v", env)
+ }
+ if err := setInstanceQuota(path, 0); err != nil {
+ t.Fatal(err)
+ }
+ env, _ = readEnvFile(path)
+ if _, ok := env["LCHAT_BLOB_QUOTA_BYTES"]; ok {
+ t.Fatalf("cap not removed: %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) {
+ t.Errorf("absent env: err = %v, want not-exist", err)
+ }
+}
+
+// quotaControl fakes controld's stats + quota endpoints, recording pushes.
+func quotaControl(t *testing.T, bytesByHost map[string]int64, pushed map[string]int64) string {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[r.PathValue("host")]})
+ })
+ mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Bytes int64 `json:"bytes"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ pushed[r.PathValue("host")] = req.Bytes
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ })
+ return fakeControl(t, mux.ServeHTTP)
+}
+
+// TestQuotaPushOnUpgradeAndSweep pins the enforcement loop: a webhook upgrade
+// pushes the tier's cap immediately; the meter sweep reconciles free spaces
+// and downgrades.
+func TestQuotaPushOnUpgradeAndSweep(t *testing.T) {
+ db := openTestCloudDB(t)
+ paidID := insertSpaceRow(t, db, "paid.11ch.at", 1000)
+ insertSpaceRow(t, db, "free.11ch.at", 1000)
+
+ pushed := map[string]int64{}
+ sock := quotaControl(t, map[string]int64{"paid.11ch.at": 1, "free.11ch.at": 1}, pushed)
+ s := &cloudServer{
+ db: db, control: controlClient(sock), controlToken: "t",
+ storageCaps: map[string]int64{"free": 10e9, "family": 25e9},
+ }
+
+ // The upgrade path (what the checkout webhook calls).
+ if err := db.markSpacePaid(paidID, "cus", "sub", "family"); err != nil {
+ t.Fatal(err)
+ }
+ s.pushQuotaByID(paidID)
+ if pushed["paid.11ch.at"] != 25e9 {
+ t.Fatalf("upgrade push = %d, want 25GB", pushed["paid.11ch.at"])
+ }
+
+ // The sweep reconciles the free space (never pushed) — and doesn't
+ // re-push the paid one (already at its cap).
+ delete(pushed, "paid.11ch.at")
+ s.meterOnce()
+ if pushed["free.11ch.at"] != 10e9 {
+ t.Fatalf("sweep push = %d, want 10GB", pushed["free.11ch.at"])
+ }
+ if _, again := pushed["paid.11ch.at"]; again {
+ t.Error("sweep re-pushed an already-correct quota")
+ }
+
+ // A lapse (subscription deleted → free) re-applies the free cap.
+ if err := db.setEntitlement(paidID, entFree, 0); err != nil {
+ t.Fatal(err)
+ }
+ s.pushQuotaByID(paidID)
+ if pushed["paid.11ch.at"] != 10e9 {
+ t.Fatalf("lapse push = %d, want back to 10GB", pushed["paid.11ch.at"])
+ }
+
+ // No caps configured → nothing pushes anywhere.
+ pushed2 := map[string]int64{}
+ sock2 := quotaControl(t, map[string]int64{"paid.11ch.at": 1, "free.11ch.at": 1}, pushed2)
+ s2 := &cloudServer{db: db, control: controlClient(sock2), controlToken: "t"}
+ s2.meterOnce()
+ s2.pushQuotaByID(paidID)
+ if len(pushed2) != 0 {
+ t.Errorf("unconfigured caps pushed: %v", pushed2)
+ }
+}