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>
10 files changed,
+461
−33
billing.go+2 −0changelog/2026-07-11-storage-allowance.md+5 −0cloud.go+17 −7cloud_db.go+50 −13controld.go+54 −0docs/cloud-provisioner.md+17 −4docs/tier-economics.md+19 −3home/web/static/home.js+4 −2meter.go+156 −4meter_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) |
| + } |
| +} |