eleven / messenger Public

fix: unstick 'Photo syncing…' — nudge holders to grant the instance key

A member who joined after the instance key was bootstrapped held no copy, and the
key is wrapped per-member (server-blind), so they waited indefinitely for a holder
to next reboot and re-grant. Now: a keyless member POSTs /api/instance-key/nudge,
the server relays a keyless hint to everyone, and any online holder re-wraps the
key to whoever's missing it; the keyless member polls briefly to pick up the grant
and re-renders avatars. No key material crosses the server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell bb3d756efa5f8d8b5dfb0a0e1a5fea6d72fb717d parent 2953c33
2 files changed, +57 −1
  • main.go +17 −0
  • web/static/chat.js +40 −1
diff --git a/main.go b/main.go
index 634e42a..a877a30 100644
--- a/main.go
+++ b/main.go
@@ -113,6 +113,7 @@ func (s *Server) handler() http.Handler {
// Instance key + profile photos: a shared key for all-members content (avatars).
mux.HandleFunc("GET /api/instance-key", s.getInstanceKey)
mux.HandleFunc("POST /api/instance-key", s.postInstanceKey)
+ mux.HandleFunc("POST /api/instance-key/nudge", s.nudgeInstanceKey)
mux.HandleFunc("POST /api/avatar", s.postAvatar)
mux.HandleFunc("GET /api/threads", s.getThreads)
mux.HandleFunc("POST /api/threads", s.postThread)
@@ -580,6 +581,22 @@ func (s *Server) postInstanceKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// nudgeInstanceKey is called by a member who holds no copy of the instance key. It
+// relays a keyless "someone needs it" hint to every connected client; holders
+// respond by (re-)wrapping the key to whoever's missing it (see the WS handler +
+// grantInstanceKeyTo in the client). Carries no key material — just a prod, so a
+// new member doesn't have to wait for a holder to next reboot.
+func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ out, _ := json.Marshal(map[string]any{"type": "instance_key_nudge"})
+ s.hub.broadcast <- outbound{data: out} // members:nil → everyone
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// postAvatar sets the caller's profile photo (an encrypted blob id, or "" to clear).
func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) {
var body struct {
diff --git a/web/static/chat.js b/web/static/chat.js
index c6717b6..64af514 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -193,8 +193,36 @@ async function setupInstanceKey() {
instanceKey = k.key;
instanceKeyB64 = k.b64;
await grantInstanceKeyTo([]);
+ } else {
+ // It exists but we don't hold a copy yet. Nudge any online holder to wrap it to
+ // us, then poll briefly to pick up the grant — otherwise we'd be stuck on
+ // "Photo syncing…" until a holder next reboots.
+ fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {});
+ pollForInstanceKey();
+ }
+}
+
+// After nudging holders, poll for a wrapped instance key; once granted, unwrap it
+// and re-render avatars. (The open Edit-profile sheet picks it up on next open.)
+async function pollForInstanceKey(tries = 6) {
+ for (let i = 0; i < tries && !instanceKey; i++) {
+ await new Promise((res) => setTimeout(res, 2500));
+ if (instanceKey) return;
+ let r;
+ try {
+ r = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json();
+ } catch {
+ continue;
+ }
+ if (r.wrapped) {
+ try {
+ instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv);
+ instanceKey = await importKey(instanceKeyB64);
+ refreshAvatars();
+ } catch {}
+ return;
+ }
}
- // else: it exists but we weren't granted it yet — a member will grant it; skip.
}
// Wrap the instance key to every roster member not already in `have`.
@@ -1372,6 +1400,17 @@ function connectWS() {
} catch {
return;
}
+ if (msg.type === "instance_key_nudge") {
+ // A member lacks the instance key. If we hold it, (re-)wrap it to whoever's
+ // missing it so they can decrypt profile photos (and set their own).
+ if (instanceKeyB64) {
+ try {
+ const ik = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json();
+ await grantInstanceKeyTo(ik.have || []);
+ } catch {}
+ }
+ return;
+ }
if (msg.type === "avatar") {
const e = roster.find((x) => x.user_id === msg.user_id);
if (e) e.avatar = msg.blob_id;