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>
7 files changed,
+99
−5
changelog/2026-07-15-admin-promotion-sync.md+7 −0ios/LChat/Views/ThreadListView.swift+6 −1ios/LChatMac/NativeChatView.swift+6 −2main.go+7 −0roles_test.go+58 −0web/static/apps/admin.js+6 −1web/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 |