eleven / messenger Public

server+web+native: broadcast admin promotion so open tabs don't go stale (#999)

Promoting an EXISTING member to admin (adopting a grants_admin invite)
never told any connected client — unlike a fresh joiner's first key,
which fires "joined". A tab with a roster cached from before the
promotion kept treating the new admin as an ordinary member, so a
report filed in that window silently omitted them from the
admin-encryption list; the Reports panel then showed a specific,
unverifiable "filed before you became an admin" message that could be
wrong (the true cause was staleness on the reporter's side, not
timing on the viewer's).

Broadcast a server-synthesized "role_changed" frame (mirroring
"joined") right after the DB commit, and refresh the roster on web and
native when it arrives. Also make the missing-content message honest
about the ambiguity instead of asserting a specific cause.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 04929aa4e4cf732de68f66d344221a7a2a9cd838 parent ff6c12c
7 files changed, +99 −5
  • changelog/2026-07-15-admin-promotion-sync.md +7 −0
  • ios/LChat/Views/ThreadListView.swift +6 −1
  • ios/LChatMac/NativeChatView.swift +6 −2
  • main.go +7 −0
  • roles_test.go +58 −0
  • web/static/apps/admin.js +6 −1
  • web/static/shell.js +9 −1
diff --git a/changelog/2026-07-15-admin-promotion-sync.md b/changelog/2026-07-15-admin-promotion-sync.md
new file mode 100644
index 0000000..9faf624
--- /dev/null
+++ b/changelog/2026-07-15-admin-promotion-sync.md
@@ -0,0 +1,7 @@
+🤖 Fixed: promoting an existing member to admin now tells every open tab
+right away, instead of waiting for a reconnect. Previously a stale tab could
+keep treating a freshly-promoted admin as a regular member for a while —
+notably, a member filing a report during that window would leave the new
+admin unable to read it, with the Reports panel showing a misleading
+"filed before you became an admin" message. That message is now more
+honest about what it can't verify.
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 67aa260..3c3bfaa 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -458,8 +458,13 @@ struct ThreadListView: View {
store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
return
}
+ // role_changed rides the same debounced refresh: an admin
+ // promotion (main.go adoptKeyInvite) is server-synthesized, not a
+ // stored-message mutation, but the roster it invalidates is the
+ // same one reportableAdmins and isAdmin-gated UI read — a stale
+ // copy would otherwise persist until the next msg/edit/delete.
guard frame.ephemeral != true,
- ["msg", "edit", "delete"].contains(frame.type) else { return }
+ ["msg", "edit", "delete", "role_changed"].contains(frame.type) else { return }
guard !refreshPending else { return }
refreshPending = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 854d2ed..367695a 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -1458,9 +1458,13 @@ struct NativeChatView: View {
}
// Any stored-message mutation anywhere in the instance refreshes
// the sidebar (previews, ordering, unread) — debounced so a burst
- // is one refresh.
+ // is one refresh. role_changed rides the same path: an admin
+ // promotion is server-synthesized, not a stored-message mutation,
+ // but invalidates the same roster reportableAdmins and
+ // isAdmin-gated UI read — without this a stale copy would
+ // persist until the next msg/edit/delete.
guard frame.ephemeral != true,
- ["msg", "edit", "delete"].contains(frame.type) else { return }
+ ["msg", "edit", "delete", "role_changed"].contains(frame.type) else { return }
if frame.type == "msg" { notifyIfBackgrounded(frame) }
scheduleRefresh()
}
diff --git a/main.go b/main.go
index 4886941..d95e425 100644
--- a/main.go
+++ b/main.go
@@ -3855,6 +3855,13 @@ func (s *Server) adoptKeyInvite(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "invite unavailable")
return
}
+ // Unlike a fresh joiner's first key (which fires "joined"), promoting an
+ // EXISTING member to admin touches no roster field a client watches for —
+ // so every other open tab kept treating this user as a non-admin (e.g.
+ // when building a report's admin-encryption list) until something else
+ // happened to trigger a reload. Tell open tabs now.
+ out, _ := json.Marshal(map[string]any{"type": "role_changed", "user_id": uid})
+ s.hub.broadcast <- outbound{data: out}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
diff --git a/roles_test.go b/roles_test.go
index e3ae473..3a586a5 100644
--- a/roles_test.go
+++ b/roles_test.go
@@ -1,10 +1,14 @@
package main
import (
+ "context"
"encoding/json"
"net/http"
"strings"
"testing"
+ "time"
+
+ "github.com/coder/websocket"
)
// A setup invite (grants_admin) makes its claimer an admin; ordinary invites
@@ -60,6 +64,60 @@ func TestSetupInviteGrantsAdminRole(t *testing.T) {
}
}
+// Promoting an EXISTING member to admin (adopting a grants_admin invite on an
+// already-joined account, as opposed to a fresh claim) must tell open tabs
+// right away — otherwise a stale roster elsewhere (e.g. someone mid-way
+// through filing a report, building its admin-encryption list from their own
+// cached roster) silently omits the newly-promoted admin.
+func TestAdminPromotionBroadcastsRoleChanged(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ danaTok, danaID, _ := joinedMember(t, srv, "Dana")
+ erinTok, _, erinTid := joinedMember(t, srv, "Erin")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ // Erin's tab is already open, watching, when Dana gets promoted.
+ obs := dialWS(t, ctx, ts.URL, erinTok, erinTid)
+ defer obs.Close(websocket.StatusNormalClosure, "")
+
+ // Minted the way an admin would to promote an existing member.
+ r := adminPost(t, ts.URL+"/api/admin/key-invites",
+ `{"id":"promote-dana","claim_hash":"`+testClaimHash+`","grants_admin":true}`)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("create admin invite: want 200, got %d", r.StatusCode)
+ }
+
+ resp, err := http.Post(ts.URL+"/api/key-invite/promote-dana/adopt", "application/json",
+ strings.NewReader(`{"token":"`+danaTok+`","claim_secret":"`+testClaimSecret+`"}`))
+ if err != nil {
+ t.Fatalf("adopt: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("adopt: want 200, got %d", resp.StatusCode)
+ }
+ if role := srv.db.userRole(danaID); role != "admin" {
+ t.Fatalf("role after adopt: want admin, got %q", role)
+ }
+
+ for {
+ _, data, err := obs.Read(ctx)
+ if err != nil {
+ t.Fatalf("read broadcast: %v", err)
+ }
+ var msg struct {
+ Type string `json:"type"`
+ UserID string `json:"user_id"`
+ }
+ if json.Unmarshal(data, &msg) == nil && msg.Type == "role_changed" {
+ if msg.UserID != danaID {
+ t.Fatalf("role_changed user_id: want %q, got %q", danaID, msg.UserID)
+ }
+ return
+ }
+ }
+}
+
// Admin endpoints accept a member device token when the member's user holds the
// admin role — and still reject everyone else's.
func TestRoleBasedAdminAuth(t *testing.T) {
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 8d79b44..d84c7ab 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -962,7 +962,12 @@ export const app = defineApp({
const na = document.createElement("p");
na.className = "admin-report-words";
na.style.fontStyle = "italic";
- na.textContent = "Filed before you became an admin — its contents are sealed to the admins of that moment.";
+ // Missing content has more than one possible cause (filed before this
+ // admin existed, or the reporter's tab had a stale roster at filing
+ // time and silently omitted them) and the server has no reliable way
+ // to tell them apart after the fact — so this stays honest rather
+ // than asserting a specific one.
+ na.textContent = "Not readable — its contents weren't sealed to you at filing time.";
box.appendChild(na);
}
diff --git a/web/static/shell.js b/web/static/shell.js
index e2c8696..5d34201 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -4959,7 +4959,7 @@ setSetupBridge({
try {
rep.content = await openSealed(rep.blob, session.userPriv);
} catch {
- rep.content = null; // filed before I was admin — not sealed to me
+ rep.content = null; // wasn't sealed to me at filing time — unknown cause
}
}
delete rep.blob;
@@ -6306,6 +6306,14 @@ function connectWS() {
if (t && (t.members || []).includes(msg.user_id)) updatePendingBanner(t);
return;
}
+ if (msg.type === "role_changed") {
+ // Someone was promoted to admin: refresh the roster so isAdmin() checks
+ // (nav entry points, invite gating) and anything that builds an
+ // admin-encryption list from the roster (e.g. filing a report) see it
+ // right away — not just after this tab's next reconnect.
+ await loadRoster();
+ return;
+ }
if (msg.type === "instance_key_nudge") {
// A member lacks the instance key. The nudge names WHO needs it, but we
// never wrap to a key handed to us in a raw frame — a compromised server