home: demo surface + claim relay — 'Try a demo space' + POST /api/spaces/claim (re-opened #979) (#1028)
* home: demo-space gates — mandatory adaptive hashcash + per-IP-bucket cooldown, no surface yet docs/demo-spaces.md gates slice. POST /api/demo/challenge + /api/demo are the account-free demo path (LCHAT_HOME_DEMO=1, default off): proof of work is MANDATORY and adaptive (base LCHAT_DEMO_POW_BITS=18; +1 bit at 5 creates in the trailing hour, another per doubling; difficulty sealed inside each challenge so escalation never voids in-flight work), and each IP bucket (IPv6 /56) gets one demo per cycle — blocked until expiry + one full TTL, so serial recreation nets one rolling slot. Buckets are HMACs under a per-process random key: no raw IP stored, nothing linkable across restarts, failed creates never burn the bucket. Forwards kind=demo with a one-shot anonymous requester; relays the cloud's stable codes. /api/features advertises 'demo'. No UI sends this yet — the surface is the next slice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * home: demo surface + claim relay — 'Try a demo space' on the sign-in card, POST /api/spaces/claim The surface slice of docs/demo-spaces.md, stacked on the gates. The sign-in card gains a feature-gated 'Try a demo space' offer with the honest one-liner (no sign-in; gone in 24 hours; claiming keeps it); the client grinds the adaptive hashcash inline — the burn is the wait — and lands in the fresh space's setup link same-tab. All outcomes render inline in #status, never a toast; server messages (cooldown, demos-busy) show verbatim. POST /api/spaces/claim (link-token, additive like creation) relays a claim to the cloud's /claim route with the signed-in uid as the new owner, passing 200/409/410 through verbatim and flattening the rest. The in-space countdown/claim banner is the remaining surface piece. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+178
−2
docs/cloud-provisioner.md+8 −2home/demo.go+54 −0home/demo_test.go+63 −0home/main.go+3 −0home/web/pages/index.html+7 −0home/web/static/home.css+6 −0home/web/static/home.js+37 −0
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index a366700..4a319c9 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -373,8 +373,14 @@ HMACs under a per-process random key — no raw IP is ever stored, nothing |
| links across a restart, and a failed create never burns the bucket. The |
| client IP comes from the router's `X-Forwarded-For` stamp. |
| `GET /api/features` advertises `demo` so clients can show the button only |
| -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. |
| +where it works. **The home surface**: the sign-in card offers "Try a demo |
| +space" (feature-gated, with the honest one-liner about the 24 hours), the |
| +client grinds the hashcash inline — the burn is the wait — and lands in |
| +the fresh space's setup link same-tab. `POST /api/spaces/claim` |
| +(link-token) relays a claim to the cloud so a signed-in account can adopt |
| +a demo as its free space; folding the claimed space into the encrypted |
| +backup is the client's move, like a create. Still to come: the in-space |
| +countdown/claim banner (an instance-side surface). |
| **Claiming** (`POST /api/instances/{host}/claim`, caller-token): the happy |
| exit — a signed-in account adopts a demo, transferring ownership and |
diff --git a/home/demo.go b/home/demo.go| index 9c692b5..de0a555 100644 |
| --- a/home/demo.go |
| +++ b/home/demo.go |
| @@ -36,6 +36,7 @@ import ( |
| "io" |
| "net" |
| "net/http" |
| + "net/url" |
| "strings" |
| "sync" |
| "time" |
| @@ -301,3 +302,56 @@ func (c *cloudProvisioner) createDemoSpace(requester string) (int, map[string]an |
| payload, _ := json.Marshal(map[string]string{"requester": requester, "kind": "demo"}) |
| return c.do("POST", "/api/instances", payload) |
| } |
| + |
| +// claimSpaceHandler: POST /api/spaces/claim — adopt a demo space as the |
| +// signed-in account's free space (docs/demo-spaces.md, the happy exit). |
| +// Link-token auth: claiming is additive, like creation. The cloud does the |
| +// real work (ownership transfer, quota, expiry guard); home just relays its |
| +// stable codes. Folding the claimed space into the account's encrypted |
| +// backup is the CLIENT's move, exactly like a create — the blob stays |
| +// content-blind. |
| +func (s *Server) claimSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| + uid, ok := s.linkUser(r) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + if s.cloud == nil { |
| + writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| + "error": "unavailable", "message": "this home server does not offer space creation", |
| + }) |
| + return |
| + } |
| + var req struct { |
| + Host string `json:"host"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Host) == "" { |
| + writeJSON(w, http.StatusBadRequest, map[string]any{ |
| + "error": "bad_request", "message": "host required", |
| + }) |
| + return |
| + } |
| + status, body, err := s.cloud.claimSpace(strings.TrimSpace(req.Host), uid) |
| + if err != nil { |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "claim_failed", "message": "could not reach the space provisioner", |
| + }) |
| + return |
| + } |
| + // Relay the stable codes (200 claimed; 409 quota/not-demo; 410 expired — |
| + // all carry member-facing messages); flatten anything unexpected. |
| + switch status { |
| + case http.StatusOK, http.StatusConflict, http.StatusGone: |
| + writeJSON(w, status, body) |
| + default: |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "claim_failed", "message": "claiming this space failed", |
| + }) |
| + } |
| +} |
| + |
| +// claimSpace forwards one claim to the provisioner. |
| +func (c *cloudProvisioner) claimSpace(host, requester string) (int, map[string]any, error) { |
| + payload, _ := json.Marshal(map[string]string{"requester": requester}) |
| + return c.do("POST", "/api/instances/"+url.PathEscape(host)+"/claim", payload) |
| +} |
diff --git a/home/demo_test.go b/home/demo_test.go| index b0c136d..18ad30f 100644 |
| --- a/home/demo_test.go |
| +++ b/home/demo_test.go |
| @@ -242,3 +242,66 @@ func TestDemoFeaturesFlag(t *testing.T) { |
| t.Fatalf("features = %v, want demo true", m) |
| } |
| } |
| + |
| +// ---- the claim relay --------------------------------------------------------- |
| + |
| +func TestClaimRelay(t *testing.T) { |
| + cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "host": "8oe.11ch.at", "url": "https://8oe.11ch.at", "entitlement": "free", |
| + }) |
| + ts, s := newDemoTestServer(t, cloud.URL) |
| + uid, linkToken := seedUser(t, s) |
| + |
| + post := func(token string, payload any) (*http.Response, map[string]any) { |
| + t.Helper() |
| + body, _ := json.Marshal(payload) |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/claim", bytes.NewReader(body)) |
| + if token != "" { |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var m map[string]any |
| + _ = json.NewDecoder(resp.Body).Decode(&m) |
| + return resp, m |
| + } |
| + |
| + // Auth required; host required. |
| + if resp, _ := post("", map[string]string{"host": "8oe.11ch.at"}); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("no token: HTTP %d", resp.StatusCode) |
| + } |
| + if resp, body := post(linkToken, map[string]string{}); resp.StatusCode != http.StatusBadRequest || body["error"] != "bad_request" { |
| + t.Fatalf("no host: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + |
| + // Happy path: home forwards {requester: uid} to the cloud's claim route. |
| + resp, body := post(linkToken, map[string]string{"host": "8oe.11ch.at"}) |
| + if resp.StatusCode != http.StatusOK || body["entitlement"] != "free" { |
| + t.Fatalf("claim: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + if (*captured).URL.Path != "/api/instances/8oe.11ch.at/claim" { |
| + t.Fatalf("forwarded path = %s", (*captured).URL.Path) |
| + } |
| + var sent map[string]string |
| + if err := json.Unmarshal(*capturedBody, &sent); err != nil { |
| + t.Fatal(err) |
| + } |
| + if sent["requester"] != uid { |
| + t.Fatalf("forwarded requester = %q, want the signed-in uid", sent["requester"]) |
| + } |
| + |
| + // Stable refusals relay verbatim; the unexpected flattens. |
| + gone, _, _ := fakeCloud(t, http.StatusGone, map[string]any{"error": "demo_expired", "message": "this demo's 24 hours are up"}) |
| + s.cloud = &cloudProvisioner{url: gone.URL, token: "caller-token", client: http.DefaultClient} |
| + if resp, body := post(linkToken, map[string]string{"host": "x.11ch.at"}); resp.StatusCode != http.StatusGone || body["error"] != "demo_expired" { |
| + t.Fatalf("expired relay: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"}) |
| + s.cloud = &cloudProvisioner{url: weird.URL, token: "caller-token", client: http.DefaultClient} |
| + if resp, body := post(linkToken, map[string]string{"host": "x.11ch.at"}); resp.StatusCode != http.StatusBadGateway || body["error"] != "claim_failed" { |
| + t.Fatalf("weird relay: HTTP %d %v", resp.StatusCode, body) |
| + } |
| +} |
diff --git a/home/main.go b/home/main.go| index 3ded817..7f22837 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -306,6 +306,9 @@ func (s *Server) handler() http.Handler { |
| // per-IP rate limiter in front. |
| mux.HandleFunc("POST /api/demo/challenge", s.rateLimited(s.demoChallengeHandler)) |
| mux.HandleFunc("POST /api/demo", s.rateLimited(s.demoCreateHandler)) |
| + // Claiming a demo as your free space — additive, so the link token |
| + // suffices (same reasoning as creation). |
| + mux.HandleFunc("POST /api/spaces/claim", s.rateLimited(s.claimSpaceHandler)) |
| // Which spaces this user provisioned (read; drives the delete affordance)… |
| mux.HandleFunc("GET /api/spaces", s.listSpacesHandler) |
| // …and permanent deletion of one — ceremony-gated like every destructive op. |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 6273165..5ce5915 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -439,6 +439,13 @@ |
| <button id="signin">Sign in</button> |
| <div class="path-divider">New here?</div> |
| <button id="create-passkey" class="secondary" type="button">Create your sign-in</button> |
| + <!-- The no-account path (demo.go) — hidden until /api/features says this |
| + home server offers demos. --> |
| + <div id="demo-offer" class="hidden"> |
| + <div class="path-divider">Just looking?</div> |
| + <button id="try-demo" class="secondary" type="button">Try a demo space</button> |
| + <p class="demo-hint">No sign-in needed. Demo spaces disappear after 24 hours — claiming one keeps it.</p> |
| + </div> |
| </div> |
| <!-- Revealed after a failed sign-in, as reinforcement. Cancelled and |
diff --git a/home/web/static/home.css b/home/web/static/home.css| index ebe3f45..b79de33 100644 |
| --- a/home/web/static/home.css |
| +++ b/home/web/static/home.css |
| @@ -97,6 +97,12 @@ body { |
| content: ""; flex: 1; height: 1px; background: var(--border); |
| } |
| +/* The demo offer sits under the create path, quieter still: the honest |
| + one-liner does the explaining (--muted keeps contrast in both themes). */ |
| +#demo-offer { display: flex; flex-direction: column; gap: 8px; } |
| +#demo-offer.hidden { display: none; } |
| +.demo-hint { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.4; } |
| + |
| button { |
| font: inherit; |
| font-weight: 600; |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 0933539..1811a6d 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -841,6 +841,41 @@ async function solveCreateChallenge() { |
| } |
| } |
| +// tryDemo: the no-account path (POST /api/demo/challenge → grind → POST |
| +// /api/demo). The hashcash burn IS the wait, same trade as creation; every |
| +// outcome lands inline in #status (never a toast) — the server's messages are |
| +// member-facing (cooldown, demos-busy) and shown verbatim. On success we go |
| +// straight into the fresh space's setup link, same-tab like the create flow. |
| +async function tryDemo() { |
| + const btn = $("try-demo"); |
| + btn.disabled = true; |
| + setStatus("Preparing your demo space — a few seconds of math keeps the bots away…", "busy"); |
| + try { |
| + const res = await fetch("/api/demo/challenge", { method: "POST" }); |
| + const c = await res.json().catch(() => ({})); |
| + if (!res.ok) throw new Error(c.message || "Demos aren't available right now."); |
| + const enc = new TextEncoder(); |
| + let nonce = null; |
| + for (let n = 0; ; n++) { |
| + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n))); |
| + if (leadingZeroBits(digest) >= c.bits) { nonce = String(n); break; } |
| + } |
| + setStatus("Building your demo space…", "busy"); |
| + const create = await fetch("/api/demo", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ challenge: c.challenge, nonce }), |
| + }); |
| + const body = await create.json().catch(() => ({})); |
| + if (!create.ok) throw new Error(body.message || "Couldn't create a demo space right now."); |
| + location.href = body.setup_link; |
| + } catch (err) { |
| + setStatus(err.message, "error"); |
| + btn.disabled = false; |
| + } |
| +} |
| +$("try-demo").addEventListener("click", tryDemo); |
| + |
| function leadingZeroBits(bytes) { |
| let bits = 0; |
| for (const b of bytes) { |
| @@ -1330,6 +1365,8 @@ $("space-search").addEventListener("input", () => { if (vault) showVault(); }); |
| apiJSON("/api/features") |
| .then((f) => { |
| spacesEnabled = !!f.spaces; |
| + // The no-account demo path (shown signed-out, where it matters). |
| + $("demo-offer").classList.toggle("hidden", !f.demo); |
| if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace()); |
| // If the list rendered before this answer, re-render: the lazy ledger read |
| // (Upgrade affordance) only triggers once spacesEnabled is known. |