eleven / messenger Public

server,admin: optional administrator name + contact email (#208)

Add two optional, admin-set branding fields alongside the group name — who
administers this space and a contact email — so members can learn who runs
it and how to reach them (Eleven's welcome tour will surface them, and they
degrade gracefully when unset). Plaintext branding like the group name and
server photo: admin-authored, never user content.

Unlike the group name, the administrator identity is member-only, not public:
/api/group is also the unauthenticated onboarding read, so the admin fields
are added there only when a valid member token is presented, and the console
reads them back via the admin-authed settings endpoint. Both fields are
optional (empty clears); the email gets a loose sanity check.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell d71dda8c7ee58385b6eae02be3191739b7ea3597 parent 10549ed
5 files changed, +151 −12
  • db.go +11 −0
  • handlers_test.go +66 −0
  • main.go +50 −4
  • web/pages/admin.html +10 −5
  • web/static/admin.js +14 −3
diff --git a/db.go b/db.go
index 374c7ae..9f2e457 100644
--- a/db.go
+++ b/db.go
@@ -32,6 +32,17 @@ func (db *DB) setGroupName(name string) error {
return db.setConfig("group_name", name)
}
+// getAdminName / getAdminEmail return the optional administrator identity an
+// admin may publish to members (who runs this space + how to reach them). Shown
+// by Eleven's guided tour and answers. Plaintext branding like the group name —
+// admin-authored, never user content — and empty until set. Served only to
+// authenticated members (see getGroup), not on the public onboarding read.
+func (db *DB) getAdminName() string { v, _, _ := db.getConfig("admin_name"); return v }
+func (db *DB) getAdminEmail() string { v, _, _ := db.getConfig("admin_email"); return v }
+
+func (db *DB) setAdminName(v string) error { return db.setConfig("admin_name", v) }
+func (db *DB) setAdminEmail(v string) error { return db.setConfig("admin_email", v) }
+
// getInstanceAvatar returns the admin-set instance branding photo (bytes + MIME +
// an updated-at stamp for cache validation), or ok=false when none is set. Like the
// group name, this is plaintext branding the server serves in the clear.
diff --git a/handlers_test.go b/handlers_test.go
index 8d8b0f0..881e1e0 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -570,6 +570,72 @@ func TestGroupNameEscapedInHTML(t *testing.T) {
}
}
+func TestAdminIdentity(t *testing.T) {
+ ts, _ := newTestServer(t)
+
+ // Set the space name + the optional administrator identity.
+ if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_name":"Paul Campbell","admin_email":"paul@example.com"}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("set: %d", resp.StatusCode)
+ }
+
+ // The admin console reads it back via the admin-authed settings endpoint.
+ var s struct {
+ AdminName string `json:"admin_name"`
+ AdminEmail string `json:"admin_email"`
+ }
+ json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&s)
+ if s.AdminName != "Paul Campbell" || s.AdminEmail != "paul@example.com" {
+ t.Fatalf("settings identity = %q / %q", s.AdminName, s.AdminEmail)
+ }
+
+ // Public /api/group (onboarding, no token) must NOT leak the admin identity.
+ var pub map[string]any
+ json.NewDecoder(mustGet(t, ts.URL+"/api/group").Body).Decode(&pub)
+ if _, ok := pub["admin_name"]; ok {
+ t.Fatal("public /api/group leaked admin_name")
+ }
+
+ // A valid member token gets it (used by Eleven's tour).
+ adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1","claim_hash":"`+testClaimHash+`"}`)
+ r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`))
+ var claim struct {
+ Token string `json:"token"`
+ }
+ json.NewDecoder(r.Body).Decode(&claim)
+ var member struct {
+ AdminName string `json:"admin_name"`
+ AdminEmail string `json:"admin_email"`
+ }
+ json.NewDecoder(mustGet(t, ts.URL+"/api/group?token="+claim.Token).Body).Decode(&member)
+ if member.AdminName != "Paul Campbell" || member.AdminEmail != "paul@example.com" {
+ t.Fatalf("member identity = %q / %q", member.AdminName, member.AdminEmail)
+ }
+
+ // Both are optional: empty clears them.
+ adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam"}`)
+ json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&s)
+ if s.AdminName != "" || s.AdminEmail != "" {
+ t.Fatalf("expected cleared identity, got %q / %q", s.AdminName, s.AdminEmail)
+ }
+
+ // A malformed email is rejected (so does an over-long name).
+ if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_email":"not an email"}`); resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("bad email: want 400, got %d", resp.StatusCode)
+ }
+ if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_name":"`+strings.Repeat("x", 61)+`"}`); resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("long name: want 400, got %d", resp.StatusCode)
+ }
+}
+
+func mustGet(t *testing.T, url string) *http.Response {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatalf("get %s: %v", url, err)
+ }
+ return resp
+}
+
// adminReq issues a request with a raw body and the admin bearer token.
func adminReq(t *testing.T, method, url, contentType string, body []byte) *http.Response {
t.Helper()
diff --git a/main.go b/main.go
index 5bf5c0d..b1bb8d7 100644
--- a/main.go
+++ b/main.go
@@ -2110,7 +2110,15 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) {
// speculative image fetch (used by the join cards, home Save-passkey card, and
// the native add-chat screens).
_, _, _, hasAvatar := s.db.getInstanceAvatar()
- writeJSON(w, http.StatusOK, map[string]any{"name": s.db.getGroupName(), "avatar": hasAvatar})
+ out := map[string]any{"name": s.db.getGroupName(), "avatar": hasAvatar}
+ // The administrator identity is member-only, not public branding: this
+ // endpoint is also the unauthenticated onboarding read, so only add the
+ // admin's name + contact email when a valid member token is presented.
+ if s.tokenOK(r.URL.Query().Get("token")) {
+ out["admin_name"] = s.db.getAdminName()
+ out["admin_email"] = s.db.getAdminEmail()
+ }
+ writeJSON(w, http.StatusOK, out)
}
// ---- server avatar (instance branding photo) ----------------------------
@@ -2190,7 +2198,9 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ AdminName string `json:"admin_name"`
+ AdminEmail string `json:"admin_email"`
}
json.NewDecoder(r.Body).Decode(&body)
name := strings.TrimSpace(body.Name)
@@ -2198,11 +2208,43 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "name must be 1–60 characters")
return
}
+ // The administrator name + contact email are optional (empty clears them).
+ adminName := strings.TrimSpace(body.AdminName)
+ adminEmail := strings.TrimSpace(body.AdminEmail)
+ if len(adminName) > 60 {
+ httpError(w, http.StatusBadRequest, "administrator name must be at most 60 characters")
+ return
+ }
+ // A light sanity check, not full RFC validation: a contact email should look
+ // like one (an @ with something either side) and stay short.
+ if adminEmail != "" && (len(adminEmail) > 120 || !isRoughEmail(adminEmail)) {
+ httpError(w, http.StatusBadRequest, "contact email doesn't look valid")
+ return
+ }
if err := s.db.setGroupName(name); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
+ if err := s.db.setAdminName(adminName); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if err := s.db.setAdminEmail(adminEmail); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name, "admin_name": adminName, "admin_email": adminEmail})
+}
+
+// isRoughEmail is a deliberately loose check — just enough to reject obvious
+// typos in the admin's optional contact address without pretending to validate
+// deliverability. "a@b.c" passes; "not an email" doesn't.
+func isRoughEmail(s string) bool {
+ at := strings.IndexByte(s, '@')
+ if at <= 0 || at == len(s)-1 {
+ return false
+ }
+ return strings.Contains(s[at+1:], ".") && !strings.ContainsAny(s, " \t\n")
}
// ---- instance settings (admin) ------------------------------------------
@@ -2212,7 +2254,11 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
- writeJSON(w, http.StatusOK, map[string]any{"user_invites": s.db.usersCanInvite()})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "user_invites": s.db.usersCanInvite(),
+ "admin_name": s.db.getAdminName(),
+ "admin_email": s.db.getAdminEmail(),
+ })
}
// setSettings toggles instance settings. Fields are pointers so an omitted field
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 1894504..28195f9 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -41,11 +41,16 @@
</section>
<section>
- <h2>Group name</h2>
- <p class="sub" style="margin:0 0 8px">Shown as the app name when added to a Home Screen, and as the chat's title.</p>
- <form id="nameForm" class="inline-form" autocomplete="off">
- <input id="groupName" placeholder="e.g. Family Chat" maxlength="60" />
- <button type="submit">Save name</button>
+ <h2>This space</h2>
+ <p class="sub" style="margin:0 0 8px">The name is shown as the app name on a Home Screen and as the chat's title. The administrator name &amp; contact email are optional — they're shown to members (e.g. by Eleven's welcome tour) so people know who runs the space and how to reach you.</p>
+ <form id="nameForm" autocomplete="off">
+ <label class="field-label" for="groupName">Group name</label>
+ <input id="groupName" placeholder="e.g. Family Chat" maxlength="60" style="display:block;width:100%;margin:0 0 8px" />
+ <label class="field-label" for="adminName">Administrator (optional)</label>
+ <input id="adminName" placeholder="e.g. Paul Campbell" maxlength="60" style="display:block;width:100%;margin:0 0 8px" />
+ <label class="field-label" for="adminEmail">Contact email (optional)</label>
+ <input id="adminEmail" type="email" placeholder="e.g. paul@example.com" maxlength="120" style="display:block;width:100%;margin:0 0 8px" />
+ <button type="submit">Save</button>
</form>
</section>
diff --git a/web/static/admin.js b/web/static/admin.js
index 7a18150..9ed4c04 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -62,20 +62,31 @@ async function wireUserInvites() {
};
}
-// ---- group name ---------------------------------------------------------
+// ---- this space (name + optional administrator identity) ----------------
async function wireGroupName() {
const input = document.getElementById("groupName");
+ const adminName = document.getElementById("adminName");
+ const adminEmail = document.getElementById("adminEmail");
try {
const { name } = await (await fetch("/api/group")).json();
if (name) input.value = name;
+ // The admin identity is member-gated on /api/group, so read it back via the
+ // admin-authed settings endpoint instead.
+ const settings = await (await api("GET", "/api/admin/settings")).json();
+ if (settings.admin_name) adminName.value = settings.admin_name;
+ if (settings.admin_email) adminEmail.value = settings.admin_email;
} catch {}
document.getElementById("nameForm").onsubmit = async (e) => {
e.preventDefault();
const name = input.value.trim();
if (!name) return;
- const res = await api("POST", "/api/admin/group", { name });
- toast(res.ok ? "Group name saved" : "Couldn't save name");
+ const res = await api("POST", "/api/admin/group", {
+ name,
+ admin_name: adminName.value.trim(),
+ admin_email: adminEmail.value.trim(),
+ });
+ toast(res.ok ? "Saved" : "Couldn't save");
};
}