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>
2 files changed,
+57
−1
main.go+17 −0web/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; |