eleven / ocho Public

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>
Paul Campbell 30619d4e8b22c0f80e32a1b0056ccb3c334f2d4c parent b8f2af1
7 files changed, +178 −2
  • docs/cloud-provisioner.md +8 −2
  • home/demo.go +54 −0
  • home/demo_test.go +63 −0
  • home/main.go +3 −0
  • home/web/pages/index.html +7 −0
  • home/web/static/home.css +6 −0
  • home/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.