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>
10 files changed,
+254
−20
cloud.go+17 −9cloud_test.go+40 −0control.go+35 −2control_test.go+67 −0controld.go+4 −0demo.go+1 −1home/provision.go+18 −3home/provision_test.go+35 −0main.go+31 −5roles_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) |
| } |