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>
5 files changed,
+238
−0
cloud.go+2 −0cloud_db.go+40 −0demo.go+87 −0demo_test.go+97 −0docs/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 |