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>
5 files changed,
+586
−4
docs/cloud-provisioner.md+17 −3home/demo.go+303 −0home/demo_test.go+244 −0home/main.go+18 −0home/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 |