server+home+web: demo countdown/claim banner — the last demo surface piece (#1062)
* server+home+web: demo countdown/claim banner — the last demo surface piece
The in-space band a demo space wears from its first second (docs/demo-spaces.md
lifecycle step 2): a live countdown across the top of the shell with "Keep this
space" linking to home's new #claim=<host> screen.
A claim flips the cloud ledger WITHOUT restarting the instance (deliberate),
so the boot-time LCHAT_ENTITLEMENT can read stale-demo for a space that's
already safe. The band therefore trusts a live status read, not the env:
- cloud: GET /api/instances/{host}/demo-status (caller-token) — "still a demo,
and until when"; claimed/ordinary/unknown hosts are one indistinguishable
{demo:false}, so nothing can probe existence or plan.
- home: GET /api/spaces/demo-status?host= — public, CORS-open relay reshaped
to exactly the banner's two fields. Deliberately OUTSIDE the shared ceremony
rate limiter (behind the router every caller shares one RemoteAddr bucket;
an auto-fired read would drain sign-in's tokens) — a short per-host cache
bounds the load instead.
- web (instance): the #demo-banner band — inline, occupies layout, accent-
tinted while running, amber once ended; ticks every 30s via the new pure
formatCountdown helper; re-checks on tab refocus so it vanishes the moment
the space is claimed; hidden in native wrappers' webviews (same posture as
Upgrade) and in embed mode.
- home (page): #claim=<host> deep link waits through sign-in (a demo claimant
often has no account yet), then a one-confirm Keep screen relays the claim
and folds the space into the account's encrypted list like a create.
Native apps deliberately don't show the band yet — a demo reaching a native
app is an add-device edge case; scoped web-first, noted in docs/demo-spaces.md.
Changelog entry held until demos flip on for real (the standing decision).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server: LCHAT_CONTROL_HOME_ORIGIN — a box default for provisioned instances' home origin
cloud's controlCreate never names a home origin, so provisioner-created
spaces always got the compiled production default — right on the prod box,
wrong on any other fleet (dev mesh): their backup nudges point at prod home,
and the demo banner's live status read would ask a home that answers
{demo:false} and hide the band on a real demo. controld now takes a box-level
default (env LCHAT_CONTROL_HOME_ORIGIN / -home-origin) applied to creates
that don't name one; an explicit request value still wins. Empty keeps the
compiled default, so production needs nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* infra: dev SSH ingress default follows the operator's rotated IP (109.255.206.133)
The live SGs (eleven-dev-box + eleven-dev-mesh-ie) were updated via the AWS
CLI when SSH went dark 2026-07-17; this keeps the next tofu apply from
reverting them. PR #1043's dev-mesh tofu carries the same stale default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
20 files changed,
+648
−14
cloud.go+3 −0controld.go+18 −2controld_test.go+43 −0demo.go+28 −0demo_test.go+74 −0docs/demo-spaces.md+8 −1docs/planet-scale-guide/reference.html+1 −0home/demo.go+100 −0home/demo_test.go+93 −0home/main.go+11 −0home/web/pages/index.html+17 −0home/web/static/home.js+92 −0infra/dev/compute.tf+1 −1main.go+5 −3meter.go+3 −2test/format.test.mjs+16 −1web/pages/index.html+9 −3web/static/format.js+13 −0web/static/shell.js+71 −1web/static/style.css+42 −0
diff --git a/cloud.go b/cloud.go| index 7e9a3d6..93a8751 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -113,6 +113,9 @@ func (s *cloudServer) handler() http.Handler { |
| mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler)) |
| // Claiming a demo (demo.go) — additive, so caller-token gated like creation. |
| mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler)) |
| + // The countdown banner's live "still a demo?" check (demo.go) — read-only, |
| + // relayed to browsers by home, so it discloses demo-ness and nothing else. |
| + mux.HandleFunc("GET /api/instances/{host}/demo-status", s.callerAuth(s.demoStatusHandler)) |
| mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler)) |
| mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler) |
| // Usage ingest (#857): meter-token-gated, not caller-gated — an instance |
diff --git a/controld.go b/controld.go| index 06b7412..1522111 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -76,7 +76,15 @@ type controld struct { |
| // a resource cap (hibernating rows hold no process). <=0 defaults to |
| // 10x maxInstances. |
| maxRegistered int |
| - mu sync.Mutex |
| + // homeOrigin (env LCHAT_CONTROL_HOME_ORIGIN) is the box's default |
| + // LCHAT_HOME_ORIGIN for provisioner creates that don't name one. The home |
| + // origin is an environment property (which home server fronts this fleet), |
| + // not per-space policy — without this, a non-production box's spaces point |
| + // at the compiled production default, so their backup nudges and the demo |
| + // banner's live status read ask the wrong home. Empty = keep the compiled |
| + // default (production boxes need nothing). |
| + homeOrigin string |
| + mu sync.Mutex |
| } |
| // controlCreateReq mirrors addInstanceOpts across the socket. AdminToken is the |
| @@ -555,11 +563,17 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) { |
| if strings.EqualFold(strings.TrimSpace(req.Entitlement), "demo") { |
| hibernate = false |
| } |
| + // The box default fills a create that names no home origin (see the |
| + // controld.homeOrigin comment); an explicit request value still wins. |
| + homeOrigin := req.HomeOrigin |
| + if homeOrigin == "" { |
| + homeOrigin = c.homeOrigin |
| + } |
| res, err := addInstance(c.reg, addInstanceOpts{ |
| Host: req.Host, |
| AdminToken: req.AdminToken, |
| AppStoreURL: req.AppStoreURL, |
| - HomeOrigin: req.HomeOrigin, |
| + HomeOrigin: homeOrigin, |
| PushRelayURL: req.PushRelayURL, |
| PushRelayToken: req.PushRelayToken, |
| Entitlement: req.Entitlement, |
| @@ -622,6 +636,7 @@ func runControld(args []string) { |
| trashKeepDays := fs.Int("trash-keep-days", envInt("LCHAT_TRASH_KEEP_DAYS", 0), "purge archived (deleted) spaces from /data/trash after this many days; 0 keeps them forever") |
| hibernateCreates := fs.Bool("hibernate-creates", os.Getenv("LCHAT_CONTROL_HIBERNATE_CREATES") == "1", "create provisioner spaces as hibernating (activator-managed, no systemd unit) — requires the router to run with LCHAT_HIBERNATE_BUCKET") |
| maxRegistered := fs.Int("max-registered", envInt("LCHAT_MAX_REGISTERED", 0), "with -hibernate-creates: refuse registration beyond this many total spaces, hibernating included (0 = 10x -max-instances)") |
| + homeOrigin := fs.String("home-origin", os.Getenv("LCHAT_CONTROL_HOME_ORIGIN"), "default LCHAT_HOME_ORIGIN for provisioner creates that don't name one (empty = the compiled production default)") |
| _ = fs.Parse(args) |
| tok, err := os.ReadFile(*tokenFile) |
| @@ -639,6 +654,7 @@ func runControld(args []string) { |
| minFreeGB: *minFreeGB, |
| hibernateCreates: *hibernateCreates, |
| maxRegistered: *maxRegistered, |
| + homeOrigin: strings.TrimSpace(*homeOrigin), |
| } |
| if err := os.Remove(*socketPath); err != nil && !os.IsNotExist(err) { |
diff --git a/controld_test.go b/controld_test.go| index 386ae47..9710cdc 100644 |
| --- a/controld_test.go |
| +++ b/controld_test.go |
| @@ -765,3 +765,46 @@ func TestControldMaterializeIdempotent(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A box-level default home origin fills provisioner creates that name none |
| +// (LCHAT_CONTROL_HOME_ORIGIN — non-production fleets front a different home); |
| +// an explicit request value still wins. |
| +func TestControldHomeOriginDefault(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5, |
| + homeOrigin: "https://home.ie.dev.example"} |
| + srv := httptest.NewServer(c.handler()) |
| + defer srv.Close() |
| + |
| + create := func(payload map[string]string) { |
| + t.Helper() |
| + body, _ := json.Marshal(payload) |
| + req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer test-control-token") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create %v: HTTP %d", payload, resp.StatusCode) |
| + } |
| + } |
| + envOrigin := func(host string) string { |
| + t.Helper() |
| + env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return env["LCHAT_HOME_ORIGIN"] |
| + } |
| + |
| + create(map[string]string{"host": "defaulted.example.com"}) |
| + if got := envOrigin("defaulted.example.com"); got != "https://home.ie.dev.example" { |
| + t.Fatalf("defaulted create's LCHAT_HOME_ORIGIN = %q", got) |
| + } |
| + create(map[string]string{"host": "explicit.example.com", "home_origin": "https://home.other.example"}) |
| + if got := envOrigin("explicit.example.com"); got != "https://home.other.example" { |
| + t.Fatalf("explicit create's LCHAT_HOME_ORIGIN = %q, want the request's own", got) |
| + } |
| +} |
diff --git a/demo.go b/demo.go| index bcf2413..81da983 100644 |
| --- a/demo.go |
| +++ b/demo.go |
| @@ -241,3 +241,31 @@ func (s *cloudServer) claimDemoHandler(w http.ResponseWriter, r *http.Request) { |
| "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) |
| +} |
diff --git a/demo_test.go b/demo_test.go| index 5d3e121..56b0793 100644 |
| --- a/demo_test.go |
| +++ b/demo_test.go |
| @@ -415,3 +415,77 @@ func TestClaimRefusals(t *testing.T) { |
| t.Fatalf("quota-full claim: HTTP %d %v", resp.StatusCode, body) |
| } |
| } |
| + |
| +// ---- the countdown banner's live status read --------------------------------- |
| + |
| +func demoStatus(t *testing.T, srv *httptest.Server, token, host string) (*http.Response, map[string]any) { |
| + t.Helper() |
| + req, _ := http.NewRequest("GET", srv.URL+"/api/instances/"+host+"/demo-status", nil) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + var m map[string]any |
| + _ = json.NewDecoder(resp.Body).Decode(&m) |
| + return resp, m |
| +} |
| + |
| +func TestDemoStatus(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + token := mintCaller(t, srv, "home") |
| + |
| + // A live demo reports demo:true with its death-date. |
| + resp := createKind(t, srv, token, "demo-status", "demo") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo create: HTTP %d", resp.StatusCode) |
| + } |
| + host, _ := decodeBody(t, resp)["host"].(string) |
| + resp, body := demoStatus(t, srv, token, host) |
| + if resp.StatusCode != http.StatusOK || body["demo"] != true { |
| + t.Fatalf("live demo status: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + exp, ok := body["expires_at"].(float64) |
| + if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() { |
| + t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"]) |
| + } |
| + |
| + // Claimed → one indistinguishable {demo:false}, same as unknown and free |
| + // hosts: the read discloses demo-ness only, never existence. |
| + if resp, _ := claimHost(t, srv, token, host, "alice"); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("claim: HTTP %d", resp.StatusCode) |
| + } |
| + for _, h := range []string{host, "nope.example.com"} { |
| + resp, body := demoStatus(t, srv, token, h) |
| + if resp.StatusCode != http.StatusOK || body["demo"] != false { |
| + t.Fatalf("status for %s: HTTP %d %v, want 200 demo:false", h, resp.StatusCode, body) |
| + } |
| + if _, present := body["expires_at"]; present { |
| + t.Fatalf("status for %s leaked expires_at: %v", h, body) |
| + } |
| + } |
| + |
| + // An expired-but-unswept demo still reads demo:true, expiry in the past — |
| + // the banner's "this demo has ended" state. |
| + seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix()) |
| + resp, body = demoStatus(t, srv, token, "stale.example.com") |
| + if resp.StatusCode != http.StatusOK || body["demo"] != true { |
| + t.Fatalf("expired status: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + if exp, _ := body["expires_at"].(float64); int64(exp) >= time.Now().Unix() { |
| + t.Fatalf("expired demo's expires_at not in the past: %v", body["expires_at"]) |
| + } |
| + |
| + // TTL since unset (demos switched off, row survives): demo:true with the |
| + // expiry left unknown rather than invented. |
| + s.demoTTL = 0 |
| + resp, body = demoStatus(t, srv, token, "stale.example.com") |
| + if resp.StatusCode != http.StatusOK || body["demo"] != true { |
| + t.Fatalf("no-TTL status: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + if _, present := body["expires_at"]; present { |
| + t.Fatalf("no-TTL status invented an expiry: %v", body) |
| + } |
| +} |
diff --git a/docs/demo-spaces.md b/docs/demo-spaces.md| index fa5394d..bf16109 100644 |
| --- a/docs/demo-spaces.md |
| +++ b/docs/demo-spaces.md |
| @@ -134,7 +134,14 @@ cloud service, physics in root controld, IP-adjacent checks in home. |
| 2. **PR 2 — the gates.** IP-bucket semaphore + cooldown in home, adaptive PoW |
| difficulty, demo caps in cloud. |
| 3. **PR 3 — the surface.** Home "Try it now", the countdown banner, the claim |
| - flow (including the quota-blocked variants). |
| + flow (including the quota-blocked variants). *Built:* the home offer + create |
| + path shipped first; the banner + claim landed next as the in-space band |
| + ("Keep this space" → home's `#claim=<host>` screen). The banner trusts the |
| + HOME server's live status (`GET /api/spaces/demo-status`, relaying the |
| + cloud's per-host read), not the boot-time entitlement env — a claim flips |
| + the ledger without restarting the instance, so only the live read can hide |
| + the band. Native apps don't show the band yet (a demo reaching a native app |
| + is an add-device edge case; deliberate web-first scope). |
| 4. **PR 4 — pool coupling.** TTL and live-cap become pressure-tunable once |
| `docs/free-pool.md` PR 3 lands. |
diff --git a/docs/planet-scale-guide/reference.html b/docs/planet-scale-guide/reference.html| index 7f35be6..513ab35 100644 |
| --- a/docs/planet-scale-guide/reference.html |
| +++ b/docs/planet-scale-guide/reference.html |
| @@ -125,6 +125,7 @@ |
| <tr><td><code>LCHAT_CONTROL_HIBERNATE_CREATES</code></td><td>off</td><td><code>1</code> = every provisioner-created space is born hibernating. <a href="spaces.html#hibernating-creates">Part 05</a>.</td></tr> |
| <tr><td><code>LCHAT_MAX_REGISTERED</code></td><td>0 (= 10 × max instances)</td><td>Row ceiling counting hibernating registrations.</td></tr> |
| <tr><td><code>LCHAT_TRASH_KEEP_DAYS</code></td><td>0 (keep forever)</td><td>Age at which archived (deleted) spaces in <code>/data/trash</code> are purged.</td></tr> |
| + <tr><td><code>LCHAT_CONTROL_HOME_ORIGIN</code></td><td>empty (compiled production default)</td><td>Default <code>LCHAT_HOME_ORIGIN</code> for provisioner creates that name none — set on non-production fleets so their spaces point at the right home server (backup nudges, the demo banner's live status read).</td></tr> |
| <tr><td><code>LCHAT_CONTROL_SOCKET</code> / <code>LCHAT_CONTROL_TOKEN_FILE</code></td><td><code>/run/lchat/control.sock</code> / <code>/etc/lchat/control.token</code></td><td>Where it listens / the root-only bearer token gating it.</td></tr> |
| </table> |
| </div> |
diff --git a/home/demo.go b/home/demo.go| index de0a555..1eec7f1 100644 |
| --- a/home/demo.go |
| +++ b/home/demo.go |
| @@ -355,3 +355,103 @@ func (c *cloudProvisioner) claimSpace(host, requester string) (int, map[string]a |
| payload, _ := json.Marshal(map[string]string{"requester": requester}) |
| return c.do("POST", "/api/instances/"+url.PathEscape(host)+"/claim", payload) |
| } |
| + |
| +// demoStatusCache collapses the banner's repeated status reads (one per page |
| +// load and per tab refocus, on every open demo tab) into at most one |
| +// provisioner round trip per host per window. It exists so this public, |
| +// auto-fired read can stay OUT of the shared ceremony rate limiter without |
| +// becoming a relay amplifier at the cloud. Size-bounded: a flood of made-up |
| +// hosts degrades to uncached reads, never to unbounded memory. |
| +type demoStatusCache struct { |
| + mu sync.Mutex |
| + entries map[string]demoStatusEntry |
| +} |
| + |
| +type demoStatusEntry struct { |
| + demo bool |
| + expiresAt int64 // unix seconds; 0 = none reported |
| + until time.Time |
| +} |
| + |
| +const demoStatusTTL = 10 * time.Second |
| +const demoStatusCacheMax = 4096 |
| + |
| +func (c *demoStatusCache) get(host string, now time.Time) (demoStatusEntry, bool) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + e, ok := c.entries[host] |
| + if !ok || now.After(e.until) { |
| + return demoStatusEntry{}, false |
| + } |
| + return e, true |
| +} |
| + |
| +func (c *demoStatusCache) put(host string, e demoStatusEntry, now time.Time) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if c.entries == nil { |
| + c.entries = map[string]demoStatusEntry{} |
| + } |
| + if len(c.entries) >= demoStatusCacheMax { |
| + for h, old := range c.entries { |
| + if now.After(old.until) { |
| + delete(c.entries, h) |
| + } |
| + } |
| + if len(c.entries) >= demoStatusCacheMax { |
| + return // still full of live entries — skip caching this one |
| + } |
| + } |
| + e.until = now.Add(demoStatusTTL) |
| + c.entries[host] = e |
| +} |
| + |
| +// demoStatusHandler: GET /api/spaces/demo-status?host=… — the in-space |
| +// countdown banner's live check. Public and CORS-open on purpose: the banner |
| +// renders on the demo space's own origin before any account exists, and a |
| +// claim flips the cloud ledger without restarting the instance, so this is |
| +// the only truth the banner can trust. It answers "still a demo, and until |
| +// when" and nothing else — claimed, ordinary, and never-existed hosts all |
| +// come back {demo:false}, so it can't probe what exists or who pays. Gated on |
| +// s.cloud only (NOT s.demo): live demos must keep their countdown even if the |
| +// create gate is later switched off. |
| +func (s *Server) demoStatusHandler(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Access-Control-Allow-Origin", "*") |
| + if s.cloud == nil { |
| + writeJSON(w, http.StatusOK, map[string]any{"demo": false}) |
| + return |
| + } |
| + host := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("host"))) |
| + if host == "" || len(host) > 255 { |
| + writeJSON(w, http.StatusBadRequest, map[string]any{ |
| + "error": "bad_request", "message": "host required", |
| + }) |
| + return |
| + } |
| + now := time.Now() |
| + e, hit := s.demoStatus.get(host, now) |
| + if !hit { |
| + status, body, err := s.cloud.demoStatus(host) |
| + if err != nil || status != http.StatusOK { |
| + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "status_failed"}) |
| + return |
| + } |
| + e.demo, _ = body["demo"].(bool) |
| + if exp, ok := body["expires_at"].(float64); ok && e.demo { |
| + e.expiresAt = int64(exp) |
| + } |
| + s.demoStatus.put(host, e, now) |
| + } |
| + // Re-shape rather than relay: only the two fields the banner needs cross |
| + // the public boundary, whatever the provisioner adds later. |
| + out := map[string]any{"demo": e.demo} |
| + if e.demo && e.expiresAt > 0 { |
| + out["expires_at"] = e.expiresAt |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| +} |
| + |
| +// demoStatus forwards one status read to the provisioner. |
| +func (c *cloudProvisioner) demoStatus(host string) (int, map[string]any, error) { |
| + return c.do("GET", "/api/instances/"+url.PathEscape(host)+"/demo-status", nil) |
| +} |
diff --git a/home/demo_test.go b/home/demo_test.go| index 18ad30f..f70ba0b 100644 |
| --- a/home/demo_test.go |
| +++ b/home/demo_test.go |
| @@ -305,3 +305,96 @@ func TestClaimRelay(t *testing.T) { |
| t.Fatalf("weird relay: HTTP %d %v", resp.StatusCode, body) |
| } |
| } |
| + |
| +// ---- the countdown banner's live status read ----------------------------------- |
| + |
| +func TestDemoStatusRelay(t *testing.T) { |
| + cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "demo": true, "expires_at": 1999999999, "internal": "never-relayed", |
| + }) |
| + ts, _ := newDemoTestServer(t, cloud.URL) |
| + |
| + get := func(query string) (*http.Response, map[string]any) { |
| + t.Helper() |
| + resp, err := http.Get(ts.URL + "/api/spaces/demo-status" + query) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var m map[string]any |
| + _ = json.NewDecoder(resp.Body).Decode(&m) |
| + return resp, m |
| + } |
| + |
| + // No auth needed; host required. |
| + if resp, body := get(""); resp.StatusCode != http.StatusBadRequest || body["error"] != "bad_request" { |
| + t.Fatalf("no host: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + |
| + // Happy path: re-shaped to exactly the banner's two fields, CORS-open (the |
| + // banner fetches from the demo space's own origin), forwarded to the |
| + // cloud's per-host route. |
| + resp, body := get("?host=8OE.11ch.at") |
| + if resp.StatusCode != http.StatusOK || body["demo"] != true { |
| + t.Fatalf("status: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + if exp, _ := body["expires_at"].(float64); int64(exp) != 1999999999 { |
| + t.Fatalf("expires_at = %v", body["expires_at"]) |
| + } |
| + if _, leaked := body["internal"]; leaked { |
| + t.Fatalf("relayed a field beyond the banner's two: %v", body) |
| + } |
| + if resp.Header.Get("Access-Control-Allow-Origin") != "*" { |
| + t.Fatal("missing CORS header — the banner's cross-origin fetch would fail") |
| + } |
| + if (*captured).URL.Path != "/api/instances/8oe.11ch.at/demo-status" { |
| + t.Fatalf("forwarded path = %s (host not lowercased?)", (*captured).URL.Path) |
| + } |
| + |
| + // Repeats inside the cache window never re-hit the provisioner (the read |
| + // auto-fires per tab load/refocus) — and the endpoint sits outside the |
| + // shared ceremony limiter, so a burst can't 429 (drained tokens would |
| + // starve sign-in, since the router funnels every caller into one bucket). |
| + ts4, s := newDemoTestServer(t, cloud.URL) |
| + s.limiter = newRateLimiter(0, 0) // every rateLimited endpoint would 429 |
| + for i := 0; i < 3; i++ { |
| + if resp, body := getFrom(t, ts4, "/api/spaces/demo-status?host=cached.11ch.at"); resp.StatusCode != http.StatusOK || body["demo"] != true { |
| + t.Fatalf("read %d: HTTP %d %v", i, resp.StatusCode, body) |
| + } |
| + } |
| + cloud.Close() // cached answers must not need the upstream at all |
| + if resp, body := getFrom(t, ts4, "/api/spaces/demo-status?host=cached.11ch.at"); resp.StatusCode != http.StatusOK || body["demo"] != true { |
| + t.Fatalf("cached read after upstream gone: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + |
| + // A non-demo answer carries no expiry even if the upstream sent one. |
| + claimed, _, _ := fakeCloud(t, http.StatusOK, map[string]any{"demo": false, "expires_at": 123}) |
| + ts2, _ := newDemoTestServer(t, claimed.URL) |
| + resp, body = getFrom(t, ts2, "/api/spaces/demo-status?host=x.11ch.at") |
| + if resp.StatusCode != http.StatusOK || body["demo"] != false { |
| + t.Fatalf("claimed status: HTTP %d %v", resp.StatusCode, body) |
| + } |
| + if _, present := body["expires_at"]; present { |
| + t.Fatalf("claimed status leaked an expiry: %v", body) |
| + } |
| + |
| + // No provisioner wired = no demos, quietly. |
| + ts3, _ := newDemoTestServer(t, "") |
| + resp, body = getFrom(t, ts3, "/api/spaces/demo-status?host=x.11ch.at") |
| + if resp.StatusCode != http.StatusOK || body["demo"] != false { |
| + t.Fatalf("no-cloud status: HTTP %d %v", resp.StatusCode, body) |
| + } |
| +} |
| + |
| +// getFrom fetches a JSON GET off one of the harness servers. |
| +func getFrom(t *testing.T, ts *httptest.Server, path string) (*http.Response, map[string]any) { |
| + t.Helper() |
| + resp, err := http.Get(ts.URL + path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var m map[string]any |
| + _ = json.NewDecoder(resp.Body).Decode(&m) |
| + return resp, m |
| +} |
diff --git a/home/main.go b/home/main.go| index 7f22837..81f48b6 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -58,6 +58,9 @@ type Server struct { |
| // 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 |
| + // demoStatus caches the countdown banner's live status reads (demo.go) — |
| + // zero value ready. |
| + demoStatus demoStatusCache |
| // origin is this server's public origin (also the WebAuthn RP origin) — |
| // used to build the checkout return URLs. |
| origin string |
| @@ -309,6 +312,14 @@ func (s *Server) handler() http.Handler { |
| // 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)) |
| + // The countdown banner's live "still a demo?" read — public + CORS-open |
| + // (it renders on the demo space's origin, pre-account); discloses only |
| + // demo-ness + expiry, never existence or ownership. Deliberately OUTSIDE |
| + // the shared ceremony limiter: this read auto-fires on every demo-tab load |
| + // and refocus, and behind the router every caller shares one RemoteAddr |
| + // bucket — it would drain the tokens sign-in ceremonies need. A short |
| + // per-host cache bounds the load instead (demoStatusCache). |
| + mux.HandleFunc("GET /api/spaces/demo-status", s.demoStatusHandler) |
| // 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 5ce5915..9a04361 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -519,6 +519,23 @@ |
| <p class="status" id="upgrade-status"></p> |
| </div> |
| + <!-- Keep a demo space: the in-space countdown banner deep-links here as |
| + #claim=<host>. One confirm, one call — the provisioner does the real |
| + work (ownership, quota, expiry guard); success folds the space into |
| + this account's list exactly like a create. --> |
| + <div id="claim" class="hidden screen"> |
| + <button id="claim-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button> |
| + <h2 class="create-title">Keep <span id="claim-host"></span></h2> |
| + <p class="create-sub">Claiming stops this demo space's 24-hour countdown — |
| + it becomes one of your free spaces, saved to your list, and everything |
| + in it stays.</p> |
| + <div class="actions"> |
| + <button id="claim-go" type="button">Keep this space</button> |
| + <a id="claim-open" class="btn-link hidden" href="#">Open your space</a> |
| + </div> |
| + <p class="status" id="claim-status"></p> |
| + </div> |
| + |
| <!-- The archive: spaces you've set aside, hidden from the main list. Bring one |
| back (unarchive), or delete it forever behind a stern typed-confirmation — |
| "delete your access" (held while the space reports this is its ONLY admin |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 80eff93..6fae43e 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -100,6 +100,10 @@ if (upgradedHost) history.replaceState(null, "", location.pathname + location.ha |
| // A space's own menu deep-links here as #upgrade=<host>; consumed once the |
| // provisioner ledger is in hand (before sign-in it just waits in the fragment). |
| let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase(); |
| +// A demo space's countdown banner deep-links here as #claim=<host>; waits in |
| +// the fragment through sign-in — a demo needs no account, so its claimant |
| +// often has to create their sign-in first, and the claim resumes after. |
| +let claimLinkHost = ((location.hash.match(/^#claim=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase(); |
| // Ceremony options are pre-fetched so the button click reaches |
| // navigator.credentials.* synchronously — Safari drops the user activation |
| @@ -249,6 +253,7 @@ function signOut() { |
| $("vault").classList.add("hidden"); |
| $("create").classList.add("hidden"); |
| $("upgrade").classList.add("hidden"); |
| + $("claim").classList.add("hidden"); |
| $("archive").classList.add("hidden"); |
| archiving = null; |
| clearUndo(); |
| @@ -330,6 +335,10 @@ function showVault() { |
| upgradedHost = ""; |
| setTimeout(() => { provisioned = null; if (vault && !$("vault").classList.contains("hidden")) showVault(); }, 5000); |
| } |
| + |
| + // A pending #claim=<host> deep link resumes here, the first vault render |
| + // after sign-in — it needs only the account, not the provisioner ledger. |
| + consumeClaimLink(); |
| } |
| // refreshProvisioned learns which spaces this account created and their |
| @@ -768,6 +777,81 @@ async function startUpgrade(host, tier, box) { |
| } |
| } |
| +// ---- keep (claim) a demo space -------------------------------------------------- |
| + |
| +function claimStatus(msg, kind) { |
| + const el = $("claim-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| + |
| +// consumeClaimLink honours a #claim=<host> deep link (the demo banner's "Keep |
| +// this space") once signed in: opens the claim screen for that host. Cleared |
| +// either way, so a stale fragment can't re-fire the screen on later renders. |
| +function consumeClaimLink() { |
| + if (!claimLinkHost || !vault) return; |
| + const host = claimLinkHost; |
| + claimLinkHost = ""; |
| + history.replaceState(null, "", location.pathname + location.search); |
| + openClaim(host); |
| +} |
| + |
| +// openClaim shows the one-confirm claim screen. No pre-checks here — the |
| +// provisioner's answers (quota, not-a-demo, expired) are member-facing and |
| +// shown verbatim, so asking twice would only add a failure mode. |
| +function openClaim(host) { |
| + $("vault").classList.add("hidden"); |
| + $("claim").classList.remove("hidden"); |
| + $("claim-host").textContent = host; |
| + claimStatus(""); |
| + const go = $("claim-go"); |
| + go.hidden = false; |
| + go.disabled = false; |
| + go.onclick = () => doClaim(host, go); |
| + $("claim-open").classList.add("hidden"); |
| +} |
| + |
| +function closeClaim() { |
| + $("claim").classList.add("hidden"); |
| + showVault(); |
| +} |
| + |
| +// doClaim is the real call: home relays to the provisioner (ownership |
| +// transfer, quota, expiry guard), then the space folds into this account's |
| +// encrypted list exactly like a create — sign-in always finds the way back. |
| +async function doClaim(host, btn) { |
| + btn.disabled = true; |
| + claimStatus("Claiming " + host + "…", "busy"); |
| + try { |
| + const res = await fetch("/api/spaces/claim", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify({ host }), |
| + }); |
| + const body = await res.json().catch(() => ({})); |
| + if (!res.ok) throw new Error(body.message || body.error || `Couldn't claim this space (HTTP ${res.status}).`); |
| + const url = body.url || "https://" + host; |
| + vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| + vault.obj.servers.push({ url, name: host }); |
| + vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins |
| + try { |
| + await saveVault(); |
| + } catch { |
| + // The claim stuck even if the vault save raced; the entry lands on the |
| + // next successful save. |
| + } |
| + provisioned = null; // the ledger now lists this space as this account's |
| + claimStatus("This space is yours now — the countdown is off, and it's saved to your list.", "ok"); |
| + btn.hidden = true; |
| + const open = $("claim-open"); |
| + open.href = url; |
| + open.classList.remove("hidden"); |
| + } catch (e) { |
| + claimStatus(e.message || "Couldn't claim this space.", "error"); |
| + btn.disabled = false; |
| + } |
| +} |
| + |
| // openPortal hands off to Stripe's Billing Portal for one space — cancel, |
| // change card, invoices. Same-tab round trip; the portal's Return link comes |
| // back here. |
| @@ -1330,6 +1414,13 @@ const onCreatePasskeyError = (err) => { |
| setSignedOutBusy(false); |
| }; |
| +// Arriving from a demo banner's "Keep this space" while signed out: say why |
| +// you're looking at a sign-in page. The fragment waits; the claim screen |
| +// opens on the first vault render after sign-in (or after creating one). |
| +if (claimLinkHost && !(state.token && state.dek)) { |
| + setStatus("Sign in — or create your sign-in — to keep your demo space.", ""); |
| +} |
| + |
| if (!window.PublicKeyCredential) { |
| setStatus("This browser can't sign you in — try another browser.", "error"); |
| setSignedOutBusy(true); |
| @@ -1348,6 +1439,7 @@ $("create-done").addEventListener("click", closeCreate); |
| // (Creating a space is the fader now — mounted in openCreate; see fader.js.) |
| $("archive-back").addEventListener("click", closeArchive); |
| $("upgrade-back").addEventListener("click", closeUpgrade); |
| +$("claim-back").addEventListener("click", closeClaim); |
| $("archive-cancel").addEventListener("click", closeArchiveStern); |
| $("delete-access").addEventListener("click", deleteAccess); |
| $("destroy-forever").addEventListener("click", destroyForEveryone); |
diff --git a/infra/dev/compute.tf b/infra/dev/compute.tf| index a21d1e1..2a0487e 100644 |
| --- a/infra/dev/compute.tf |
| +++ b/infra/dev/compute.tf |
| @@ -26,7 +26,7 @@ resource "aws_key_pair" "dev_box" { |
| variable "ssh_ingress_cidr" { |
| description = "Where SSH is allowed from (the operator's current IP/32)" |
| type = string |
| - default = "109.255.91.23/32" |
| + default = "109.255.206.133/32" |
| } |
| resource "aws_security_group" "dev_box" { |
diff --git a/main.go b/main.go| index b99c314..46e6d91 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -84,9 +84,11 @@ type Server struct { |
| // the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment. |
| homeOrigin string |
| // The cloud ledger's plan flag, mirrored into this instance's env by the |
| - // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"). Gates the space menu's |
| - // Upgrade entry; empty on self-hosted/operator instances, which have no |
| - // billing surface and show nothing. |
| + // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"/"demo"). Gates the space |
| + // menu's Upgrade entry (free) and the demo countdown band (demo); empty on |
| + // self-hosted/operator instances, which have no billing surface and show |
| + // nothing. Display only — a claim flips the ledger without restarting this |
| + // process, so the band verifies against home's live status before showing. |
| entitlement string |
| // Where this instance's webhook-ingest sidecar is reachable from outside |
| // (LCHAT_HOOKS_BASE, e.g. "http://127.0.0.1:8090" in dev). Empty — the |
diff --git a/meter.go b/meter.go| index 7b17ee5..35b39dc 100644 |
| --- a/meter.go |
| +++ b/meter.go |
| @@ -276,8 +276,9 @@ func updateInstanceEnv(envPath string, mutate func(env map[string]string)) error |
| // setInstanceQuota upserts LCHAT_BLOB_QUOTA_BYTES in an instance's env file |
| // (0 removes it — back to the compiled default) and, when entitlement is |
| -// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid" — the UI's billing- |
| -// affordance gate) in the same write, preserving every other var. |
| +// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid"/"demo" — the UI's |
| +// billing-affordance / demo-band gate) in the same write, preserving every |
| +// other var. |
| func setInstanceQuota(envPath string, quota int64, entitlement string) error { |
| return updateInstanceEnv(envPath, func(env map[string]string) { |
| if quota <= 0 { |
diff --git a/test/format.test.mjs b/test/format.test.mjs| index 65551ce..5980410 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| import assert from "node:assert/strict"; |
| -import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs } from "../web/static/format.js"; |
| +import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js"; |
| import { emojify, emojiMatches } from "../web/static/emoji.js"; |
| let failures = 0; |
| @@ -473,6 +473,21 @@ await test("emphasis markers around a URL never ride into the href", () => { |
| assert.ok(!link2.href.endsWith("*")); |
| }); |
| +await test("formatCountdown: whole units, ceiled, never optimistic", () => { |
| + const MIN = 60000; |
| + assert.equal(formatCountdown(0), "under a minute"); |
| + assert.equal(formatCountdown(30 * 1000), "under a minute"); |
| + assert.equal(formatCountdown(MIN), "under a minute"); |
| + assert.equal(formatCountdown(MIN + 1), "2m"); // ceil: the label outlives the space, not vice versa |
| + assert.equal(formatCountdown(45 * MIN), "45m"); |
| + assert.equal(formatCountdown(59 * MIN), "59m"); |
| + assert.equal(formatCountdown(59 * MIN + 1), "1h"); // 59m + a tick ceils to the hour |
| + assert.equal(formatCountdown(60 * MIN), "1h"); |
| + assert.equal(formatCountdown(60 * MIN + 1), "1h 1m"); |
| + assert.equal(formatCountdown(23 * 60 * MIN + 40 * MIN), "23h 40m"); |
| + assert.equal(formatCountdown(24 * 60 * MIN), "24h"); |
| +}); |
| + |
| await test("bare domains glued mid-token stay plain (the lookbehind rule)", () => { |
| // Mid-word, email, and mid-hyphenated-token candidates never link — and a |
| // hyphenated host still links whole, from its clean start. |
diff --git a/web/pages/index.html b/web/pages/index.html| index be18337..df24149 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -26,9 +26,10 @@ |
| is public, so exposing it here is fine. --> |
| <script nonce="__CSP_NONCE__"> |
| window.__homeOrigin = "__HOME_ORIGIN__"; |
| - // The provisioner-pushed plan flag ("free"/"paid"; empty when self-hosted). |
| - // shell.js reveals the menu's Upgrade entry from it — display only, nothing |
| - // here enforces billing. |
| + // The provisioner-pushed plan flag ("free"/"paid"/"demo"; empty when |
| + // self-hosted). shell.js reveals the menu's Upgrade entry (free) or the |
| + // demo countdown band (demo) from it — display only, nothing here enforces |
| + // billing or the demo TTL. |
| window.__entitlement = "__ENTITLEMENT__"; |
| </script> |
| <!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in |
| @@ -126,6 +127,11 @@ |
| top band shown ONLY when this space fails an integrity check. Inline |
| (occupies layout, never overlays); contents built in shell.js. --> |
| <div class="integrity-banner hidden" id="integrity-banner" role="alert"></div> |
| + <!-- Demo countdown band (docs/demo-spaces.md): shown only on a demo space. |
| + Inline (occupies layout, never overlays); contents built in shell.js |
| + from the HOME server's live status — the boot-time entitlement can go |
| + stale the moment someone claims the space. --> |
| + <div class="demo-banner hidden" id="demo-banner"></div> |
| <div class="tt-body"> |
| <aside class="sidebar"> |
| <header class="side-head"> |
diff --git a/web/static/format.js b/web/static/format.js| index 3b1c12d..4465d20 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -379,3 +379,16 @@ export function relTime(sec) { |
| if ((now - d) / 86400000 < 7) return d.toLocaleDateString([], { weekday: "short" }); |
| return d.toLocaleDateString([], { day: "2-digit", month: "2-digit" }); |
| } |
| + |
| +// formatCountdown renders a remaining duration as a compact whole-unit |
| +// countdown for the demo banner: "23h 40m" above an hour, "40m" under one, |
| +// "under a minute" at the end. Minutes are ceiled so the promise is never |
| +// optimistic — the space outlives the label, not the other way round. |
| +export function formatCountdown(ms) { |
| + const mins = Math.ceil(ms / 60000); |
| + if (mins <= 1) return "under a minute"; |
| + if (mins < 60) return mins + "m"; |
| + const h = Math.floor(mins / 60); |
| + const m = mins % 60; |
| + return m ? h + "h " + m + "m" : h + "h"; |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index e234865..87a1902 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -48,7 +48,7 @@ import { |
| capAllows, |
| appReloadMessage, |
| } from "/static/ui.js"; |
| -import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime } from "/static/format.js"; |
| +import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { startBootScene, finishBootScene } from "/static/bootscene.js"; |
| import { processImage, videoMeta } from "/static/media.js"; |
| @@ -2659,6 +2659,76 @@ function renderApp() { |
| ); |
| } |
| + // Demo countdown band (docs/demo-spaces.md): a demo space wears its 24-hour |
| + // death across the top — disclosed from the first second, with the one happy |
| + // exit (claim it into an account, on the home page) a tap away. The boot-time |
| + // flag only says "born a demo": a claim flips the ledger WITHOUT restarting |
| + // this instance, so the HOME server's live status decides whether the band |
| + // shows at all, and hides it when the user comes back from claiming. |
| + if (window.__entitlement === "demo" && window.__homeOrigin && !embed) { |
| + const banner = document.getElementById("demo-banner"); |
| + const text = document.createElement("span"); |
| + text.className = "demo-banner-text"; |
| + let ticker = 0; |
| + const dismiss = () => { |
| + banner.classList.add("hidden"); |
| + if (ticker) clearInterval(ticker); |
| + ticker = 0; |
| + }; |
| + const paint = (expiresAt) => { |
| + const left = expiresAt ? expiresAt * 1000 - Date.now() : NaN; |
| + if (Number.isNaN(left)) { |
| + text.textContent = "This is a demo space — it disappears 24 hours after it was created."; |
| + } else if (left <= 0) { |
| + text.textContent = "This demo has ended — the space will disappear shortly."; |
| + banner.classList.add("demo-over"); |
| + } else { |
| + text.textContent = "This is a demo space — it disappears in " + formatCountdown(left) + "."; |
| + } |
| + }; |
| + const check = async () => { |
| + let expiresAt = 0; // unix seconds; 0 = status unreachable (generic copy) |
| + try { |
| + const res = await fetch( |
| + window.__homeOrigin + "/api/spaces/demo-status?host=" + encodeURIComponent(location.host), |
| + ); |
| + if (!res.ok) throw new Error(); |
| + const body = await res.json(); |
| + if (!body.demo) return dismiss(); // claimed — the space is safe now |
| + expiresAt = body.expires_at || 0; |
| + } catch { |
| + /* home unreachable — the death is still the contract; show the band */ |
| + } |
| + if (!banner.contains(text)) { |
| + banner.append(text); |
| + // Claiming is free (not a purchase), but the native wrappers keep all |
| + // account plumbing out-of-app for now — same posture as Upgrade above. |
| + if (!inNativeWrapper) { |
| + const keep = document.createElement("a"); |
| + keep.className = "demo-banner-keep"; |
| + keep.textContent = "Keep this space"; |
| + keep.href = window.__homeOrigin + "/#claim=" + encodeURIComponent(location.host); |
| + keep.target = "_blank"; |
| + keep.rel = "noopener"; |
| + banner.append(keep); |
| + } |
| + } |
| + paint(expiresAt); |
| + banner.classList.remove("hidden"); |
| + if (ticker) clearInterval(ticker); |
| + if (expiresAt) ticker = setInterval(() => paint(expiresAt), 30_000); |
| + }; |
| + check(); |
| + // The claim happens in another tab; re-check when this one comes back into |
| + // view so the band vanishes the moment the space is actually theirs. |
| + // (renderApp can re-mount the template — a listener over a replaced band |
| + // retires itself via isConnected.) |
| + document.addEventListener("visibilitychange", () => { |
| + if (!banner.isConnected) return; |
| + if (document.visibilityState === "visible" && !banner.classList.contains("hidden")) check(); |
| + }); |
| + } |
| + |
| // Notifications toggle (hidden where push isn't usable, e.g. iOS before install). |
| const notif = document.getElementById("menu-notif"); |
| const refreshNotif = async () => { |
diff --git a/web/static/style.css b/web/static/style.css| index 2e6b027..eec546a 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -2061,6 +2061,48 @@ input.switch:disabled { opacity: 0.5; cursor: default; } |
| .integrity-banner { flex-wrap: wrap; } |
| .integrity-banner .ib-actions { width: 100%; } |
| } |
| + |
| +/* Demo countdown band: same full-width top-band idiom as the integrity banner, |
| + but informational, not an alarm — accent-tinted while the demo runs, amber |
| + only once it has ended. Occupies layout, never overlays. */ |
| +.demo-banner { |
| + flex: none; |
| + display: flex; |
| + align-items: center; |
| + justify-content: center; |
| + flex-wrap: wrap; |
| + gap: 8px 14px; |
| + padding: 9px 16px; |
| + padding-top: calc(9px + env(safe-area-inset-top, 0px)); |
| + background: color-mix(in srgb, var(--accent) 12%, var(--panel)); |
| + border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); |
| + color: var(--ink); |
| + font-size: 13.5px; |
| + line-height: 1.45; |
| + text-align: center; |
| +} |
| +.demo-banner.hidden { display: none; } |
| +.demo-banner .demo-banner-text { font-variant-numeric: tabular-nums; } |
| +.demo-banner .demo-banner-keep { |
| + font: inherit; |
| + font-weight: 600; |
| + border-radius: 8px; |
| + padding: 4px 12px; |
| + min-height: 24px; |
| + display: inline-flex; |
| + align-items: center; |
| + text-decoration: none; |
| + background: var(--accent); |
| + color: #fff; |
| +} |
| +.demo-banner .demo-banner-keep:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| +.demo-banner.demo-over { |
| + background: color-mix(in srgb, #d97706 16%, var(--panel)); |
| + border-bottom-color: color-mix(in srgb, #d97706 45%, transparent); |
| +} |
| +@media (prefers-color-scheme: dark) { |
| + .demo-banner.demo-over { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); } |
| +} |
| /* floating connection-status pill (only shown during a sustained outage) */ |
| .conn-status { |
| position: fixed; |