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>
3 files changed,
+138
−0
db.go+21 −0handlers_test.go+63 −0main.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 |