eleven / messenger Public

server+web: plan choice moves into Eleven's conversation; create is never pay-gated (#1374) (#1379)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1e7a47e9bc688d397e7a75da0f16f09b219e9cd0 parent ca83667
14 files changed, +760 −40
  • changelog/2026-07-28-plan-in-conversation.md +9 −0
  • cloud.go +15 −0
  • cloud_test.go +53 −0
  • docs/cloud-provisioner.md +9 −0
  • docs/conversational-onboarding.md +8 −2
  • home/main.go +6 −0
  • home/pricebook.go +74 −0
  • home/pricebook_test.go +78 −0
  • home/web/pages/index.html +8 −8
  • home/web/static/home.js +56 −18
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +162 −1
  • test/flow.test.mjs +99 −2
  • web/static/apps/setup.js +68 −8
  • web/static/setup.flow.js +115 −1
diff --git a/changelog/2026-07-28-plan-in-conversation.md b/changelog/2026-07-28-plan-in-conversation.md
new file mode 100644
index 0000000..bfc605a
--- /dev/null
+++ b/changelog/2026-07-28-plan-in-conversation.md
@@ -0,0 +1,9 @@
+๐Ÿค– Creating a space is back to two beats: name it, slide โ€” and it exists,
+immediately, every time. Choosing how it's paid for now happens where it
+belongs: in your first conversation with Eleven, which asks "who is this
+space for?" and shows the real prices for a team, a community, or family.
+Pick one and the payment waits as a tap on the finish line โ€” your space runs
+free until you take it. Keep it free and that's a first-class answer too.
+The create screen only ever shows plans when paying is genuinely the way
+through (you've used your free space, or the free pool is full for the
+moment).
diff --git a/cloud.go b/cloud.go
index e495ef3..b7e8e8a 100644
--- a/cloud.go
+++ b/cloud.go
@@ -151,6 +151,9 @@ func (s *cloudServer) handler() http.Handler {
// The provisioning surface: bearer caller token (a home server).
mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler))
mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler))
+ // The price book alone, without a requester's ledger โ€” home republishes it
+ // unauthenticated for the setup conversation's plan step (#1374).
+ mux.HandleFunc("GET /api/pricebook", s.callerAuth(s.pricebookHandler))
mux.HandleFunc("DELETE /api/instances/{host}", s.callerAuth(s.deleteSpaceHandler))
// Billing (optional โ€” 501 unless Stripe is configured; see billing.go). The
// webhook is signature-gated rather than token-gated: Stripe calls it.
@@ -532,6 +535,18 @@ func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": billing})
}
+// pricebookHandler: GET /api/pricebook โ€” the public price book, same shape as
+// the listing's billing key ({tiers} or false when billing is off). It exists
+// so home can serve the book WITHOUT a signed-in requester: the conversational
+// plan step (#1374) runs on the space origin, which holds no home session.
+func (s *cloudServer) pricebookHandler(w http.ResponseWriter, r *http.Request) {
+ billing := any(false)
+ if s.billing != nil {
+ billing = map[string]any{"tiers": s.billing.publicTiers()}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"billing": billing})
+}
+
// ---- admin: caller tokens ---------------------------------------------------
func (s *cloudServer) mintTokenHandler(w http.ResponseWriter, r *http.Request) {
diff --git a/cloud_test.go b/cloud_test.go
index d0b3148..44c3ca4 100644
--- a/cloud_test.go
+++ b/cloud_test.go
@@ -702,3 +702,56 @@ func TestCloudCreatePassesName(t *testing.T) {
t.Errorf("61-char name: %d, want 400", r.StatusCode)
}
}
+
+// The public price book endpoint (#1374): the book alone, caller-token gated,
+// billing:false when Stripe isn't configured.
+func TestPricebookHandler(t *testing.T) {
+ srv, s := newTestCloud(t, okControl(t))
+ tok := mintCaller(t, srv, "home")
+
+ get := func() (int, map[string]any) {
+ req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+ 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.StatusCode, m
+ }
+
+ // Billing off: the same "no Upgrade UI" signal the listing sends.
+ status, body := get()
+ if status != http.StatusOK {
+ t.Fatalf("status %d", status)
+ }
+ if b, ok := body["billing"].(bool); !ok || b {
+ t.Fatalf("billing = %v, want false with Stripe unconfigured", body["billing"])
+ }
+
+ // Billing on: the tiers ride out in publicTiers shape.
+ s.billing = &stripeBilling{tiers: []billingTier{{Key: "family", Name: "Family", Label: "โ‚ฌ5.50 a year", Price: "price_f"}}}
+ _, body = get()
+ b, ok := body["billing"].(map[string]any)
+ if !ok {
+ t.Fatalf("billing = %v, want the tier map", body["billing"])
+ }
+ tiers, _ := b["tiers"].([]any)
+ if len(tiers) != 1 || tiers[0].(map[string]any)["key"] != "family" {
+ t.Fatalf("tiers = %v, want the configured family tier", tiers)
+ }
+
+ // No caller token โ†’ the gate holds (this is not a public endpoint here;
+ // HOME republishes it publicly).
+ req, _ := http.NewRequest("GET", srv.URL+"/api/pricebook", nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no-token status %d, want 401", resp.StatusCode)
+ }
+}
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index ea1742a..4a7b744 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -540,6 +540,15 @@ deletion drops the space back to `free`). The row's numeric ledger id rides in
the session's `client_reference_id` and the subscription's metadata, the tier
key in both metadata blobs โ€” that's how events route back to a space.
+Creating a space itself is **never pay-gated** (#1374): the create step is
+just the name, and "who is this space for?" is asked by Eleven inside the
+space, priced from the cloud's `GET /api/pricebook` (caller-token'd; home
+republishes it unauthenticated and CORS-open at its own `/api/pricebook`,
+cached, for space origins to read). The create screen's tier cards appear
+only when a create bounces off the free quota or the pool gate โ€” the two
+errors paying genuinely fixes; picking one there runs the pay-first
+checkout-create path below.
+
**What paying does today:**
- a paid space **doesn't count toward the creation quota** (`allocateSpace`
diff --git a/docs/conversational-onboarding.md b/docs/conversational-onboarding.md
index 7d6cbe4..65ce046 100644
--- a/docs/conversational-onboarding.md
+++ b/docs/conversational-onboarding.md
@@ -144,8 +144,14 @@ role-based auth):
hosted instances it takes effect; on self-hosted it's a preference the
sidecar respects when one exists.
6. **Can people invite other people?** โ†’ `user_invites` (exists).
-7. **The key ceremony** (next section) โ€” deliberately last, so it's the note
- the setup ends on.
+7. **Who is this space for?** (#1374) โ€” the plan question, moved here from the
+ create flow so creating is never pay-gated. Web-only, and only when home's
+ public `/api/pricebook` answers with tiers (native wrappers and self-hosted
+ spaces never see it); a paid answer becomes a "Set up your plan" chip on
+ the finish line that hands off to home's upgrade picker. The space runs
+ free until that tap.
+8. **The key ceremony** (next section) โ€” deliberately near-last, so it's the
+ note the setup ends on.
On completion the script sets `setup_complete` and hands over: "you can change
any of this later โ€” I've put an **Admin** app in your sidebar."
diff --git a/home/main.go b/home/main.go
index 6f90a9c..44a2adc 100644
--- a/home/main.go
+++ b/home/main.go
@@ -89,6 +89,9 @@ type Server struct {
// fx caches the ECB reference rates behind the picker's โ‰ˆ display
// (fx.go) โ€” zero value ready (empty until the loop's first fetch).
fx fxCache
+ // pricebook caches the provisioner's public price book for the
+ // unauthenticated /api/pricebook (pricebook.go) โ€” zero value ready.
+ pricebook pricebookCache
// demoStatus caches the countdown banner's live status reads (demo.go) โ€”
// zero value ready.
demoStatus demoStatusCache
@@ -456,6 +459,9 @@ func (s *Server) handler() http.Handler {
// budget with anyone (the write side is rate-limited like the begins).
mux.HandleFunc("POST /api/enroll-done", s.rateLimited(s.enrollDoneHandler))
mux.HandleFunc("GET /api/enroll-status", s.enrollStatusHandler)
+ // The public price book (pricebook.go): read by SPACE origins' browser JS
+ // (the setup conversation's plan step, #1374) โ€” no auth, CORS-open.
+ mux.HandleFunc("GET /api/pricebook", s.pricebookHandler)
// The blob: read/write with a link token; delete only with a fresh ceremony.
mux.HandleFunc("GET /api/blob", s.getBlobHandler)
diff --git a/home/pricebook.go b/home/pricebook.go
new file mode 100644
index 0000000..488069d
--- /dev/null
+++ b/home/pricebook.go
@@ -0,0 +1,74 @@
+package main
+
+// The public price book (#1374). The setup conversation's plan step runs on
+// the SPACE origin, which holds no home session โ€” so the book must be
+// readable without auth. Prices are public information (the same figures
+// render on this page's own pickers); the payload carries nothing else, and
+// an empty book simply means the step doesn't happen. Cached so a burst of
+// setups never fans out into the provisioner, and served stale on refresh
+// errors so a provisioner blip can't blank a conversation mid-flight.
+
+import (
+ "net/http"
+ "sync"
+ "time"
+)
+
+const pricebookTTL = 10 * time.Minute
+
+type pricebookCache struct {
+ mu sync.Mutex
+ tiers []any
+ fetchedAt time.Time
+}
+
+// pricebookTiers returns the cached book, refreshing from the default
+// region's provisioner (any region as fallback) when stale. Never errors:
+// no cloud, billing off, or an unreachable provisioner with no prior copy
+// all come back as an empty book.
+func (s *Server) pricebookTiers() []any {
+ c := &s.pricebook
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if time.Since(c.fetchedAt) < pricebookTTL {
+ return c.tiers
+ }
+ // Default region first โ€” it's the book's source of truth (the same
+ // preference the spaces listing applies to its billing key).
+ clouds := s.allClouds()
+ if s.cloud != nil {
+ ordered := []*cloudProvisioner{s.cloud}
+ for _, cl := range clouds {
+ if cl != s.cloud {
+ ordered = append(ordered, cl)
+ }
+ }
+ clouds = ordered
+ }
+ for _, cl := range clouds {
+ status, body, err := cl.do("GET", "/api/pricebook", nil)
+ if err != nil || status != http.StatusOK {
+ continue
+ }
+ tiers := []any{}
+ if b, ok := body["billing"].(map[string]any); ok {
+ if arr, ok := b["tiers"].([]any); ok {
+ tiers = arr
+ }
+ }
+ c.tiers, c.fetchedAt = tiers, time.Now()
+ return c.tiers
+ }
+ if c.fetchedAt.IsZero() {
+ return []any{} // nothing cached and nothing reachable โ€” an empty book, not an error
+ }
+ return c.tiers // stale copy beats a blanked conversation step
+}
+
+// pricebookHandler: GET /api/pricebook โ€” public and CORS-open (the caller is
+// a space origin's browser JS). {tiers:[โ€ฆ]}; empty = billing off.
+func (s *Server) pricebookHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ writeJSON(w, http.StatusOK, map[string]any{"tiers": s.pricebookTiers()})
+}
diff --git a/home/pricebook_test.go b/home/pricebook_test.go
new file mode 100644
index 0000000..79b6bb8
--- /dev/null
+++ b/home/pricebook_test.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+// The public price book (#1374): CORS-open pass-through of the provisioner's
+// tiers, cached, empty when no provisioner is configured.
+func TestPricebookServesTiersCORSOpenAndCached(t *testing.T) {
+ tiers := []map[string]any{{"key": "family", "name": "Family", "label": "โ‚ฌ5.50 a year"}}
+ cloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "billing": map[string]any{"tiers": tiers},
+ })
+ ts, s := newTestServer(t)
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ resp, err := http.Get(ts.URL + "/api/pricebook")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ // The caller is a SPACE origin's browser JS โ€” the endpoint must be open.
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Errorf("Access-Control-Allow-Origin = %q, want *", got)
+ }
+ var body struct {
+ Tiers []map[string]any `json:"tiers"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body.Tiers) != 1 || body.Tiers[0]["key"] != "family" {
+ t.Fatalf("tiers = %v, want the fake cloud's family tier", body.Tiers)
+ }
+
+ // Cached: kill the upstream; the answer must not change within the TTL.
+ cloud.Close()
+ resp2, err := http.Get(ts.URL + "/api/pricebook")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp2.Body.Close()
+ var body2 struct {
+ Tiers []map[string]any `json:"tiers"`
+ }
+ if err := json.NewDecoder(resp2.Body).Decode(&body2); err != nil {
+ t.Fatal(err)
+ }
+ if len(body2.Tiers) != 1 {
+ t.Fatalf("cached tiers = %v, want the earlier copy", body2.Tiers)
+ }
+}
+
+func TestPricebookEmptyWithoutProvisioner(t *testing.T) {
+ ts, _ := newTestServer(t)
+ resp, err := http.Get(ts.URL + "/api/pricebook")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ var body struct {
+ Tiers []map[string]any `json:"tiers"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body.Tiers) != 0 {
+ t.Fatalf("tiers = %v, want empty (no provisioner)", body.Tiers)
+ }
+}
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index f91e5a9..3c4b9c8 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -1071,20 +1071,20 @@
<p class="status" id="combine-status"></p>
</div>
- <!-- Plan a space (create, step 1 of 2): name it and pick who pays. The
- slider screen (step 2) then creates a free space immediately, or hands
- off to Stripe's checkout and the space is created once the payment
- confirms (the claim on return). Tier cards render from the provisioner's
- price book; with billing off the picker vanishes and this is just the
- name step. -->
+ <!-- Name a space (create, step 1 of 2). The slider screen (step 2) then
+ creates the space immediately โ€” always free, never pay-gated: the plan
+ question is Eleven's, inside the space (#1374). The tier cards below
+ appear only when a create bounces off a gate paying genuinely fixes
+ (the free quota, or the pool under budget pressure); picking one hands
+ off to Stripe and the space is created the moment it's paid. -->
<div id="create-plan" class="hidden screen">
<button id="plan-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">Create a space</h2>
<label class="create-sub" for="plan-name">What's your space called?</label>
<input id="plan-name" type="text" maxlength="60" autocomplete="off" placeholder="e.g. The Campbells">
<div id="plan-tiers-wrap" class="hidden">
- <p class="create-sub" id="plan-tiers-lead">And how is it paid for?</p>
- <div id="plan-tiers" role="group" aria-label="How your space is paid for"></div>
+ <p class="create-sub" id="plan-tiers-lead">Pick a plan to create it now:</p>
+ <div id="plan-tiers" role="group" aria-label="Pick a plan"></div>
<p class="fx-note hidden" id="plan-fx"></p>
</div>
<div class="actions">
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 0a08c8b..ed2ea8d 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -149,9 +149,12 @@ let paidCreateRegion = new URLSearchParams(location.search).get("pcregion") || "
if (paidCreateSession) history.replaceState(null, "", location.pathname + location.hash);
// 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).
-// An optional &band=<band> rides along (the space menu doesn't send one, but
-// shared links may).
-let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)(?:&band=[a-z]+)?$/) || [])[1] || "").toLowerCase();
+// Optional &band=<band> and &tier=<key> ride along (the space menu doesn't
+// send them, but shared links and the setup conversation's plan chip do).
+let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)((?:&(?:band=[a-z]+|tier=[a-z0-9_-]+))*)$/) || [])[1] || "").toLowerCase();
+// The conversation's chosen tier (#1374): highlights that card in the picker โ€”
+// a nudge, never a commitment; every card stays one tap from checkout.
+let tierLink = ((location.hash.match(/[#&]tier=([a-z0-9_-]+)/) || [])[1] || "").toLowerCase();
// A shared link pre-selects an honor band (docs/pricing-bands.md ยง The
// picker โ€” "click a link, get the lower price, instantly"): #band=reduced,
// ?band=reduced, or riding an #upgrade fragment. The server honours whatever
@@ -2599,7 +2602,7 @@ function consumeUpgradeLink() {
try { return new URL(sv.url).host.toLowerCase() === target; } catch { return false; }
});
const ledger = provisioned && provisioned[target];
- if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s);
+ if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s, true);
}
// ---- archive: undo banner + entry point --------------------------------------
@@ -2996,7 +2999,9 @@ let fxSnapshot = null; // {date, rates} once /api/fx answers; null before
// openUpgrade shows the plan picker for one space. The payment itself is
// Stripe's hosted page; this screen only chooses the plan and hands off.
-function openUpgrade(s) {
+// fromLink keeps the deep link's &tier= highlight; a manual open drops it.
+function openUpgrade(s, fromLink) {
+ if (!fromLink) tierLink = "";
hideVault();
$("upgrade").classList.remove("hidden");
$("upgrade-host").textContent = hostOf(s.url);
@@ -3095,6 +3100,12 @@ function renderUpgrade(s) {
std.textContent = "standard price";
btn.append(std);
}
+ // The deep link's chosen tier (the setup conversation's plan chip):
+ // highlighted, not auto-bought โ€” checkout stays the user's tap.
+ if (tierLink && tierLink === t.key) {
+ btn.classList.add("selected");
+ btn.setAttribute("aria-pressed", "true");
+ }
btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, view.band, box));
box.append(btn);
}
@@ -3286,7 +3297,11 @@ async function requestSpace() {
const reply = await res.json().catch(() => ({}));
if (!res.ok) {
// Structured errors carry a member-facing message (quota, at-capacity).
- throw new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`);
+ // The code rides along so the fader's error path can tell "paying would
+ // fix this" (quota, pool pressure) from a plain failure (#1374).
+ const err = new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`);
+ err.code = reply.error || "";
+ throw err;
}
const url = reply.url;
const host = new URL(url).host;
@@ -3542,6 +3557,20 @@ function ensureFader() {
},
onReady: (r) => showNewSpaceResult(r.host, r.setupLink),
onError: (err) => {
+ // The two errors paying genuinely fixes โ€” the free quota and the free
+ // pool's budget gate โ€” reopen the plan screen with the paid tiers
+ // showing (#1374). Everything else stays a plain inline error.
+ const code = (err && err.code) || "";
+ if ((code === "quota_exceeded" || code === "free_pool_full") && billingTiers.length) {
+ fader?.stop();
+ $("create").classList.add("hidden");
+ planPaidRequired = true;
+ planTier = billingTiers[0].key;
+ renderPlanTiers();
+ $("create-plan").classList.remove("hidden");
+ planStatus((err && err.message) || "", "error");
+ return;
+ }
$("create-back").classList.remove("hidden");
spaceStatus((err && err.message) || String(err), "error");
},
@@ -3553,18 +3582,25 @@ function ensureFader() {
// free create or the Stripe handoff. tier "" = free.
let planChoice = null;
let planTier = "free";
-
-// openCreate opens step 1: name the space, pick who pays. The slider (step 2)
-// stays the actual commit either way.
+// planPaidRequired: the create bounced off a gate paying genuinely fixes
+// (the free quota, or the pool under budget pressure) โ€” the plan screen
+// re-opens showing the paid tiers. The HAPPY path never shows them: every
+// space is born free, the slider creates it immediately, and "who is this
+// space for?" is Eleven's question inside the space (#1374).
+let planPaidRequired = false;
+
+// openCreate opens step 1: name the space. The slider (step 2) stays the
+// actual commit.
function openCreate() {
clearUndo();
planChoice = null;
planTier = "free";
+ planPaidRequired = false;
$("plan-name").value = "";
$("plan-continue").disabled = true;
planStatus("");
- // The picker needs the price book; kick the lazy ledger read if it hasn't
- // happened yet (renderPlanTiers fills in when it answers).
+ // The ledger read still warms the price book โ€” the paid-required error
+ // path needs it in hand (renderPlanTiers fills in when it answers).
if (spacesEnabled && provisioned === null && state.token) refreshProvisioned();
renderPlanTiers();
hideVault();
@@ -3584,14 +3620,17 @@ function planStatus(msg, kind) {
}
// renderPlanTiers paints the who-pays picker from the provisioner's price
-// book: Free first (subsidised โ€” the honest framing), then each tier at its
-// base price with the โ‰ˆ local figure the upgrade picker also shows. Billing
-// off (self-hosters, or the book briefly unreachable) โ†’ no picker at all;
-// the step is just the name.
+// book โ€” but ONLY in paid-required mode (#1374): the happy path is just the
+// name, and the plan question lives in Eleven's setup conversation. Here the
+// create bounced off the quota or the pool gate, so the paid tiers (no Free
+// card โ€” free is exactly what bounced) are the way through, at base price
+// with the โ‰ˆ local figure the upgrade picker also shows.
function renderPlanTiers() {
const wrap = $("plan-tiers-wrap");
- wrap.classList.toggle("hidden", !billingTiers.length);
- if (!billingTiers.length) return;
+ const showing = planPaidRequired && billingTiers.length;
+ wrap.classList.toggle("hidden", !showing);
+ if (!showing) return;
+ $("plan-tiers-lead").textContent = "Pick a plan to create it now:";
// The โ‰ˆ rates ride the same one-fetch-per-page-life snapshot as the
// upgrade picker; render now, again when they land.
if (!fxSnapshot) {
@@ -3637,7 +3676,6 @@ function renderPlanTiers() {
});
box.append(btn);
};
- card("free", "Free", "โ‚ฌ0", "Kept free by the members who pay for theirs.");
for (const t of billingTiers) {
const view = tierForBand(t, "base");
const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language);
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
index 5740c80..21591b2 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -476,7 +476,7 @@
"label": "invited Eleven's AI in",
"value": "on"
},
- "goto": "keys"
+ "goto": "plan"
},
{
"label": "Not now",
@@ -492,6 +492,155 @@
"label": "kept AI off",
"value": "off"
},
+ "goto": "plan"
+ }
+ ]
+ },
+ {
+ "id": "plan",
+ "skipIf": [
+ "done",
+ "native"
+ ],
+ "skipTo": "keys",
+ "entries": [
+ {
+ "if": "!bridge.planPrices",
+ "goto": "keys"
+ }
+ ],
+ "bubbles": [
+ {
+ "text": "One more question โ€” who is this space for?\n\nEvery space starts free, and free is a real home โ€” kept that way by the spaces that pay. If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).",
+ "key": "q-plan"
+ }
+ ],
+ "chips": [
+ {
+ "if": "bridge.planTeam",
+ "label": "A team",
+ "goto": "plan-team"
+ },
+ {
+ "if": "bridge.planCommunity",
+ "label": "A community",
+ "goto": "plan-community"
+ },
+ {
+ "if": "bridge.planFamily",
+ "label": "Family & friends",
+ "goto": "plan-family"
+ },
+ {
+ "label": "Keep it free",
+ "spent": "Free",
+ "trailing": true,
+ "record": {
+ "label": "kept this space free",
+ "value": "free"
+ },
+ "goto": "keys"
+ }
+ ]
+ },
+ {
+ "id": "plan-team",
+ "bubbles": [
+ {
+ "text": "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} โ€” it scales with the team, and bots are free.\n\nA paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
+ "key": "q-plan-team"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Choose {bridge.planNameTeam}",
+ "spent": "Chosen",
+ "set": {
+ "vars.planTier": "team"
+ },
+ "record": {
+ "step": "plan",
+ "label": "chose the {bridge.planNameTeam} plan โ€” payment waits at the finish line",
+ "value": "team"
+ },
+ "goto": "keys"
+ },
+ {
+ "label": "Keep it free",
+ "spent": "Free",
+ "record": {
+ "step": "plan",
+ "label": "kept this space free",
+ "value": "free"
+ },
+ "goto": "keys"
+ }
+ ]
+ },
+ {
+ "id": "plan-community",
+ "bubbles": [
+ {
+ "text": "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\nA paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
+ "key": "q-plan-community"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Choose {bridge.planNameCommunity}",
+ "spent": "Chosen",
+ "set": {
+ "vars.planTier": "community"
+ },
+ "record": {
+ "step": "plan",
+ "label": "chose the {bridge.planNameCommunity} plan โ€” payment waits at the finish line",
+ "value": "community"
+ },
+ "goto": "keys"
+ },
+ {
+ "label": "Keep it free",
+ "spent": "Free",
+ "record": {
+ "step": "plan",
+ "label": "kept this space free",
+ "value": "free"
+ },
+ "goto": "keys"
+ }
+ ]
+ },
+ {
+ "id": "plan-family",
+ "bubbles": [
+ {
+ "text": "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\nA paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
+ "key": "q-plan-family"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Choose {bridge.planNameFamily}",
+ "spent": "Chosen",
+ "set": {
+ "vars.planTier": "family"
+ },
+ "record": {
+ "step": "plan",
+ "label": "chose the {bridge.planNameFamily} plan โ€” payment waits at the finish line",
+ "value": "family"
+ },
+ "goto": "keys"
+ },
+ {
+ "label": "Keep it free",
+ "spent": "Free",
+ "record": {
+ "step": "plan",
+ "label": "kept this space free",
+ "value": "free"
+ },
"goto": "keys"
}
]
@@ -710,6 +859,10 @@
{
"if": "!vars.backedUp",
"text": "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it โ€” for everyone. Sign in any time and it's safe."
+ },
+ {
+ "if": "vars.planTier",
+ "text": "\n\nAnd the plan you picked โ€” tap below whenever you're ready to set up the payment. Your space runs free until then."
}
]
},
@@ -726,6 +879,14 @@
"verb": "savePasskey"
}
},
+ {
+ "if": "vars.planTier",
+ "label": "Set up your plan",
+ "advancing": false,
+ "effect": {
+ "verb": "openPlanCheckout"
+ }
+ },
{
"label": "Open admin",
"advancing": false,
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index 838b72a..8eba4c5 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -13,7 +13,7 @@ const tick = () => new Promise((r) => setImmediate(r));
// A fake io that mirrors the real engine surface: say dedupes by key||text
// (like eleven.js) but always re-arms chips; verbs return scripted results.
-function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", envExtra = {} } = {}) {
+function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, myName = "", planBook = null, envExtra = {} } = {}) {
const h = {
transcript: [], // { text, chips: [labels], extra }
verbLog: [],
@@ -32,7 +32,23 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, my
respond: () => {},
typing: null,
sender: { name: "Eleven", kind: "bot" },
- bridge: { myName: () => h.myName, enrolled: () => h.enrolled, fromHome: () => h.fromHome },
+ bridge: {
+ myName: () => h.myName,
+ enrolled: () => h.enrolled,
+ fromHome: () => h.fromHome,
+ // The plan step's price book (#1374) โ€” mirrors apps/setup.js's getters;
+ // absent (the default) skips the step, like a self-hosted space.
+ planPrices: () => !!planBook,
+ planTeam: () => !!(planBook && planBook.team),
+ planCommunity: () => !!(planBook && planBook.community),
+ planFamily: () => !!(planBook && planBook.family),
+ planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "",
+ planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "",
+ planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "",
+ planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "",
+ planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "",
+ planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "",
+ },
space: { name: "Old Name", nameSet: false },
onEnd: () => (h.ended = true),
};
@@ -434,6 +450,87 @@ await test("setup: native creator โ€” greeting shows, name pre-set, passkey neve
assert.ok(chipLabels.includes("Open admin"), "native hops to Admin");
});
+await test("setup: the plan step โ€” asked with a price book, priced from it, recorded on 'plan'", async () => {
+ // #1374: "who is this space for?" lives in the conversation; the create
+ // slider is never pay-gated. Names + prices interpolate from the book.
+ const book = {
+ team: { key: "business", name: "Business", price: "โ‚ฌ11 per person per year" },
+ community: { key: "community", name: "Community & Friends", price: "โ‚ฌ0.11 per member per year (โ‚ฌ11 minimum)" },
+ family: { key: "family", name: "Family", price: "โ‚ฌ5.50 a year" },
+ };
+ const h = harness({
+ myName: "Paul",
+ planBook: book,
+ done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true },
+ });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+
+ const q = h.transcript.find((t) => t.extra.key === "q-plan");
+ assert.ok(q, "the plan question is asked");
+ assert.match(q.text, /who is this space for\?/);
+ assert.deepEqual(q.chips, ["A team", "A community", "Family & friends", "Keep it free"]);
+
+ await h.tap("A team");
+ const price = h.transcript.find((t) => t.extra.key === "q-plan-team");
+ assert.match(price.text, /Business: โ‚ฌ11 per person per year/, "name + price come from the book, not the script");
+ await h.tap("Choose Business");
+ assert.deepEqual(h.records.find((r) => r.step === "plan"), {
+ step: "plan",
+ label: "chose the Business plan โ€” payment waits at the finish line",
+ value: "team",
+ });
+
+ // The keys pitch follows; decline through to finish โ€” which carries the
+ // plan note and the payment chip (an action chip, never a side effect).
+ await h.tap("Not now");
+ await h.tap("I understand the risk");
+ assert.ok(h.transcript.find((t) => /the plan you picked โ€” tap below/.test(t.text)));
+ await h.tap("Set up your plan");
+ assert.ok(h.verbLog.find((v) => v[0] === "openPlanCheckout"), "the chip fires the checkout verb");
+ assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "the flow still completes");
+});
+
+await test("setup: the plan step โ€” 'Keep it free' records free; a partial book trims its chips", async () => {
+ const h = harness({
+ myName: "Paul",
+ planBook: { family: { key: "family", name: "Family", price: "โ‚ฌ5.50 a year" } },
+ done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true },
+ });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+ const q = h.transcript.find((t) => t.extra.key === "q-plan");
+ assert.deepEqual(q.chips, ["Family & friends", "Keep it free"], "only tiers the book offers get chips");
+ await h.tap("Family & friends");
+ await h.tap("Keep it free");
+ assert.deepEqual(h.records.find((r) => r.step === "plan"), { step: "plan", label: "kept this space free", value: "free" });
+ // No plan chosen โ†’ no payment note and no payment chip at the finish.
+ await h.tap("Not now");
+ await h.tap("I understand the risk");
+ assert.equal(h.transcript.some((t) => /the plan you picked/.test(t.text)), false);
+ assert.equal(h.transcript.flatMap((t) => t.chips || []).includes("Set up your plan"), false);
+});
+
+await test("setup: the plan step vanishes without a price book, on native, and once done", async () => {
+ // No book (self-hosted, billing off, unreachable): straight to keys.
+ const h = harness({ myName: "Paul", done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true } });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+ assert.equal(h.transcript.some((t) => t.extra.key === "q-plan"), false);
+ assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text)));
+
+ // Done (answered on another client): never re-asked, even with a book.
+ const book = { family: { key: "family", name: "Family", price: "โ‚ฌ5.50 a year" } };
+ const h2 = harness({
+ myName: "Paul",
+ planBook: book,
+ done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true, plan: true },
+ });
+ runFlow(SETUP_FLOW, h2.env, h2.io);
+ await tick();
+ assert.equal(h2.transcript.some((t) => t.extra.key === "q-plan"), false);
+});
+
await test("engine: template + condition mini-language", () => {
const env = { vars: { x: "hi" }, space: { name: "" }, __ack: "A" };
assert.equal(renderText(env, "{vars.x} {space.name|fallback}"), "hi fallback");
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js
index 3734ccd..9a8d244 100644
--- a/web/static/apps/setup.js
+++ b/web/static/apps/setup.js
@@ -81,6 +81,37 @@ const runningEnvs = new Map(); // threadId โ†’ env
const ownRecords = new Map(); // threadId โ†’ Set(step)
const say = makeSay("setup", N.ev("say"));
+// ---- the plan step's price data (#1374) -------------------------------------
+
+// The price book, fetched from home's public /api/pricebook before the flow
+// runs (startSetup awaits it, so bridge.plan* are plain sync getters like
+// every other bridge field). null = the plan step doesn't happen: a native
+// wrapper (no external purchases โ€” App Store rules), a self-hosted or
+// already-paid space, billing off, or an unreachable book. Keyed by AUDIENCE;
+// the book's tier keys are the ledger's routing keys, mapped here.
+let planBook = null; // { team|community|family: { key, name, price } }
+
+async function fetchPlanBook() {
+ planBook = null;
+ if (window.__entitlement !== "free" || !window.__homeOrigin) return;
+ if (document.documentElement.classList.contains("native")) return;
+ try {
+ // Capped so a slow book never delays Eleven's hello โ€” no book, no step.
+ const r = await fetch(window.__homeOrigin + "/api/pricebook", { signal: AbortSignal.timeout(4000) });
+ if (!r.ok) return;
+ const body = await r.json();
+ const audience = { business: "team", community: "community", family: "family" };
+ const book = {};
+ for (const t of body.tiers || []) {
+ const kind = audience[t.key];
+ if (kind && t.name && t.label) book[kind] = { key: t.key, name: t.name, price: t.label };
+ }
+ if (Object.keys(book).length) planBook = book;
+ } catch {
+ // Unreachable book โ€” the conversation simply skips the plan step.
+ }
+}
+
// The bridge-verb table: the CLOSED set of side effects the setup flow may
// name (docs/conversation-scripts.md ยง interpreter contract). A flow is data โ€”
// it can never do anything this table doesn't offer.
@@ -108,6 +139,20 @@ function verb(name, args, env) {
return bridge.deferBackupReminders && bridge.deferBackupReminders();
case "openAdmin":
return openAppThread("admin");
+ case "openPlanCheckout": {
+ // The finish line's payment chip (#1374): an upgrade of the already-
+ // created (free) space on the home page โ€” this server has no billing
+ // surface. The recorded plan step holds the audience; the fetched book
+ // maps it back to the tier key. Same-tab: setup is complete, and home
+ // returns you to your list once the payment settles.
+ const st = appStateFor(env.threadId, "setup");
+ const kind = st && st.steps && st.steps.plan && st.steps.plan.value;
+ const tier = planBook && kind && planBook[kind];
+ if (!tier || !window.__homeOrigin) return;
+ location.href =
+ window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host) + "&tier=" + encodeURIComponent(tier.key);
+ return;
+ }
case "openQuickForm":
// The name step's fast path โ€” the host owns the form sheet (web:
// openSetupQuickForm in shell.js; native: the quick-form sheet).
@@ -139,6 +184,17 @@ export function startSetup(env) {
// than pitching passkeys from scratch. Must be forwarded here โ€” the flow's
// bridge.* only sees what env.bridge exposes.
fromHome: () => setupBridge().fromHome(),
+ // The plan step's price book (#1374) โ€” absent means the step is skipped.
+ planPrices: () => !!planBook,
+ planTeam: () => !!(planBook && planBook.team),
+ planCommunity: () => !!(planBook && planBook.community),
+ planFamily: () => !!(planBook && planBook.family),
+ planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "",
+ planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "",
+ planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "",
+ planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "",
+ planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "",
+ planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "",
};
env.onEnd = () => {
running.delete(env.threadId);
@@ -167,12 +223,16 @@ export function startSetup(env) {
imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token),
delay: (ms, fn) => setTimeout(fn, ms),
};
- spaceInfo()
- .then((info) => {
- env.space = info || {};
- })
- .catch(() => {
- env.space = {};
- })
- .finally(() => runFlow(SETUP_FLOW, env, io));
+ // The plan step's price book rides alongside the space info โ€” both settle
+ // before the flow starts, so every bridge getter stays synchronous.
+ Promise.all([
+ spaceInfo()
+ .then((info) => {
+ env.space = info || {};
+ })
+ .catch(() => {
+ env.space = {};
+ }),
+ fetchPlanBook(),
+ ]).finally(() => runFlow(SETUP_FLOW, env, io));
}
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js
index dbb9d83..e9bb2ab 100644
--- a/web/static/setup.flow.js
+++ b/web/static/setup.flow.js
@@ -322,15 +322,118 @@ export default {
spent: "On",
effect: { verb: "setFeature", args: ["ai", true] },
record: { label: "invited Eleven's AI in", value: "on" },
- goto: "keys",
+ goto: "plan",
},
{
label: "Not now",
spent: "Off",
effect: { verb: "setFeature", args: ["ai", false] },
record: { label: "kept AI off", value: "off" },
+ goto: "plan",
+ },
+ ],
+ },
+ {
+ // The plan question, moved HERE from the create flow (#1374): creating
+ // a space is never pay-gated โ€” the conversation is where "who is this
+ // space for?" gets asked, with real prices from the price book, and a
+ // paid answer becomes an upgrade chip on the finish line (the space
+ // already exists, free, and stays free until that tap). Web-only, like
+ // the photo: native wrappers must not surface an external purchase
+ // (App Store rules โ€” the same gate as the โ‹ฎ Upgrade item), and with no
+ // reachable price book (self-hosted, billing off, already paid) the
+ // step vanishes. Whichever client asks, the record lands on THIS step.
+ id: "plan",
+ skipIf: ["done", "native"],
+ skipTo: "keys",
+ entries: [{ if: "!bridge.planPrices", goto: "keys" }],
+ bubbles: [
+ {
+ text:
+ "One more question โ€” who is this space for?\n\n" +
+ "Every space starts free, and free is a real home โ€” kept that way by the spaces that pay. " +
+ "If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).",
+ key: "q-plan",
+ },
+ ],
+ chips: [
+ { if: "bridge.planTeam", label: "A team", goto: "plan-team" },
+ { if: "bridge.planCommunity", label: "A community", goto: "plan-community" },
+ { if: "bridge.planFamily", label: "Family & friends", goto: "plan-family" },
+ {
+ label: "Keep it free",
+ spent: "Free",
+ trailing: true,
+ record: { label: "kept this space free", value: "free" },
+ goto: "keys",
+ },
+ ],
+ },
+ {
+ // The three per-audience price cards. Names and prices interpolate from
+ // the price book (config, never code โ€” docs/tier-economics.md); the
+ // record's value is the AUDIENCE, which openPlanCheckout maps back to
+ // the book's tier key at tap time.
+ id: "plan-team",
+ bubbles: [
+ {
+ text:
+ "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} โ€” it scales with the team, and bots are free.\n\n" +
+ "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
+ key: "q-plan-team",
+ },
+ ],
+ chips: [
+ {
+ label: "Choose {bridge.planNameTeam}",
+ spent: "Chosen",
+ set: { "vars.planTier": "team" },
+ record: { step: "plan", label: "chose the {bridge.planNameTeam} plan โ€” payment waits at the finish line", value: "team" },
goto: "keys",
},
+ { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
+ ],
+ },
+ {
+ id: "plan-community",
+ bubbles: [
+ {
+ text:
+ "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\n" +
+ "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
+ key: "q-plan-community",
+ },
+ ],
+ chips: [
+ {
+ label: "Choose {bridge.planNameCommunity}",
+ spent: "Chosen",
+ set: { "vars.planTier": "community" },
+ record: { step: "plan", label: "chose the {bridge.planNameCommunity} plan โ€” payment waits at the finish line", value: "community" },
+ goto: "keys",
+ },
+ { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
+ ],
+ },
+ {
+ id: "plan-family",
+ bubbles: [
+ {
+ text:
+ "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\n" +
+ "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
+ key: "q-plan-family",
+ },
+ ],
+ chips: [
+ {
+ label: "Choose {bridge.planNameFamily}",
+ spent: "Chosen",
+ set: { "vars.planTier": "family" },
+ record: { step: "plan", label: "chose the {bridge.planNameFamily} plan โ€” payment waits at the finish line", value: "family" },
+ goto: "keys",
+ },
+ { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
],
},
{
@@ -493,6 +596,13 @@ export default {
text:
"\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it โ€” for everyone. Sign in any time and it's safe.",
},
+ // The plan they chose mid-conversation (#1374): the payment is a
+ // chip below, never a side effect โ€” and the space stays free (and
+ // fully working) until that tap.
+ {
+ if: "vars.planTier",
+ text: "\n\nAnd the plan you picked โ€” tap below whenever you're ready to set up the payment. Your space runs free until then.",
+ },
],
},
{ text: "Where would you like to start?" },
@@ -501,6 +611,10 @@ export default {
// The parting reminder's action โ€” finish-line chips stay live, and the
// completion poll picks the sign-in up even after the flow has ended.
{ if: "!vars.backedUp", label: "Sign in", advancing: false, effect: { verb: "savePasskey" } },
+ // The chosen plan's payment (#1374): hands off to the home page's
+ // upgrade picker with the tier pre-highlighted. Same tab โ€” setup is
+ // complete, and home brings you back to your list.
+ { if: "vars.planTier", label: "Set up your plan", advancing: false, effect: { verb: "openPlanCheckout" } },
// Native hops threads too (the conversation model's openAdmin verb).
{ label: "Open admin", advancing: false, effect: { verb: "openAdmin" } },
// The tour is the web's guided overlay โ€” web-only for now.