eleven / messenger Public

server: seed a new space's display name through the create chain (#1328)

The create flow will ask for the name up front (pay-gated create, next
slice), so the whole chain learns to carry it: home POST /api/spaces {name}
→ cloud → controld → add-instance → LCHAT_GROUP_NAME in the instance env →
the instance names itself at first boot (applySeededName, seed-only — an
already-named space ignores it, and the setup conversation already offers
'Keep …' for a pre-set name). Sanitized at the control seam (control chars
would inject env-file lines) and capped at 60 everywhere the instance caps
it. Operator parity: add-instance --group-name.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5df3d4a9ea33d0c281ba076e97a8c0fd96742844 parent f066ead
10 files changed, +254 −20
  • cloud.go +17 −9
  • cloud_test.go +40 −0
  • control.go +35 −2
  • control_test.go +67 −0
  • controld.go +4 −0
  • demo.go +1 −1
  • home/provision.go +18 −3
  • home/provision_test.go +35 −0
  • main.go +31 −5
  • roles_test.go +6 −0
diff --git a/cloud.go b/cloud.go
index 3572dbb..70382aa 100644
--- a/cloud.go
+++ b/cloud.go
@@ -96,12 +96,12 @@ type cloudServer struct {
// production's single provisioner, every self-hoster, any box without the flag
// — keeps today's cloud.db-PK hostname, byte for byte. Only the address's id
// source moves; the PK stays the ledger key.
- ids *idalloc.Allocator
- quota int // max spaces per (caller, requester)
- reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
- reapDryRun bool // log what the reaper would reclaim, delete nothing
- reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
- billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
+ ids *idalloc.Allocator
+ quota int // max spaces per (caller, requester)
+ reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
+ reapDryRun bool // log what the reaper would reclaim, delete nothing
+ reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
+ billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
// enterprise is the Enterprise Edition page's public checkout
// (enterprise.go) — its own Stripe key, deliberately separate from
// billing's so it can run against the sandbox; nil = off.
@@ -220,6 +220,9 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
var req struct {
Requester string `json:"requester"`
Kind string `json:"kind"` // "" = a free space (the default); "demo" = a 24h demo (demo.go)
+ // Name optionally seeds the space's display name (plaintext branding by
+ // design — never member content). Sanitized again at the control seam.
+ Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil {
writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
@@ -234,6 +237,11 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown kind (want empty or \"demo\")")
return
}
+ req.Name = strings.TrimSpace(req.Name)
+ if len(req.Name) > 60 {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "name must be at most 60 characters")
+ return
+ }
// The fleet-wide circuit breaker: a trailing-24h creation budget, counted
// against an append-only log so create-and-delete can't reset it. Insurance
@@ -312,7 +320,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
}
host := slug + "." + s.baseDomain
- status, body, err := s.controlCreate(host, entFree)
+ status, body, err := s.controlCreate(host, entFree, req.Name)
if err != nil {
s.db.deleteSpaceByID(id)
log.Printf("cloud: control call for %s: %v", host, err)
@@ -358,8 +366,8 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
// of the durability ladder (billing flips re-push via pushReplicationByID);
// a demo's class is always empty — controld keeps demos off the activator
// entirely, so they never replicate (docs/demo-spaces.md: no backups).
-func (s *cloudServer) controlCreate(host, entitlement string) (int, map[string]any, error) {
- payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entitlement,
+func (s *cloudServer) controlCreate(host, entitlement, name string) (int, map[string]any, error) {
+ payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entitlement, Name: name,
Replication: s.replicationFor(entitlement, ""),
// Stamp this box's region as the new space's home_region (multi-edge
// Phase 1) — the same on every create, regular or demo, since both reach
diff --git a/cloud_test.go b/cloud_test.go
index 75d9058..d0b3148 100644
--- a/cloud_test.go
+++ b/cloud_test.go
@@ -662,3 +662,43 @@ func TestCloudNoRegionLeavesHomeRegionEmpty(t *testing.T) {
t.Errorf("home_region = %q, want empty (region-agnostic)", got)
}
}
+
+// The optional display name rides create → controld verbatim (sanitization is
+// the control seam's job); an oversized one is refused before the dial.
+func TestCloudCreatePassesName(t *testing.T) {
+ var gotName string
+ ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ var req controlCreateReq
+ json.NewDecoder(r.Body).Decode(&req)
+ gotName = req.Name
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
+ })
+ })
+ srv, _ := newTestCloud(t, ctl)
+ tok := mintCaller(t, srv, "home")
+
+ post := func(body map[string]string) *http.Response {
+ payload, _ := json.Marshal(body)
+ req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(payload))
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+ }
+
+ if r := post(map[string]string{"requester": "user-1", "name": " The Campbells "}); r.StatusCode != http.StatusCreated {
+ t.Fatalf("create with name: %d, want 201", r.StatusCode)
+ }
+ if gotName != "The Campbells" {
+ t.Errorf("controld saw name %q, want %q", gotName, "The Campbells")
+ }
+
+ long := strings.Repeat("x", 61)
+ if r := post(map[string]string{"requester": "user-1", "name": long}); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("61-char name: %d, want 400", r.StatusCode)
+ }
+}
diff --git a/control.go b/control.go
index aeb0dc8..ce6425e 100644
--- a/control.go
+++ b/control.go
@@ -19,6 +19,8 @@ import (
"time"
"github.com/benbjohnson/litestream"
+ "unicode"
+ "unicode/utf8"
)
// The control subcommands automate what an operator would otherwise do by hand to
@@ -122,8 +124,13 @@ type addInstanceOpts struct {
// the space menu's Upgrade. Empty for operator-created instances, which have
// no billing surface and show nothing.
Entitlement string
- HomeRegion string // geo seam (#550 Part 7): "" = the single implicit region
- Provider string // co-op seam (#550 Part 10): "" = the single implicit provider
+ // GroupName seeds the space's display name (LCHAT_GROUP_NAME → the instance
+ // names itself at first boot; main.go applySeededName). The create flow asks
+ // for the name up front, so a provisioned space greets its admin by name
+ // instead of as "lchat". Seed-only: an already-named space ignores it.
+ GroupName string
+ HomeRegion string // geo seam (#550 Part 7): "" = the single implicit region
+ Provider string // co-op seam (#550 Part 10): "" = the single implicit provider
// Hibernate registers the instance as activator-managed (#550 Part 4 v2):
// no systemd unit is created — the router wakes it on first request. Needs
// the router running with LCHAT_HIBERNATE_BUCKET, or the host never answers.
@@ -139,6 +146,28 @@ type addInstanceOpts struct {
RouterUnit string // default "lchat-router"
}
+// sanitizeGroupName makes an untrusted display name safe to carry in the
+// KEY=value env file writeEnvFile emits: control characters (newlines would
+// inject env lines) are dropped, whitespace trimmed, and length capped at 60
+// bytes on a rune boundary — the same cap the instance's own rename enforces.
+func sanitizeGroupName(name string) string {
+ name = strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) {
+ return -1
+ }
+ return r
+ }, name)
+ name = strings.TrimSpace(name)
+ if len(name) > 60 {
+ cut := 60
+ for cut > 0 && !utf8.RuneStart(name[cut]) {
+ cut--
+ }
+ name = strings.TrimSpace(name[:cut])
+ }
+ return name
+}
+
// addInstanceResult is what a successful creation yields. SetupLink may be empty
// with SetupErr set: the invite mint is best-effort (the admin link still
// bootstraps the instance), matching the CLI's long-standing behaviour.
@@ -158,6 +187,7 @@ type addInstanceResult struct {
// blip). Today the two always run back to back and the caller's capacity cap
// (controld.atCapacity) guards the pair, exactly as it did before the split.
func addInstance(reg *Registry, opts addInstanceOpts) (*addInstanceResult, error) {
+ opts.GroupName = sanitizeGroupName(opts.GroupName)
route, token, err := registerInstance(reg, opts)
if err != nil {
return nil, err
@@ -242,6 +272,7 @@ func materializeInstance(reg *Registry, route Route, token string, opts addInsta
"LCHAT_PUSH_RELAY_URL": opts.PushRelayURL,
"LCHAT_PUSH_RELAY_TOKEN": opts.PushRelayToken,
"LCHAT_ENTITLEMENT": opts.Entitlement,
+ "LCHAT_GROUP_NAME": opts.GroupName,
} {
if v != "" {
env[k] = v
@@ -360,6 +391,7 @@ func runAddInstance(args []string) {
adminToken := fs.String("admin-token", "", "import: use this admin token instead of minting a new one (preserves saved admin links when migrating an instance whose DB is already in place)")
appStoreURL := fs.String("app-store-url", "", "per-instance LCHAT_APP_STORE_URL")
homeOrigin := fs.String("home-origin", "", "per-instance LCHAT_HOME_ORIGIN")
+ groupName := fs.String("group-name", "", "seed the space's display name (LCHAT_GROUP_NAME; the instance names itself at first boot — an already-named space ignores it)")
pushRelayURL := fs.String("push-relay-url", "", "per-instance LCHAT_PUSH_RELAY_URL")
pushRelayToken := fs.String("push-relay-token", "", "per-instance LCHAT_PUSH_RELAY_TOKEN")
homeRegion := fs.String("home-region", "", "placement region recorded as the instance's home_region (multi-edge, docs/multi-edge.md) — the mesh owns the space on a box whose LCHAT_MESH_REGION matches; empty = region-agnostic (today's behaviour)")
@@ -377,6 +409,7 @@ func runAddInstance(args []string) {
AdminToken: *adminToken,
AppStoreURL: *appStoreURL,
HomeOrigin: *homeOrigin,
+ GroupName: *groupName,
PushRelayURL: *pushRelayURL,
PushRelayToken: *pushRelayToken,
HomeRegion: strings.TrimSpace(*homeRegion),
diff --git a/control_test.go b/control_test.go
index 65612c4..b4e172d 100644
--- a/control_test.go
+++ b/control_test.go
@@ -1099,3 +1099,70 @@ func TestArchiveInstanceDBMovesDBKeepsEnv(t *testing.T) {
t.Errorf("archived DB should be recoverable in /data/trash, found %d", len(trashes))
}
}
+
+func TestSanitizeGroupName(t *testing.T) {
+ long := strings.Repeat("é", 40) // 80 bytes — the cap must cut on a rune boundary
+ cases := []struct{ in, want string }{
+ {" The Campbells ", "The Campbells"},
+ {"Line\nBREAK=1\ninjection", "LineBREAK=1injection"}, // env-line injection defused
+ {"tabs\tand\rreturns", "tabsandreturns"},
+ {long, strings.Repeat("é", 30)},
+ {"\n\t ", ""},
+ }
+ for _, c := range cases {
+ if got := sanitizeGroupName(c.in); got != c.want {
+ t.Errorf("sanitizeGroupName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// A seeded display name lands in the instance env (LCHAT_GROUP_NAME) already
+// sanitized — the instance names itself from it at first boot.
+func TestAddInstanceSeedsGroupName(t *testing.T) {
+ reg := controlTestEnv(t)
+ host := "named.example.com"
+ if _, err := addInstance(reg, addInstanceOpts{Host: host, GroupName: " The Campbells\n "}); err != nil {
+ t.Fatalf("addInstance: %v", err)
+ }
+ env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
+ if err != nil {
+ t.Fatalf("read env: %v", err)
+ }
+ if env["LCHAT_GROUP_NAME"] != "The Campbells" {
+ t.Errorf("LCHAT_GROUP_NAME = %q, want %q", env["LCHAT_GROUP_NAME"], "The Campbells")
+ }
+
+ // No name → no env line at all (the instance keeps its default behaviour).
+ host2 := "unnamed.example.com"
+ if _, err := addInstance(reg, addInstanceOpts{Host: host2}); err != nil {
+ t.Fatalf("addInstance: %v", err)
+ }
+ env2, err := readEnvFile(filepath.Join(instanceDataDir, host2+".env"))
+ if err != nil {
+ t.Fatalf("read env: %v", err)
+ }
+ if _, present := env2["LCHAT_GROUP_NAME"]; present {
+ t.Error("empty group name still wrote LCHAT_GROUP_NAME")
+ }
+}
+
+// applySeededName consumes LCHAT_GROUP_NAME exactly once: it names a fresh
+// space and never overrides a name an admin has since chosen.
+func TestApplySeededName(t *testing.T) {
+ db := newTestDB(t)
+ t.Setenv("LCHAT_GROUP_NAME", "Book Club")
+ if err := applySeededName(db); err != nil {
+ t.Fatal(err)
+ }
+ if got := db.getGroupName(); got != "Book Club" {
+ t.Errorf("seeded name = %q, want Book Club", got)
+ }
+ // A later boot with a different env value must not clobber the set name.
+ t.Setenv("LCHAT_GROUP_NAME", "Usurper")
+ if err := applySeededName(db); err != nil {
+ t.Fatal(err)
+ }
+ if got := db.getGroupName(); got != "Book Club" {
+ t.Errorf("after re-seed, name = %q, want Book Club (seed-only)", got)
+ }
+}
diff --git a/controld.go b/controld.go
index fe594f7..1d7321c 100644
--- a/controld.go
+++ b/controld.go
@@ -98,6 +98,9 @@ type controlCreateReq struct {
PushRelayURL string `json:"push_relay_url,omitempty"`
PushRelayToken string `json:"push_relay_token,omitempty"`
Entitlement string `json:"entitlement,omitempty"`
+ // Name seeds the space's display name (→ LCHAT_GROUP_NAME; the create flow
+ // asks for it up front). Sanitized + capped by addInstance; optional.
+ Name string `json:"name,omitempty"`
// Replication classes the space at birth (registry.go's durability ladder)
// so a free space never spends a single awake window on the streaming rung.
Replication string `json:"replication,omitempty"`
@@ -584,6 +587,7 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) {
PushRelayURL: req.PushRelayURL,
PushRelayToken: req.PushRelayToken,
Entitlement: req.Entitlement,
+ GroupName: req.Name,
Replication: strings.ToLower(strings.TrimSpace(req.Replication)),
// Placement region (multi-edge Phase 1): recorded on the registry row,
// where the mesh reads it to place the space (control.go registerInstance
diff --git a/demo.go b/demo.go
index 897f5b2..eeacf03 100644
--- a/demo.go
+++ b/demo.go
@@ -75,7 +75,7 @@ func (s *cloudServer) createDemoSpace(ctx context.Context, w http.ResponseWriter
}
host := slug + "." + s.baseDomain
- status, body, err := s.controlCreate(host, entDemo)
+ status, body, err := s.controlCreate(host, entDemo, "")
if err != nil {
s.db.deleteSpaceByID(id)
log.Printf("cloud: control call for demo %s: %v", host, err)
diff --git a/home/provision.go b/home/provision.go
index 8454945..27ab55c 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -256,6 +256,7 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
// pow_required below.
var req struct {
Region string `json:"region"`
+ Name string `json:"name"`
Challenge string `json:"challenge"`
Nonce string `json:"nonce"`
}
@@ -280,7 +281,17 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
// Owner override: an explicit region places the space there; empty
// auto-assigns to the default region. An unknown code quietly falls back to
// the default (cloudForRegion) rather than failing the create.
- status, body, err := s.cloudForRegion(req.Region).createSpace(uid)
+ // The name rides through to the provisioner (→ the instance's seeded
+ // display name). Plaintext branding by design; capped here so a huge body
+ // never travels further.
+ name := strings.TrimSpace(req.Name)
+ if len(name) > 60 {
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "bad_request", "message": "The space name can be at most 60 characters.",
+ })
+ return
+ }
+ status, body, err := s.cloudForRegion(req.Region).createSpace(uid, name)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "create_failed", "message": "could not reach the space provisioner",
@@ -493,8 +504,12 @@ func (s *Server) portalSpaceHandler(w http.ResponseWriter, r *http.Request) {
// createSpace forwards one provisioning request; the user id is the quota key
// (opaque to the provisioner — random, no email or name anywhere).
-func (c *cloudProvisioner) createSpace(requester string) (int, map[string]any, error) {
- payload, _ := json.Marshal(map[string]string{"requester": requester})
+func (c *cloudProvisioner) createSpace(requester, name string) (int, map[string]any, error) {
+ body := map[string]string{"requester": requester}
+ if name != "" {
+ body["name"] = name
+ }
+ payload, _ := json.Marshal(body)
return c.do("POST", "/api/instances", payload)
}
diff --git a/home/provision_test.go b/home/provision_test.go
index 8d6576f..2d04389 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -419,3 +419,38 @@ func TestListSpacesProxiesLedger(t *testing.T) {
t.Errorf("cloud saw ?%s, want requester=%s", captured.URL.RawQuery, uid)
}
}
+
+// The optional space name rides the create body through to the provisioner;
+// an oversized one is refused before any dial.
+func TestCreateSpaceForwardsName(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedMember(t, s)
+ cloud, _, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{
+ "host": "h.example.com", "url": "https://h.example.com",
+ })
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ post := func(body string) *http.Response {
+ req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+ }
+
+ if r := post(`{"name":" Book Club "}`); r.StatusCode != http.StatusCreated {
+ t.Fatalf("create with name: %d, want 201", r.StatusCode)
+ }
+ var sent map[string]string
+ json.Unmarshal(*capturedBody, &sent)
+ if sent["name"] != "Book Club" {
+ t.Errorf("provisioner saw name %q, want %q", sent["name"], "Book Club")
+ }
+
+ if r := post(`{"name":"` + strings.Repeat("x", 61) + `"}`); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("61-char name: %d, want 400", r.StatusCode)
+ }
+}
diff --git a/main.go b/main.go
index 311ce98..a3c6ecf 100644
--- a/main.go
+++ b/main.go
@@ -312,6 +312,9 @@ func main() {
}
printAdminLink(*addr, tok)
}
+ if err := applySeededName(db); err != nil {
+ log.Fatalf("seeded name: %v", err)
+ }
if err := applySkipSetup(db); err != nil {
log.Fatalf("skip-setup defaults: %v", err)
}
@@ -4379,6 +4382,31 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name, "admin_name": adminName, "admin_email": adminEmail})
}
+// applySeededName seeds the space's name from LCHAT_GROUP_NAME — the create
+// flow asks for the name up front now, and the provisioner delivers it via the
+// instance env (control.go writes it at add-instance). Seed-only, never an
+// override: a name the admin has since changed (or sealed) always wins, so
+// restarts can't clobber it. The setup conversation composes with this — it
+// offers "Keep "{name}"" when a name is already set rather than asking cold.
+func applySeededName(db *DB) error {
+ name := strings.TrimSpace(os.Getenv("LCHAT_GROUP_NAME"))
+ if name == "" || db.groupNameSet() {
+ return nil
+ }
+ // The create path caps at 60 too; belt-and-suspenders, at a rune boundary.
+ if len(name) > 60 {
+ cut := 60
+ for cut > 0 && !utf8.RuneStart(name[cut]) {
+ cut--
+ }
+ name = strings.TrimSpace(name[:cut])
+ }
+ if name == "" {
+ return nil
+ }
+ return db.setGroupName(name)
+}
+
// applySkipSetup is a TEST/DEV convenience (LCHAT_SKIP_SETUP=1): mark the
// first-run setup conversation complete and seed workable defaults, so a
// scratch instance is usable the moment its setup link is claimed — the
@@ -4393,12 +4421,10 @@ func applySkipSetup(db *DB) error {
if err := db.setSetupComplete(true); err != nil {
return err
}
+ // applySeededName already consumed LCHAT_GROUP_NAME; this only backfills the
+ // dev default when nothing named the space at all.
if !db.groupNameSet() {
- name := strings.TrimSpace(os.Getenv("LCHAT_GROUP_NAME"))
- if name == "" {
- name = "Test Space"
- }
- if err := db.setGroupName(name); err != nil {
+ if err := db.setGroupName("Test Space"); err != nil {
return err
}
}
diff --git a/roles_test.go b/roles_test.go
index 3a586a5..11edc87 100644
--- a/roles_test.go
+++ b/roles_test.go
@@ -224,6 +224,12 @@ func TestApplySkipSetup(t *testing.T) {
}
t.Setenv("LCHAT_SKIP_SETUP", "1")
t.Setenv("LCHAT_GROUP_NAME", "Canary Nest")
+ // Boot runs the pair: applySeededName consumes LCHAT_GROUP_NAME (for every
+ // instance, not just skip-setup ones — see TestApplySeededName), then
+ // applySkipSetup backfills the dev defaults around it.
+ if err := applySeededName(db); err != nil {
+ t.Fatalf("applySeededName: %v", err)
+ }
if err := applySkipSetup(db); err != nil {
t.Fatalf("applySkipSetup: %v", err)
}