eleven / messenger Public

home: demo-space gates — mandatory adaptive hashcash + per-IP-bucket cooldown, no surface yet (#883)

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>
Paul Campbell b5dc9393a13679898df1300f9a8ad2d608bfd41a parent bab1d50
5 files changed, +586 −4
  • docs/cloud-provisioner.md +17 −3
  • home/demo.go +303 −0
  • home/demo_test.go +244 −0
  • home/main.go +18 −0
  • home/provision.go +4 −1
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index cf8150b..8b5c6a6 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -358,9 +358,23 @@ Three deliberate inversions of the free-space rules:
slot for its 24h life; the live-demo cap bounds how many.
Demos take a `demo` key in `LCHAT_CLOUD_STORAGE_CAPS` (falling back to the
-`free` cap when absent). This slice ships dark — home never sends
-`kind=demo` yet; the per-IP semaphore + cooldown + adaptive proof-of-work
-gates land before any surface does.
+`free` cap when absent).
+
+**The home-side gates** (`home/demo.go`, on with `LCHAT_HOME_DEMO=1` —
+default off): `POST /api/demo/challenge` + `POST /api/demo`, the one
+account-free creation path, so the graduated-friction ladder is the whole
+boundary — **mandatory adaptive hashcash** (base `LCHAT_DEMO_POW_BITS`,
+default 18; +1 bit at 5 creates in the trailing hour and another per
+doubling, with the difficulty sealed inside each challenge so escalation
+never voids honest work in flight), and **one demo per IP bucket per
+cycle** (IPv6 at /56; blocked from creation until expiry + one full TTL
+again, so serial recreation converges to one rolling slot). Buckets are
+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.
## The storage meter (#668 slice 1)
diff --git a/home/demo.go b/home/demo.go
new file mode 100644
index 0000000..9c692b5
--- /dev/null
+++ b/home/demo.go
@@ -0,0 +1,303 @@
+package main
+
+// Demo spaces from home (docs/demo-spaces.md, the gates slice): the
+// account-free try-it path and every abuse gate in front of it. A demo needs
+// no passkey and no invite — which makes this the one truly open creation
+// endpoint, so the graduated-friction ladder carries the weight identity
+// checks carry elsewhere:
+//
+// 1. Proof of work, MANDATORY and adaptive: hashcash difficulty rises with
+// the trailing hour's demo-creation velocity, so an attack makes minting
+// expensive without punishing a quiet Tuesday. The difficulty is sealed
+// INSIDE each challenge, so a rise between mint and solve never
+// invalidates honest work in flight.
+// 2. One demo per IP bucket per cycle: from creation until expiry + one full
+// TTL again (cooldown ≥ lifetime), a bucket can't mint another — serial
+// recreation converges to one rolling slot, no better than the free tier,
+// so the burner incentive is structurally gone. IPv6 buckets at /56;
+// buckets are HMACs under a per-process random key, so no raw IP is ever
+// stored and nothing here is linkable across a restart.
+// 3. The cloud's own gates (live-demo cap, creates budget, TTL) sit behind
+// all of this — home is the friction layer, cloud is the ledger of truth.
+//
+// Everything is in-memory by design: the HMAC key dies with the process, so
+// persisted claim rows would be dead weight after a restart. A restart resets
+// demo cooldowns — acceptable, since operators restart rarely and attackers
+// can't force it. Enabled by LCHAT_HOME_DEMO=1 (default off — ships dark).
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// demoGate holds every home-side demo gate. nil = demos not offered here.
+type demoGate struct {
+ baseBits int // hashcash floor (LCHAT_DEMO_POW_BITS); velocity escalates it
+ keys *stateKeys
+ key []byte // per-process random HMAC key for IP buckets (never stored)
+
+ mu sync.Mutex
+ used map[string]int64 // consumed challenges -> expiry (replay guard)
+ claims map[string]int64 // bucket -> cooldown_until (the semaphore + cooldown)
+ creates []int64 // recent create times (velocity for adaptive bits)
+}
+
+func newDemoGate(baseBits int, keys *stateKeys) (*demoGate, error) {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, err
+ }
+ return &demoGate{
+ baseBits: baseBits,
+ keys: keys,
+ key: key,
+ used: map[string]int64{},
+ claims: map[string]int64{},
+ }, nil
+}
+
+// clientIP prefers the router-stamped X-Forwarded-For (home is publicly
+// reachable only through the router, whose proxy SetXForwarded()s every
+// request) and falls back to RemoteAddr for direct/dev setups.
+func clientIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ first, _, _ := strings.Cut(xff, ",")
+ return strings.TrimSpace(first)
+ }
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return ip
+}
+
+// bucket maps an IP to its opaque rate-limit bucket: IPv4 per address, IPv6
+// per /56 (rotating inside one delegation buys nothing), HMAC'd under the
+// per-process key so no raw IP lands in memory structures or logs.
+func (g *demoGate) bucket(ip string) string {
+ base := ip
+ if addr := net.ParseIP(ip); addr != nil {
+ if v4 := addr.To4(); v4 != nil {
+ base = v4.String()
+ } else {
+ base = addr.Mask(net.CIDRMask(56, 128)).String()
+ }
+ }
+ mac := hmac.New(sha256.New, g.key)
+ mac.Write([]byte(base))
+ return hex.EncodeToString(mac.Sum(nil))[:32]
+}
+
+// bits is the CURRENT difficulty: the base, plus one bit at 5 creates in the
+// trailing hour and another per doubling after (5→+1, 10→+2, 20→+3 …).
+func demoBits(base, recentCreates int) int {
+ bits := base
+ for threshold := 5; recentCreates >= threshold; threshold *= 2 {
+ bits++
+ }
+ return bits
+}
+
+func (g *demoGate) bits(now time.Time) int {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ cutoff := now.Add(-time.Hour).Unix()
+ kept := g.creates[:0]
+ for _, t := range g.creates {
+ if t >= cutoff {
+ kept = append(kept, t)
+ }
+ }
+ g.creates = kept
+ return demoBits(g.baseBits, len(kept))
+}
+
+// cooldownUntil reports when this bucket may next mint a demo (0 = now).
+func (g *demoGate) cooldownUntil(bucket string, now time.Time) int64 {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ for b, until := range g.claims { // prune as we go
+ if until < now.Unix() {
+ delete(g.claims, b)
+ }
+ }
+ if until, ok := g.claims[bucket]; ok && until > now.Unix() {
+ return until
+ }
+ return 0
+}
+
+// record notes a successful create: the bucket blocks until the demo's expiry
+// plus one full lifetime again (cooldown ≥ TTL), and the create feeds the
+// velocity window.
+func (g *demoGate) record(bucket string, now time.Time, expiresAt int64) {
+ ttl := expiresAt - now.Unix()
+ if ttl <= 0 {
+ ttl = 48 * 3600 // expiry missing/odd: block generously rather than not at all
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.claims[bucket] = expiresAt + ttl
+ g.creates = append(g.creates, now.Unix())
+}
+
+// mint seals a challenge bound to a bucket AND its difficulty:
+// expiry(8) || bits(2) || random(16) || bucket. Verification reads the bits
+// back out, so escalation between mint and solve never voids honest work.
+func (g *demoGate) mint(bucket string, bits int, expiry time.Time) (string, error) {
+ payload := make([]byte, 8+2+16+len(bucket))
+ binary.BigEndian.PutUint64(payload, uint64(expiry.Unix()))
+ binary.BigEndian.PutUint16(payload[8:], uint16(bits))
+ if _, err := rand.Read(payload[10:26]); err != nil {
+ return "", err
+ }
+ copy(payload[26:], bucket)
+ return g.keys.seal(payload)
+}
+
+// verify checks one solution against the challenge's own sealed difficulty,
+// consuming it — one challenge, one demo attempt.
+func (g *demoGate) verify(bucket, challenge, nonce string) error {
+ payload, ok := g.keys.open(challenge)
+ if !ok || len(payload) < 26 {
+ return fmt.Errorf("bad challenge")
+ }
+ expiry := int64(binary.BigEndian.Uint64(payload))
+ if time.Now().Unix() > expiry {
+ return fmt.Errorf("challenge expired")
+ }
+ if string(payload[26:]) != bucket {
+ return fmt.Errorf("challenge is not this connection's")
+ }
+ bits := int(binary.BigEndian.Uint16(payload[8:]))
+ if !powSolves(challenge, nonce, bits) {
+ return fmt.Errorf("proof of work does not check out")
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ now := time.Now().Unix()
+ for c, exp := range g.used {
+ if exp < now {
+ delete(g.used, c)
+ }
+ }
+ if _, replayed := g.used[challenge]; replayed {
+ return fmt.Errorf("challenge already used")
+ }
+ g.used[challenge] = expiry
+ return nil
+}
+
+// ---- HTTP surface ---------------------------------------------------------------
+
+const demoCooldownMsg = "This connection already tried a demo recently — demos come around about once a day. Creating an account gets you a free space that doesn't disappear."
+
+// demoChallengeHandler: POST /api/demo/challenge — mint a proof-of-work
+// challenge for this connection's bucket at the current adaptive difficulty.
+// Refuses early on cooldown so nobody grinds hashes for a doomed create.
+func (s *Server) demoChallengeHandler(w http.ResponseWriter, r *http.Request) {
+ if s.demo == nil || s.cloud == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]any{
+ "error": "unavailable", "message": "this home server does not offer demo spaces",
+ })
+ return
+ }
+ now := time.Now()
+ bucket := s.demo.bucket(clientIP(r))
+ if until := s.demo.cooldownUntil(bucket, now); until > 0 {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "demo_cooldown", "message": demoCooldownMsg, "retry_at": until,
+ })
+ return
+ }
+ bits := s.demo.bits(now)
+ challenge, err := s.demo.mint(bucket, bits, now.Add(powChallengeWindow))
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "challenge_failed"})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"bits": bits, "challenge": challenge})
+}
+
+// demoCreateHandler: POST /api/demo — create a 24h demo space. No account, no
+// invite: the gates above are the whole boundary, and the proof of work is
+// mandatory (unlike free-space creation, where it's an operator knob).
+func (s *Server) demoCreateHandler(w http.ResponseWriter, r *http.Request) {
+ if s.demo == nil || s.cloud == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]any{
+ "error": "unavailable", "message": "this home server does not offer demo spaces",
+ })
+ return
+ }
+ now := time.Now()
+ bucket := s.demo.bucket(clientIP(r))
+ if until := s.demo.cooldownUntil(bucket, now); until > 0 {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "demo_cooldown", "message": demoCooldownMsg, "retry_at": until,
+ })
+ return
+ }
+ var req struct {
+ Challenge string `json:"challenge"`
+ Nonce string `json:"nonce"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || req.Challenge == "" {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "pow_required", "message": "Demos ask for a little proof of work first — try again.",
+ })
+ return
+ }
+ if err := s.demo.verify(bucket, req.Challenge, req.Nonce); err != nil {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "pow_failed", "message": "The proof of work didn't check out — try again.",
+ })
+ return
+ }
+
+ // The requester is a fresh opaque id — a demo belongs to no account until
+ // it's claimed (claiming is a later slice).
+ rnd := make([]byte, 16)
+ if _, err := rand.Read(rnd); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "create_failed"})
+ return
+ }
+ status, body, err := s.cloud.createDemoSpace("demo-" + hex.EncodeToString(rnd))
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "create_failed", "message": "could not reach the space provisioner",
+ })
+ return
+ }
+ // Relay the provisioner's stable codes as-is (503 demo_busy/at_capacity
+ // carry member-facing messages; 501 = cloud has demos off); flatten the rest.
+ switch status {
+ case http.StatusCreated:
+ expires, _ := body["expires_at"].(float64)
+ s.demo.record(bucket, now, int64(expires))
+ writeJSON(w, status, body)
+ case http.StatusServiceUnavailable, http.StatusNotImplemented:
+ writeJSON(w, status, body)
+ default:
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "create_failed", "message": "demo creation failed",
+ })
+ }
+}
+
+// createDemoSpace forwards one demo provisioning request (kind=demo; the
+// cloud's live-demo cap + TTL own the rest of the lifecycle).
+func (c *cloudProvisioner) createDemoSpace(requester string) (int, map[string]any, error) {
+ payload, _ := json.Marshal(map[string]string{"requester": requester, "kind": "demo"})
+ return c.do("POST", "/api/instances", payload)
+}
diff --git a/home/demo_test.go b/home/demo_test.go
new file mode 100644
index 0000000..b0c136d
--- /dev/null
+++ b/home/demo_test.go
@@ -0,0 +1,244 @@
+package main
+
+// The demo gates (demo.go): bucket derivation, adaptive difficulty, the
+// bits-sealed-in-the-challenge property, the cooldown semaphore, and the
+// endpoint boundary (mandatory PoW, cooldown ≥ TTL, kind=demo forwarding).
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func newDemoTestServer(t *testing.T, cloudURL string) (*httptest.Server, *Server) {
+ t.Helper()
+ ts, s := newTestServer(t)
+ gate, err := newDemoGate(4, s.keys) // 4 bits: solvable in microseconds
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.demo = gate
+ if cloudURL != "" {
+ s.cloud = &cloudProvisioner{url: cloudURL, token: "caller-token", client: http.DefaultClient}
+ }
+ return ts, s
+}
+
+// postDemo hits a demo endpoint with a spoofed client IP (the router would
+// stamp X-Forwarded-For in production).
+func postDemo(t *testing.T, url, path, ip string, payload any) (*http.Response, map[string]any) {
+ t.Helper()
+ var body []byte
+ if payload != nil {
+ body, _ = json.Marshal(payload)
+ }
+ req, _ := http.NewRequest("POST", url+path, bytes.NewReader(body))
+ req.Header.Set("X-Forwarded-For", ip)
+ 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
+}
+
+func TestDemoBuckets(t *testing.T) {
+ gate, err := newDemoGate(4, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gate.bucket("203.0.113.7") != gate.bucket("203.0.113.7") {
+ t.Fatal("same IPv4 must map to the same bucket")
+ }
+ if gate.bucket("203.0.113.7") == gate.bucket("203.0.113.8") {
+ t.Fatal("distinct IPv4s must map to distinct buckets")
+ }
+ // Two addresses inside one /56 delegation share a bucket…
+ if gate.bucket("2001:db8:1:200::1") != gate.bucket("2001:db8:1:2ff::9") {
+ t.Fatal("IPv6 addresses in one /56 must share a bucket")
+ }
+ // …and a different /56 doesn't.
+ if gate.bucket("2001:db8:1:200::1") == gate.bucket("2001:db8:1:300::1") {
+ t.Fatal("distinct /56s must not share a bucket")
+ }
+ // A fresh gate (new process) buckets differently: nothing links restarts.
+ gate2, _ := newDemoGate(4, nil)
+ if gate.bucket("203.0.113.7") == gate2.bucket("203.0.113.7") {
+ t.Fatal("buckets must not be stable across processes")
+ }
+}
+
+func TestDemoBitsEscalation(t *testing.T) {
+ for _, tc := range []struct{ recent, want int }{
+ {0, 18}, {4, 18}, {5, 19}, {9, 19}, {10, 20}, {19, 20}, {20, 21}, {40, 22},
+ } {
+ if got := demoBits(18, tc.recent); got != tc.want {
+ t.Fatalf("demoBits(18, %d) = %d, want %d", tc.recent, got, tc.want)
+ }
+ }
+}
+
+func TestDemoGateVerify(t *testing.T) {
+ _, s := newTestServer(t)
+ gate, err := newDemoGate(4, s.keys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bucket := gate.bucket("203.0.113.7")
+ challenge, err := gate.mint(bucket, 4, time.Now().Add(time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonce := solvePow(challenge, 4)
+ if err := gate.verify(gate.bucket("198.51.100.1"), challenge, nonce); err == nil {
+ t.Fatal("another bucket's solution must not verify")
+ }
+ if err := gate.verify(bucket, challenge, nonce); err != nil {
+ t.Fatalf("honest solution rejected: %v", err)
+ }
+ if err := gate.verify(bucket, challenge, nonce); err == nil {
+ t.Fatal("replay must not verify")
+ }
+ // The difficulty rides INSIDE the challenge: a challenge minted at 12 bits
+ // is checked at 12 bits even though the gate's base is 4.
+ hard, _ := gate.mint(bucket, 12, time.Now().Add(time.Minute))
+ weak := solvePow(hard, 4)
+ if powSolves(hard, weak, 12) {
+ t.Skip("lucky nonce cleared 12 bits; nothing to assert")
+ }
+ if err := gate.verify(bucket, hard, weak); err == nil {
+ t.Fatal("an under-difficulty nonce must not verify against the sealed bits")
+ }
+ // Expired challenges die.
+ stale, _ := gate.mint(bucket, 4, time.Now().Add(-time.Minute))
+ if err := gate.verify(bucket, stale, solvePow(stale, 4)); err == nil {
+ t.Fatal("expired challenge must not verify")
+ }
+}
+
+func TestDemoEndpointsDisabled(t *testing.T) {
+ ts, _ := newTestServer(t) // no demo gate, no cloud
+ for _, path := range []string{"/api/demo/challenge", "/api/demo"} {
+ resp, body := postDemo(t, ts.URL, path, "203.0.113.7", nil)
+ if resp.StatusCode != http.StatusNotImplemented || body["error"] != "unavailable" {
+ t.Fatalf("%s while disabled: HTTP %d %v", path, resp.StatusCode, body)
+ }
+ }
+}
+
+func TestDemoCreateFlow(t *testing.T) {
+ cloud, captured, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{
+ "host": "8oe.11ch.at", "url": "https://8oe.11ch.at",
+ "setup_link": "https://8oe.11ch.at/join/setup#T", "kind": "demo",
+ "expires_at": time.Now().Add(24 * time.Hour).Unix(),
+ })
+ ts, _ := newDemoTestServer(t, cloud.URL)
+
+ // PoW is mandatory: no solution, no demo.
+ resp, body := postDemo(t, ts.URL, "/api/demo", "203.0.113.7", nil)
+ if resp.StatusCode != http.StatusForbidden || body["error"] != "pow_required" {
+ t.Fatalf("create without pow: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // Challenge → solve → create.
+ resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("challenge: HTTP %d %v", resp.StatusCode, body)
+ }
+ challenge, _ := body["challenge"].(string)
+ bits := int(body["bits"].(float64))
+ resp, body = postDemo(t, ts.URL, "/api/demo", "203.0.113.7",
+ map[string]string{"challenge": challenge, "nonce": solvePow(challenge, bits)})
+ if resp.StatusCode != http.StatusCreated || body["host"] != "8oe.11ch.at" {
+ t.Fatalf("create: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // Home forwarded kind=demo with an anonymous one-shot requester.
+ var sent map[string]string
+ if err := json.Unmarshal(*capturedBody, &sent); err != nil {
+ t.Fatal(err)
+ }
+ if sent["kind"] != "demo" || len(sent["requester"]) < 10 {
+ t.Fatalf("forwarded body = %v", sent)
+ }
+ if (*captured).URL.Path != "/api/instances" {
+ t.Fatalf("forwarded path = %s", (*captured).URL.Path)
+ }
+
+ // The bucket is now in cooldown: same connection can't mint or create.
+ resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil)
+ if resp.StatusCode != http.StatusForbidden || body["error"] != "demo_cooldown" {
+ t.Fatalf("challenge during cooldown: HTTP %d %v", resp.StatusCode, body)
+ }
+ if _, ok := body["retry_at"].(float64); !ok {
+ t.Fatalf("cooldown carries no retry_at: %v", body)
+ }
+ resp, body = postDemo(t, ts.URL, "/api/demo", "203.0.113.7",
+ map[string]string{"challenge": challenge, "nonce": "0"})
+ if resp.StatusCode != http.StatusForbidden || body["error"] != "demo_cooldown" {
+ t.Fatalf("create during cooldown: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // A different connection is unaffected.
+ if resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "198.51.100.1", nil); resp.StatusCode != http.StatusOK {
+ t.Fatalf("other bucket's challenge: HTTP %d %v", resp.StatusCode, body)
+ }
+}
+
+// A failed cloud create must NOT burn the bucket's cooldown.
+func TestDemoFailedCreateKeepsBucketFree(t *testing.T) {
+ cloud, _, _ := fakeCloud(t, http.StatusServiceUnavailable, map[string]any{
+ "error": "demo_busy", "message": "every demo slot is taken right now",
+ })
+ ts, _ := newDemoTestServer(t, cloud.URL)
+
+ _, body := postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil)
+ challenge, _ := body["challenge"].(string)
+ resp, body := postDemo(t, ts.URL, "/api/demo", "203.0.113.7",
+ map[string]string{"challenge": challenge, "nonce": solvePow(challenge, 4)})
+ if resp.StatusCode != http.StatusServiceUnavailable || body["error"] != "demo_busy" {
+ t.Fatalf("busy relay: HTTP %d %v", resp.StatusCode, body)
+ }
+ // No cooldown recorded — the next challenge mints fine.
+ if resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil); resp.StatusCode != http.StatusOK {
+ t.Fatalf("challenge after failed create: HTTP %d %v", resp.StatusCode, body)
+ }
+}
+
+func TestDemoCooldownIsExpiryPlusTTL(t *testing.T) {
+ gate, err := newDemoGate(4, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(2_000_000_000, 0)
+ expires := now.Add(24 * time.Hour).Unix()
+ gate.record("bucket-a", now, expires)
+ until := gate.cooldownUntil("bucket-a", now)
+ if want := expires + 24*3600; until != want {
+ t.Fatalf("cooldown_until = %d, want expiry+TTL = %d", until, want)
+ }
+ // After the cooldown passes, the bucket frees (prune path).
+ if got := gate.cooldownUntil("bucket-a", time.Unix(until+1, 0)); got != 0 {
+ t.Fatalf("bucket still blocked after cooldown: %d", got)
+ }
+}
+
+func TestDemoFeaturesFlag(t *testing.T) {
+ cloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{})
+ ts, _ := newDemoTestServer(t, cloud.URL)
+ resp, err := http.Get(ts.URL + "/api/features")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var m map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&m)
+ if m["demo"] != true {
+ t.Fatalf("features = %v, want demo true", m)
+ }
+}
diff --git a/home/main.go b/home/main.go
index 89466b6..3ded817 100644
--- a/home/main.go
+++ b/home/main.go
@@ -55,6 +55,9 @@ type Server struct {
// pow gates space creation behind hashcash when LCHAT_POW_BITS > 0
// (pow.go); nil = no gate.
pow *powGate
+ // demo is the account-free demo-space surface and its abuse gates
+ // (demo.go); nil = demos not offered here (LCHAT_HOME_DEMO unset).
+ demo *demoGate
// origin is this server's public origin (also the WebAuthn RP origin) —
// used to build the checkout return URLs.
origin string
@@ -111,6 +114,15 @@ func main() {
s.pow = newPowGate(bits, s.keys)
log.Printf("space creation gated by proof of work (%d bits)", bits)
}
+ if os.Getenv("LCHAT_HOME_DEMO") == "1" {
+ bits, _ := strconv.Atoi(envOr("LCHAT_DEMO_POW_BITS", "18"))
+ gate, err := newDemoGate(bits, s.keys)
+ if err != nil {
+ log.Fatalf("demo gate: %v", err)
+ }
+ s.demo = gate
+ log.Printf("demo spaces on (mandatory proof of work, base %d bits, adaptive)", bits)
+ }
if s.infrastructureDir = os.Getenv("LCHAT_HOME_INFRASTRUCTURE_DIR"); s.infrastructureDir != "" {
log.Printf("planet-scale guide served from %s at /infrastructure/", s.infrastructureDir)
}
@@ -288,6 +300,12 @@ func (s *Server) handler() http.Handler {
// The proof-of-work challenge behind creation (cheap to mint; rate-limited
// with the same budget as the creates it gates).
mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler))
+ // Demo spaces (demo.go): the account-free try-it path. Deliberately
+ // unauthenticated — the demo gates (mandatory adaptive proof of work +
+ // per-connection cooldown) are the whole boundary, plus the shared
+ // 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))
// 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/provision.go b/home/provision.go
index f86f288..063f6fc 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -48,7 +48,10 @@ func newCloudProvisioner(url, token string) *cloudProvisioner {
// so the page can hide UI it can't serve. Public: it reveals only a boolean,
// never a token or endpoint that would work without one.
func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]any{"spaces": s.cloud != nil})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "spaces": s.cloud != nil,
+ "demo": s.demo != nil && s.cloud != nil,
+ })
}
// challengeHandler: POST /api/spaces/challenge — mint a proof-of-work