package main // Demo spaces, slice 1 (docs/demo-spaces.md): the ledger tier, the // unconditional TTL reap, and no-backups-by-construction. A demo is a real // instance anyone will be able to try without an account, reaped 24 hours // after creation no matter what's in it — the death is the contract, disclosed // at birth. // // This slice ships DARK: nothing sends kind=demo yet (home doesn't offer it, // and the whole path answers demo_unavailable unless LCHAT_DEMO_TTL_HOURS is // set). The abuse gates that make the path safe to expose — per-IP semaphore + // cooldown in home, adaptive proof-of-work — are the next slice; the only // gates here are the live-demo cap and the shared creates_log budget, enough // to bound the dark path. // // No backups is structural, not configured away: controld forces // Hibernate=false for a demo create, so a demo is never activator-managed — // no S3 lease, no litestream replica, no parked prefix, ever (the request-cost // class #836 killed stays killed). The price is that a demo holds a // materialized slot for its 24h life; the live-demo cap bounds how many. And // the reap DISCARDS instead of archiving to /data/trash — the one deliberate // exception to archive-everything, safe because the 24h death was disclosed // from the first second. import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" ) // createDemoSpace is createSpaceHandler's kind=demo branch: no quota, no // self-reclaim — a demo's bounds are its own cap and its TTL. func (s *cloudServer) createDemoSpace(ctx context.Context, w http.ResponseWriter, callerID, requester string) { if s.demoTTL <= 0 { writeControlErr(w, http.StatusNotImplemented, "demo_unavailable", "demo spaces aren't offered here") return } n, err := s.db.countDemos() if err != nil { writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") return } if n >= s.demoMaxLive { writeControlErr(w, http.StatusServiceUnavailable, "demo_busy", "every demo slot is taken right now — they free up quickly, try again in a few minutes") return } id, err := s.db.allocateDemoSpace(callerID, requester) if err != nil { writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") return } // Same fork as the regular create path (cloud.go spaceHostID): the shared // global ledger when configured, else this demo row's cloud.db PK — today's // behaviour unchanged on any box without the shared bucket. The PK still // keys the row for the reaper/claim/finalize; only the address's id moves. hostID, err := s.spaceHostID(ctx, id) if err != nil { s.db.deleteSpaceByID(id) log.Printf("cloud: id ledger allocate for demo id %d: %v", id, err) writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable") return } slug, err := spaceSlug(hostID) if err != nil { s.db.deleteSpaceByID(id) writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") return } host := slug + "." + s.baseDomain status, body, err := s.controlCreate(host, entDemo, "") if err != nil { s.db.deleteSpaceByID(id) log.Printf("cloud: control call for demo %s: %v", host, err) writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable") return } switch { case status == http.StatusCreated: if err := s.db.finalizeSpace(id, host); err != nil { log.Printf("cloud: ledger finalize for demo %s (id %d) failed: %v", host, id, err) } log.Printf("cloud: created demo %s (id %d, caller %.8s…, dies in %s)", host, id, callerID, s.demoTTL) s.pushQuota(id, host, entDemo, "", 0) // the tiny demo cap (no-op unless configured) writeJSON(w, http.StatusCreated, map[string]any{ "host": host, "url": body["url"], "setup_link": body["setup_link"], "kind": entDemo, "expires_at": time.Now().Add(s.demoTTL).Unix(), }) case status == http.StatusServiceUnavailable: s.db.deleteSpaceByID(id) writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) default: s.db.deleteSpaceByID(id) msg, _ := body["message"].(string) log.Printf("cloud: control create demo %s: HTTP %d %s", host, status, msg) writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed") } } // demoReapLoop sweeps expired demos. Hourly and separate from the main // reaper's (default 6h) cadence: the countdown banner promises "24 hours", so // the overshoot should be minutes-to-an-hour, not a workday. func (s *cloudServer) demoReapLoop() { if s.demoTTL <= 0 { return } mode := "" if s.reapDryRun { mode = " (dry-run — logs only, deletes nothing)" } log.Printf("cloud: demo reaper on%s — demos die %s after creation, swept hourly, discarded (no trash archive)", mode, s.demoTTL) for { s.demoReapOnce(time.Now()) time.Sleep(time.Hour) } } // demoReapOnce deletes every demo past its TTL. UNCONDITIONALLY — no stats // call, no guards: content does not protect a demo (deliberate inversion of // the main reaper's three guards, per docs/demo-spaces.md — the death was // disclosed at birth and on a countdown banner ever since). The scope is // bounded by construction: the query selects entitlement='demo' only, so the // worst a bug here can cost is demos dying early. Data is discarded, not // archived (the demo contract's one exception to archive-everything). func (s *cloudServer) demoReapOnce(now time.Time) (reaped int) { cutoff := now.Add(-s.demoTTL).Unix() cands, err := s.db.demosPastTTL(cutoff) if err != nil { log.Printf("cloud: demo reaper: ledger query: %v", err) return 0 } for _, c := range cands { if s.reapDryRun { log.Printf("cloud: demo reaper: WOULD discard %s (id %d)", c.Host, c.ID) continue } // A pending row (create died between allocate and finalize) holds a // demo slot with no instance behind it — ledger-only cleanup. if c.Host == "" { if err := s.db.deleteSpaceByID(c.ID); err != nil { log.Printf("cloud: demo reaper: ledger delete pending id %d: %v", c.ID, err) } continue } status, body, err := s.controlDelete(c.Host, true) if err != nil { log.Printf("cloud: demo reaper: delete %s failed: %v", c.Host, err) continue } // unknown_host = already gone (an operator removed it); still clear the row. if status != http.StatusOK && body["error"] != "unknown_host" { log.Printf("cloud: demo reaper: delete %s: HTTP %d", c.Host, status) continue } if err := s.db.deleteSpaceByID(c.ID); err != nil { log.Printf("cloud: demo reaper: ledger delete %s (id %d): %v", c.Host, c.ID, err) } log.Printf("cloud: demo reaper: discarded expired demo %s (id %d, lived its %s)", c.Host, c.ID, s.demoTTL) reaped++ } 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, "", 0) s.pushReplication(host, entFree, "") writeJSON(w, http.StatusOK, map[string]any{ "host": host, "url": "https://" + host, "entitlement": entFree, }) } // demoStatusHandler: GET /api/instances/{host}/demo-status — the countdown // banner's live truth. A claim flips the ledger WITHOUT restarting the // instance (deliberate — see claimDemoHandler), so the instance's boot-time // LCHAT_ENTITLEMENT can read stale-demo for a space that's already safe; the // banner asks here (via home's public relay) before promising anyone a death. // One shape for claimed, ordinary, and never-existed hosts — the answer is // only ever "still a demo, and until when", so the endpoint can't be used to // probe which hosts exist or what anyone pays. func (s *cloudServer) demoStatusHandler(w http.ResponseWriter, r *http.Request) { host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) _, entitlement, createdAt, ok, err := s.db.spaceByHost(host) if err != nil { writeControlErr(w, http.StatusInternalServerError, "status_failed", "db error") return } if !ok || entitlement != entDemo { writeJSON(w, http.StatusOK, map[string]any{"demo": false}) return } out := map[string]any{"demo": true} // TTL unset (demos since turned off, row predates that) leaves the expiry // unknown rather than inventing one; the banner falls back to generic copy. if s.demoTTL > 0 { out["expires_at"] = createdAt + int64(s.demoTTL/time.Second) } writeJSON(w, http.StatusOK, out) }