eleven / messenger Public

server: viewer-private nickname-overrides blob (GET/POST /api/nicknames) (#596)

Storage for per-viewer names (#577): one opaque blob per member holding
their "I call them Dermo" overrides, sealed BY THE CLIENT to the member's
own public key — so unlike the instance-key blobs (label, about-me), not
even other members can open it; only the owner's devices, which share the
keypair. Token-gated GET/POST, additive users.nicknames_blob migration,
64 KiB cap, no roster exposure and no broadcast.

No UI yet — the web layer (resolution order: my override → published label
→ payload snapshot, rename detection, profile-sheet rename) is the next PR.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 13bed6146df143bf48f07d65e581d6e43c45b475 parent f2af1e7
3 files changed, +138 −0
  • db.go +21 −0
  • handlers_test.go +63 −0
  • main.go +54 −0
diff --git a/db.go b/db.go
index dfc24ab..ee1dcae 100644
--- a/db.go
+++ b/db.go
@@ -531,6 +531,11 @@ CREATE TABLE IF NOT EXISTS member_bots (
// A person's free-text "about me", encrypted under the instance key like
// their name and photo alt — user content, never readable by the server.
"ALTER TABLE users ADD COLUMN description_blob TEXT",
+ // A member's private per-viewer name overrides ("I call them Dermo",
+ // issue #577), sealed to their OWN public key — unlike the instance-key
+ // blobs above, not even other members can open it; the server holds
+ // ciphertext either way.
+ "ALTER TABLE users ADD COLUMN nicknames_blob TEXT",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -830,6 +835,22 @@ func (db *DB) setUserDescriptionBlob(userID, blob string) error {
return err
}
+// setUserNicknamesBlob stores the member's per-viewer name overrides (issue
+// #577), sealed to their OWN public key — readable only by their devices,
+// which share the keypair ("" clears it).
+func (db *DB) setUserNicknamesBlob(userID, blob string) error {
+ _, err := db.sql.Exec("UPDATE users SET nicknames_blob = ? WHERE id = ? AND COALESCE(is_agent, 0) = 0", blob, userID)
+ return err
+}
+
+// userNicknamesBlob returns the member's own sealed nickname overrides ("" when
+// never set).
+func (db *DB) userNicknamesBlob(userID string) (string, error) {
+ var blob string
+ err := db.sql.QueryRow("SELECT COALESCE(nicknames_blob, '') FROM users WHERE id = ?", userID).Scan(&blob)
+ return blob, err
+}
+
func (db *DB) userExists(id string) bool {
var x int
return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
diff --git a/handlers_test.go b/handlers_test.go
index 82b4020..f506847 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -1697,3 +1697,66 @@ func TestCustomAppsBroadcastsAppsChanged(t *testing.T) {
}
expectAppsChanged("delete")
}
+
+func TestNicknamesRoundTrip(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tok, _, _ := joinedMember(t, srv, "viewer")
+ other, _, _ := joinedMember(t, srv, "other")
+
+ get := func(token string) (*http.Response, string) {
+ r, err := http.Get(ts.URL + "/api/nicknames?token=" + token)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ var body struct {
+ Blob string `json:"nicknames_blob"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ return r, body.Blob
+ }
+ post := func(token, blob string) *http.Response {
+ r, err := http.Post(ts.URL+"/api/nicknames", "application/json",
+ strings.NewReader(`{"token":"`+token+`","nicknames_blob":"`+blob+`"}`))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ return r
+ }
+
+ // Token-gated on both verbs.
+ if r, _ := get("bogus"); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("bad-token get: want 403, got %d", r.StatusCode)
+ }
+ if r := post("bogus", "sealed"); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("bad-token post: want 403, got %d", r.StatusCode)
+ }
+
+ // Never set → empty blob, not an error.
+ if r, blob := get(tok); r.StatusCode != http.StatusOK || blob != "" {
+ t.Fatalf("unset: want 200/empty, got %d/%q", r.StatusCode, blob)
+ }
+
+ // Round-trip; the blob is opaque ciphertext to the server.
+ if r := post(tok, "sealed-overrides-v1"); r.StatusCode != http.StatusOK {
+ t.Fatalf("post: want 200, got %d", r.StatusCode)
+ }
+ if _, blob := get(tok); blob != "sealed-overrides-v1" {
+ t.Fatalf("round-trip: got %q", blob)
+ }
+
+ // Viewer-private: another member reads their OWN (empty) blob, never yours.
+ if _, blob := get(other); blob != "" {
+ t.Fatalf("other member saw a foreign blob: %q", blob)
+ }
+
+ // Oversized blob is rejected; "" clears.
+ if r := post(tok, strings.Repeat("a", 64*1024+1)); r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("oversized: want 400, got %d", r.StatusCode)
+ }
+ if r := post(tok, ""); r.StatusCode != http.StatusOK {
+ t.Fatalf("clear: want 200, got %d", r.StatusCode)
+ }
+ if _, blob := get(tok); blob != "" {
+ t.Fatalf("clear: got %q", blob)
+ }
+}
diff --git a/main.go b/main.go
index 66092b0..e943c97 100644
--- a/main.go
+++ b/main.go
@@ -370,6 +370,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/presence", s.postPresence)
mux.HandleFunc("POST /api/avatar", s.postAvatar)
mux.HandleFunc("POST /api/description", s.postDescription)
+ mux.HandleFunc("GET /api/nicknames", s.getNicknames)
+ mux.HandleFunc("POST /api/nicknames", s.postNicknames)
mux.HandleFunc("POST /api/group-description", s.postGroupDescription)
mux.HandleFunc("GET /api/threads", s.getThreads)
mux.HandleFunc("POST /api/threads", s.postThread)
@@ -1324,6 +1326,58 @@ func (s *Server) postDescription(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// The caller's per-viewer name overrides (issue #577): one blob holding every
+// "I call them Dermo", sealed by the client to the caller's OWN public key.
+// Viewer-private user content — it never rides the roster and never broadcasts
+// (a nickname is nobody else's business, not even its subject's); only the
+// owner's devices, which share the keypair, can open it. Opaque here, as ever.
+func (s *Server) getNicknames(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ blob, err := s.db.userNicknamesBlob(uid)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"nicknames_blob": blob})
+}
+
+func (s *Server) postNicknames(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ NicknamesBlob string `json:"nicknames_blob"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ // One sealed entry is ~100 bytes of ciphertext; 64 KiB covers hundreds of
+ // members with room to spare (the description's 4 KiB is one field, this
+ // blob is a whole map).
+ if !ok || len(body.NicknamesBlob) > 64*1024 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if err := s.db.setUserNicknamesBlob(uid, body.NicknamesBlob); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// postGroupDescription sets the space's description (admin only — the space's
// self-portrait is a branding decision, like its name). Plaintext, like the
// admin name/email: it must be editable from the keyless admin console and