eleven / messenger Public

server: demo claim — POST /api/instances/{host}/claim converts a demo into the account's free space (#975)

The demo lifecycle's happy exit (docs/demo-spaces.md): ownership transfers
and entitlement flips demo->free in one guarded UPDATE (racing claims or the
reaper lose cleanly); the claim consumes the account's free-quota slot with
the create path's courtesy self-reclaim; expired demos answer 410
demo_expired (past-TTL is dead by contract even before the sweep); the
instance's entitlement env + free cap + replication class re-push. Claims
are never pool-gated: the space's spend was already counted as a demo.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c649c081776e921263958fba74df0ef176e19932 parent ae25739
5 files changed, +238 −0
  • cloud.go +2 −0
  • cloud_db.go +40 −0
  • demo.go +87 −0
  • demo_test.go +97 −0
  • docs/cloud-provisioner.md +12 −0
diff --git a/cloud.go b/cloud.go
index 3b154f9..7e9a3d6 100644
--- a/cloud.go
+++ b/cloud.go
@@ -111,6 +111,8 @@ func (s *cloudServer) handler() http.Handler {
// Billing (optional — 501 unless Stripe is configured; see billing.go). The
// webhook is signature-gated rather than token-gated: Stripe calls it.
mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler))
+ // Claiming a demo (demo.go) — additive, so caller-token gated like creation.
+ mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler))
mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler))
mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler)
// Usage ingest (#857): meter-token-gated, not caller-gated — an instance
diff --git a/cloud_db.go b/cloud_db.go
index 8c52704..8265404 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -892,3 +892,43 @@ func (db *cloudDB) findSpaceBySubscription(subscriptionID string) (int64, bool,
}
return id, true, nil
}
+
+// spaceByHost reads the claim path's view of one space (demo.go). ok=false:
+// no such finalized host.
+func (db *cloudDB) spaceByHost(host string) (id int64, entitlement string, createdAt int64, ok bool, err error) {
+ err = db.sql.QueryRow(
+ `SELECT id, entitlement, created_at FROM spaces WHERE host = ?`, host,
+ ).Scan(&id, &entitlement, &createdAt)
+ if err == sql.ErrNoRows {
+ return 0, "", 0, false, nil
+ }
+ if err != nil {
+ return 0, "", 0, false, err
+ }
+ return id, entitlement, createdAt, true, nil
+}
+
+// claimDemoSpace converts one demo into the claiming account's free space —
+// ownership transfer + entitlement flip in one guarded UPDATE, so a racing
+// claim (or the reaper's ledger delete) loses cleanly: rows affected 0 means
+// the row is no longer a claimable demo.
+func (db *cloudDB) claimDemoSpace(id int64, callerID, requester string) (bool, error) {
+ res, err := db.sql.Exec(
+ `UPDATE spaces SET caller_id = ?, requester = ?, entitlement = ? WHERE id = ? AND entitlement = ?`,
+ callerID, requester, entFree, id, entDemo)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n == 1, err
+}
+
+// countFreeSpaces counts an account's free spaces — the claim path's quota
+// check (allocateSpace runs the same count inside its own transaction).
+func (db *cloudDB) countFreeSpaces(callerID, requester string) (int, error) {
+ var n int
+ err := db.sql.QueryRow(
+ `SELECT COUNT(*) FROM spaces WHERE caller_id = ? AND requester = ? AND entitlement = 'free'`,
+ callerID, requester).Scan(&n)
+ return n, err
+}
diff --git a/demo.go b/demo.go
index 3f655ce..bcf2413 100644
--- a/demo.go
+++ b/demo.go
@@ -23,8 +23,12 @@ package main
// from the first second.
import (
+ "encoding/json"
+ "fmt"
+ "io"
"log"
"net/http"
+ "strings"
"time"
)
@@ -154,3 +158,86 @@ func (s *cloudServer) demoReapOnce(now time.Time) (reaped int) {
}
return reaped
}
+
+// claimDemoHandler: POST /api/instances/{host}/claim — convert a demo into
+// the claiming account's free space (docs/demo-spaces.md, the happy exit).
+// Ownership TRANSFERS: a demo belongs to a one-shot anonymous requester until
+// someone signs in and claims it; from then on it's an ordinary free space —
+// it counts against the account's quota, the demo reaper no longer sees it
+// (the entitlement flip is the TTL clear), and the free tier's rules apply.
+// No pool gate here: the space's spend was already counted while it was a
+// demo, so a claim adds no net-new cost — refusing one at red would only
+// destroy data the pool has already paid for.
+func (s *cloudServer) claimDemoHandler(w http.ResponseWriter, r *http.Request) {
+ callerID := r.Context().Value(callerKey{}).(string)
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ var req struct {
+ Requester string `json:"requester"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+ return
+ }
+ req.Requester = strings.TrimSpace(req.Requester)
+ if req.Requester == "" || len(req.Requester) > 128 {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)")
+ return
+ }
+
+ id, entitlement, createdAt, ok, err := s.db.spaceByHost(host)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
+ return
+ }
+ if !ok {
+ writeControlErr(w, http.StatusNotFound, "not_found", "no such space")
+ return
+ }
+ if entitlement != entDemo {
+ writeControlErr(w, http.StatusConflict, "not_demo", "this space isn't a demo")
+ return
+ }
+ // Racing the reaper: a demo past its lifetime is already dead by
+ // contract, even if the sweep hasn't collected it yet.
+ if s.demoTTL > 0 && time.Now().Unix() > createdAt+int64(s.demoTTL/time.Second) {
+ writeControlErr(w, http.StatusGone, "demo_expired", "this demo's 24 hours are up — it can no longer be claimed")
+ return
+ }
+ // The claim consumes the account's free slot, same rules as a create —
+ // including the create path's courtesy reclaim of their own dead weight.
+ if n, err := s.db.countFreeSpaces(callerID, req.Requester); err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
+ return
+ } else if n >= s.quota {
+ if !s.reclaimOwnSpace(callerID, req.Requester) {
+ noun := "spaces"
+ if s.quota == 1 {
+ noun = "space"
+ }
+ writeControlErr(w, http.StatusConflict, "quota_exceeded",
+ fmt.Sprintf("this account already has %d free %s (the limit) — upgrading a space frees its slot, and so does deleting one you no longer use", s.quota, noun))
+ return
+ }
+ }
+
+ claimed, err := s.db.claimDemoSpace(id, callerID, req.Requester)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
+ return
+ }
+ if !claimed {
+ // Lost a race — another claim or the reaper got there first.
+ writeControlErr(w, http.StatusConflict, "not_demo", "this demo was just claimed or expired")
+ return
+ }
+ log.Printf("cloud: demo %s (id %d) claimed by caller %.8s… — now a free space", host, id, callerID)
+ // Flip the instance's own view: entitlement env + the free tier's storage
+ // cap, and record the free replication class for when the space is later
+ // flipped onto the activator (hibernation/backups are an operator or
+ // follow-up concern — the claim itself never restarts anything).
+ s.pushQuota(id, host, entFree, "")
+ s.pushReplication(host, entFree, "")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "host": host, "url": "https://" + host, "entitlement": entFree,
+ })
+}
diff --git a/demo_test.go b/demo_test.go
index 368735b..5d3e121 100644
--- a/demo_test.go
+++ b/demo_test.go
@@ -318,3 +318,100 @@ func TestControldDeleteDiscard(t *testing.T) {
t.Fatal("default delete did not archive to trash")
}
}
+
+// ---- claiming (the happy exit) ----------------------------------------------
+
+func claimHost(t *testing.T, srv *httptest.Server, token, host, requester string) (*http.Response, map[string]any) {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"requester": requester})
+ req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/claim", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ var m map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&m)
+ return resp, m
+}
+
+func TestClaimDemoSpace(t *testing.T) {
+ srv, s := newTestCloud(t, okControl(t))
+ s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
+ token := mintCaller(t, srv, "home")
+
+ // A fresh demo, claimed: entitlement flips, ownership transfers.
+ resp := createKind(t, srv, token, "demo-oneshot", "demo")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("demo create: HTTP %d", resp.StatusCode)
+ }
+ host, _ := decodeBody(t, resp)["host"].(string)
+ resp, body := claimHost(t, srv, token, host, "alice")
+ if resp.StatusCode != http.StatusOK || body["entitlement"] != "free" {
+ t.Fatalf("claim: HTTP %d %v", resp.StatusCode, body)
+ }
+ var ent, req string
+ if err := s.db.sql.QueryRow(`SELECT entitlement, requester FROM spaces WHERE host = ?`, host).Scan(&ent, &req); err != nil {
+ t.Fatal(err)
+ }
+ if ent != entFree || req != "alice" {
+ t.Fatalf("row after claim = %s/%s, want free/alice", ent, req)
+ }
+
+ // The claimed space left the demo lifecycle: the reaper never sees it…
+ cands, err := s.db.demosPastTTL(time.Now().Add(48 * time.Hour).Unix())
+ if err != nil || len(cands) != 0 {
+ t.Fatalf("claimed space still a reap candidate: %v %v", cands, err)
+ }
+ // …and it consumes the quota: at quota 1, alice can't create another free space.
+ s.quota = 1
+ if resp := createKind(t, srv, token, "alice", ""); resp.StatusCode != http.StatusConflict {
+ t.Fatalf("free create after claim at quota 1: HTTP %d, want 409", resp.StatusCode)
+ }
+
+ // A second claim of the same host: no longer a demo.
+ resp, body = claimHost(t, srv, token, host, "bob")
+ if resp.StatusCode != http.StatusConflict || body["error"] != "not_demo" {
+ t.Fatalf("re-claim: HTTP %d %v", resp.StatusCode, body)
+ }
+}
+
+func TestClaimRefusals(t *testing.T) {
+ srv, s := newTestCloud(t, okControl(t))
+ s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
+ token := mintCaller(t, srv, "home")
+
+ // Unknown host.
+ if resp, body := claimHost(t, srv, token, "nope.example.com", "alice"); resp.StatusCode != http.StatusNotFound || body["error"] != "not_found" {
+ t.Fatalf("unknown host: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // An expired demo can't be claimed even before the sweep collects it.
+ seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix())
+ if resp, body := claimHost(t, srv, token, "stale.example.com", "alice"); resp.StatusCode != http.StatusGone || body["error"] != "demo_expired" {
+ t.Fatalf("expired claim: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // A free space isn't claimable.
+ if resp := createKind(t, srv, token, "carol", ""); resp.StatusCode != http.StatusCreated {
+ t.Fatal("free create failed")
+ }
+ var freeHost string
+ if err := s.db.sql.QueryRow(`SELECT host FROM spaces WHERE requester = 'carol'`).Scan(&freeHost); err != nil {
+ t.Fatal(err)
+ }
+ if resp, body := claimHost(t, srv, token, freeHost, "alice"); resp.StatusCode != http.StatusConflict || body["error"] != "not_demo" {
+ t.Fatalf("claim a free space: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // A quota-full claimer is refused with the standard message.
+ s.quota = 1
+ if resp := createKind(t, srv, token, "dave", ""); resp.StatusCode != http.StatusCreated {
+ t.Fatal("dave's free create failed")
+ }
+ seedDemo(t, s.db, "fresh.example.com", time.Now().Unix())
+ if resp, body := claimHost(t, srv, token, "fresh.example.com", "dave"); resp.StatusCode != http.StatusConflict || body["error"] != "quota_exceeded" {
+ t.Fatalf("quota-full claim: HTTP %d %v", resp.StatusCode, body)
+ }
+}
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 7e01afd..a366700 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -376,6 +376,18 @@ client IP comes from the router's `X-Forwarded-For` stamp.
where it works. No UI sends any of this yet — the surface (home's "Try it
now" + the countdown/claim banner) is the next slice.
+**Claiming** (`POST /api/instances/{host}/claim`, caller-token): the happy
+exit — a signed-in account adopts a demo, transferring ownership and
+flipping `entitlement` to `free` in one guarded UPDATE (a racing claim or
+reaper loses cleanly). The claim consumes the account's free-quota slot
+(same rules as a create, including the courtesy self-reclaim), refuses
+expired demos (`410 demo_expired` — past-TTL is dead by contract even
+before the sweep), and re-pushes the instance's entitlement env + free
+storage cap + replication class. Claims are never pool-gated: the space's
+spend was already counted while it was a demo. Flipping the claimed space
+onto the activator (hibernation + S3 backups) is an operator/follow-up
+step — the claim itself never restarts anything.
+
## The storage meter (#668 slice 1)
Every sweep interval (`LCHAT_CLOUD_METER_INTERVAL_HOURS`, default 6, 0