Moderation 5/6: ban, spam brakes, invite hygiene (#578) (#758)
* server+web: mute a chat, mute a person (#578) server: muted_threads marker (pinned/hidden pattern) skipped by push fan-out on both transports; sealed per-user prefs blob (/api/prefs, CAS) for the muted-people set the server can't read. web: kebab Mute chat, thread-list slashed bell + dot suppression, profile Mute person, message-menu Mute/Unmute, muted-stub fold with per-session reveal, SW notification gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: changelog entry for mute Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web: DM privacy + end conversation (#578) server: users.dm_privacy door enforced at DM creation (admin+bot carve-out); closed_dms state refuses msg/edit/typing on both transports, kills queued scheduled deliveries, hides for both sides; reopen follows the other side's door. web: Edit-profile DM setting, DM kebab End conversation, closed banner with Reopen replacing the composer, profile Message button says when a door is closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios+mac: mute a chat, mute a person (#578) Mute chat in thread menus/swipe; bell.slash replaces the unread dot; NSE goes passive for muted threads + muted senders; muted-people set synced via the sealed prefs blob (CAS, web-compatible shape); muted bubbles fold to a Show stub. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web: report to admins, moderation inbox, warn, audit log (#578) server: reports queue metadata + per-admin sealed content blobs, 5/hour filing brake, resolve-once semantics; audit_log appended by admin removal, admin group-removal, level removal, warn, and resolution. web: Report on message long-press + profile (category, note, quoted evidence, also-mute), sealed to each admin via sealForPub; Admin app Reports inbox (repeat signals, warn/remove/demote ladder, inline expanders) + Audit log; warnings render with a notice framing (warn flag pinned in the event vectors). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web: block a person (#578) server: blocks table (connection metadata); enforced at DM create + reopen (both directions, admin/bot carve-out so notices land), group adds both ways, push fan-out (incl. scheduled); blocking closes any existing DM via the closed-DM machinery. Unblock restores possibility, never the conversation. web: profile Block/Unblock with honest copy, report sheet 'Also block', Message button hidden for blocked people, block implies mute. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios+mac: DM privacy + end conversation (#578) DM door picker in Settings, End conversation in DM menus, closed banner with Reopen replacing the composer, dm_closed/dm_reopened WS folds, typed dms-closed refusal on new DMs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web: ban, spam brakes, invite hygiene (#578) server: bans keyed on the member's public key (the one durable E2EE handle), enforced at pubkey registration so every invite path is covered; removeUser now also sweeps the member's queued scheduled messages; content-blind token-bucket brakes on messages (30 burst), new DMs (day-one probation), and invite minting — admins exempt; users.invited_by recorded at claim; invite-pause flips audited. web: Remove & ban in the report queue; 'invited by' in report bylines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server: init moderation brakes in handler() (nil-safe, race-free) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios+mac: report to admins + warn framing (#578) Report in message menus + profile sheet; shared ReportSheet seals the web-shape content to each admin key (ECIES); MessagePayload.warn + notice framing (EventVectorTests case for the admin_warning_message vector). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios+mac: block a person (#578) Profile Block/Unblock (destructive, honest footnotes), report sheet 'Also block', blockedPeople synced in refresh, block implies mute + mirrors the server's DM close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * admin: expander actions keep the row — true meant refresh, wiping the expander just opened Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * admin: Remove & ban's cancelled confirm keeps the row too Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * admin: queue resolutions repaint the audit log too Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: live mute folds — mix muted state into the keyed-list rev Found in the browser drive: keyedList skips rows whose rev didn't change, so muting a person folded nothing until a reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: align chat.js byte-identically with the base branch (stray hunk placement) * web: size the report sheet's checkboxes — the width:100% input rule shoved their labels out of the card Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web+sw: throttled admin notification when a report lands (#578) One push per quiet window (15 min) however many reports arrive; each banner carries the open count so the throttle loses nothing. Same plaintext-operational class + transports as the changelog announcement (Web Push + APNs), shown even while the app is open; live admin sessions also repaint the queue on the report_filed frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: report sheet shows work while sending — form freezes, button spins Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: freeze the Also-block toggle with the rest of the sending report form * server+web: report arrivals light the Admin thread + write a line in its stream The throttled report_notice frame (once per burst, admins only) drives the per-device dot + hidden-tab banner — a self-posted bubble can't light your own dot — while one visible session writes the durable '⚠️ A report came in' line into the Admin app's Chat stream; the plaintext push keeps covering fully closed apps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: report success replaces the sheet with a confirmation, not a buttonless frozen form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server: edits share the message brake — edit-churn is message spam wearing a hat Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * push.go: true union of the seam — main's id/at payload + the stack's muted/blocking drops * re-graft #807's db.go/main.go/shell.js hunks lost to a stale superset assumption (main moved mid-fold) * re-graft #808's db.go hunks (key transparency) lost to the seam resolution ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+416
−3
ban_test.go+134 −0changelog/2026-07-12-ban-brakes.md+8 −0db.go+54 −2hub.go+13 −0main.go+84 −0ratelimit.go+94 −0web/static/apps/admin.js+16 −1web/static/shell.js+13 −0
diff --git a/ban_test.go b/ban_test.go| new file mode 100644 |
| index 0000000..bcc7a25 |
| --- /dev/null |
| +++ b/ban_test.go |
| @@ -0,0 +1,134 @@ |
| +package main |
| + |
| +import ( |
| + "strconv" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Ban = remove + the key stays out: the banned public key can't re-register |
| +// through any later invite, the member's queued scheduled messages die with |
| +// them, and the act is audited. |
| +func TestBan(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") |
| + if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { |
| + t.Fatal(err) |
| + } |
| + tokBad, uidBad, tidBad := joinedMember(t, srv, "Spammer") |
| + if err := srv.db.setUserPublicKey(uidBad, "PUBKEY-SPAMMER"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A queued future message from the spammer. |
| + due := time.Now().Unix() |
| + if code, _ := modPost(t, ts.URL+"/api/schedule", |
| + `{"token":"`+tokBad+`","thread_id":"`+tidBad+`","deliver_at":`+strconv.FormatInt(due, 10)+`,"blob":"later"}`); code != 200 { |
| + t.Fatal("schedule should work pre-ban") |
| + } |
| + |
| + if code, _ := modAdminPost(t, ts.URL+"/api/admin/users/"+uidBad+"/ban", tokAdm, ""); code != 200 { |
| + t.Fatal("ban should succeed") |
| + } |
| + if srv.db.userExists(uidBad) { |
| + t.Fatal("banned member should be removed") |
| + } |
| + if due, _ := srv.db.dueScheduledMessages(1<<60, 10); len(due) != 0 { |
| + t.Fatalf("banned member's scheduled messages should die, still %d", len(due)) |
| + } |
| + if !srv.db.keyBanned("PUBKEY-SPAMMER") { |
| + t.Fatal("the key should be on the ban list") |
| + } |
| + |
| + // The same key can't re-register through a fresh invite: a new claimer |
| + // (keyless — pubkeys publish AFTER claim in the real flow) is refused the |
| + // banned key but accepted with a fresh one. joinedMember pre-registers a |
| + // key, so build the keyless joiner by hand. |
| + uidNew, err := srv.db.createUser("Reborn") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := srv.db.createKeyInvite("inv-reborn", uidNew, "wrap", testClaimHash, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + tokNew, ok, err := srv.db.claimKeyInvite("inv-reborn", "Reborn", "", testClaimSecret) |
| + if err != nil || !ok { |
| + t.Fatalf("claim: ok=%v err=%v", ok, err) |
| + } |
| + code, _ := modPost(t, ts.URL+"/api/pubkey", `{"token":"`+tokNew+`","public_key":"PUBKEY-SPAMMER"}`) |
| + if code != 403 { |
| + t.Fatalf("banned key re-registering should be 403, got %d", code) |
| + } |
| + // A different key is fine — a ban stops the key, not the invite path. |
| + if code, _ := modPost(t, ts.URL+"/api/pubkey", `{"token":"`+tokNew+`","public_key":"PUBKEY-FRESH"}`); code != 200 { |
| + t.Fatalf("a fresh key should register, got %d", code) |
| + } |
| + |
| + // Audited. |
| + entries, err := srv.db.listAudit(0, 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range entries { |
| + if e.Action == "member.banned" && e.SubjectID == uidBad && e.ActorID == uidAdm { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("ban should be audited, got %v", entries) |
| + } |
| +} |
| + |
| +// The content-blind brakes: messages flood-stop (REST path), DM opens are |
| +// capped tighter for day-one members, and invite minting has a ceiling. |
| +// Admins are exempt from all three. |
| +func TestModerationBrakes(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + tokA, uidA, tid := joinedMember(t, srv, "Chatty") |
| + // Messages: the burst is 30; the 31st in the same instant bounces. |
| + sent := 0 |
| + for i := 0; i < 40; i++ { |
| + code, _ := modPost(t, ts.URL+"/api/messages?token="+tokA, `{"thread_id":"`+tid+`","blob":"m"}`) |
| + if code == 200 { |
| + sent++ |
| + } else if code == 429 { |
| + break |
| + } else { |
| + t.Fatalf("unexpected %d", code) |
| + } |
| + } |
| + if sent != 30 { |
| + t.Fatalf("the message brake should admit the burst of 30, admitted %d", sent) |
| + } |
| + |
| + // DM opens: a brand-new member (created just now) hits the day-one cap of 3. |
| + opened := 0 |
| + for i := 0; i < 5; i++ { |
| + _, uidT, _ := joinedMember(t, srv, "T"+strconv.Itoa(i)) |
| + code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidT)) |
| + if code == 200 { |
| + opened++ |
| + } else if code == 429 { |
| + break |
| + } else { |
| + t.Fatalf("unexpected %d on dm %d", code, i) |
| + } |
| + } |
| + if opened != 3 { |
| + t.Fatalf("a day-one member should open at most 3 DMs, opened %d", opened) |
| + } |
| + |
| + // Admins are exempt from the message brake. |
| + tokAdm, uidAdm, tidAdm := joinedMember(t, srv, "Root") |
| + if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := 0; i < 35; i++ { |
| + if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokAdm, `{"thread_id":"`+tidAdm+`","blob":"m"}`); code != 200 { |
| + t.Fatalf("admin message %d should pass, got %d", i, code) |
| + } |
| + } |
| +} |
| + |
diff --git a/changelog/2026-07-12-ban-brakes.md b/changelog/2026-07-12-ban-brakes.md| new file mode 100644 |
| index 0000000..3c965c2 |
| --- /dev/null |
| +++ b/changelog/2026-07-12-ban-brakes.md |
| @@ -0,0 +1,8 @@ |
| +🤖 Admins get the last rungs of the moderation ladder: "Remove & ban" keeps a |
| +removed member out — their key can't rejoin through any invite — and every |
| +report in the inbox now shows who invited the person, so a bad invite chain |
| +is visible. Quiet spam brakes are on for everyone: far above any human pace, |
| +a flood of messages, new chats, or invites simply stops landing (admins are |
| +exempt, and day-one members can't mass-DM). The "Members can invite people" |
| +switch remains the panic lever during a spam wave — flipping it is now |
| +recorded in the audit log. |
diff --git a/db.go b/db.go| index 55a9ab4..763f2f5 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -426,6 +426,18 @@ CREATE TABLE IF NOT EXISTS blocks ( |
| created_at INTEGER NOT NULL, |
| PRIMARY KEY (blocker_id, blocked_id) |
| ); |
| +-- bans (issue #578): removal that stays removed. Bans key on the member's |
| +-- PUBLIC KEY — the one durable handle in an E2EE roster — so the same key |
| +-- can't re-register through any invite (admin, member, or cohort). Honest |
| +-- limit, documented in docs/moderation.md: a determined person with a fresh |
| +-- invite and a fresh keypair is a new stranger; a ban stops re-entry, not |
| +-- reincarnation — pair it with pausing member invites during trouble. |
| +CREATE TABLE IF NOT EXISTS bans ( |
| + public_key TEXT PRIMARY KEY, |
| + user_id TEXT NOT NULL, |
| + banned_by TEXT NOT NULL, |
| + banned_at INTEGER NOT NULL |
| +); |
| -- reports (issue #578): the moderation queue's METADATA — who reported whom, |
| -- where, and what became of it. The report's CONTENT (category, words, quoted |
| -- evidence) never lands here: it travels in report_blobs, sealed to each |
| @@ -814,6 +826,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_seq_from ON checkpoints(seq_fr |
| // welcome DM). Enforced server-side at DM creation, where a client-only |
| // check would be bypassable. A routing preference, never content. |
| "ALTER TABLE users ADD COLUMN dm_privacy TEXT NOT NULL DEFAULT ''", |
| + // Invite lineage (issue #578): which member's invite brought this |
| + // person in ('' for admin/setup invites and everyone pre-dating the |
| + // column). Written at claim time; the report queue shows it so a |
| + // problem member's way in is visible. |
| + "ALTER TABLE users ADD COLUMN invited_by TEXT NOT NULL DEFAULT ''", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -1247,6 +1264,14 @@ func (db *DB) userPrefsBlob(userID string) (string, int64, error) { |
| return blob, version, err |
| } |
| +// userCreatedAt returns when a member joined (0 when unknown) — the |
| +// moderation brakes use it for new-member probation. |
| +func (db *DB) userCreatedAt(id string) int64 { |
| + var t int64 |
| + db.sql.QueryRow("SELECT COALESCE(created_at, 0) FROM users WHERE id = ?", id).Scan(&t) |
| + return t |
| +} |
| + |
| func (db *DB) userExists(id string) bool { |
| var x int |
| return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil |
| @@ -1470,6 +1495,11 @@ func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) ( |
| if err := db.addThreadMember(ki.ThreadID, ki.UserID, wrappedSelf); err != nil { |
| return "", false, err |
| } |
| + // Invite lineage (issue #578): the pending DM's other member is who |
| + // invited them — recorded so moderation can see a member's way in. |
| + if inviter, ok := db.otherThreadMember(ki.ThreadID, ki.UserID); ok { |
| + _, _ = db.sql.Exec("UPDATE users SET invited_by = ? WHERE id = ? AND COALESCE(invited_by, '') = ''", inviter, ki.UserID) |
| + } |
| } |
| if ki.GrantsAdmin { |
| // A setup invite: its claimer is the (first) admin. Role is authorisation |
| @@ -1804,6 +1834,7 @@ func (db *DB) removeUser(id string) error { |
| {"DELETE FROM tier_keys WHERE user_id = ?", []any{id}}, |
| {"DELETE FROM dm_pairs WHERE user_a = ? OR user_b = ?", []any{id, id}}, |
| {"DELETE FROM messages WHERE author_hash = ?", []any{id}}, |
| + {"DELETE FROM scheduled_messages WHERE author_id = ?", []any{id}}, |
| {"DELETE FROM push_subscriptions WHERE user_id = ?", []any{id}}, |
| {"DELETE FROM users WHERE id = ?", []any{id}}, |
| } { |
| @@ -3142,6 +3173,9 @@ type Report struct { |
| Blob string `json:"blob,omitempty"` |
| ReporterCount int `json:"reporter_count"` |
| ReportedCount int `json:"reported_count"` |
| + // InvitedBy is who brought the reported member in ('' when unknown) — |
| + // invite hygiene for the queue (issue #578). |
| + InvitedBy string `json:"invited_by,omitempty"` |
| } |
| // createReport stores the queue row + the per-admin sealed content blobs in |
| @@ -3189,7 +3223,8 @@ func (db *DB) listReports(forAdminID string) ([]Report, error) { |
| r.status, r.action, r.resolved_by, r.resolved_at, |
| COALESCE((SELECT blob FROM report_blobs b WHERE b.report_id = r.id AND b.admin_id = ?), ''), |
| (SELECT COUNT(*) FROM reports x WHERE x.reporter_id = r.reporter_id), |
| - (SELECT COUNT(*) FROM reports x WHERE x.reported_id = r.reported_id) |
| + (SELECT COUNT(*) FROM reports x WHERE x.reported_id = r.reported_id), |
| + COALESCE((SELECT invited_by FROM users u WHERE u.id = r.reported_id), '') |
| FROM reports r |
| ORDER BY (r.status = 'open') DESC, r.created_at DESC |
| LIMIT 500`, forAdminID) |
| @@ -3202,7 +3237,7 @@ func (db *DB) listReports(forAdminID string) ([]Report, error) { |
| var r Report |
| if err := rows.Scan(&r.ID, &r.CreatedAt, &r.ReporterID, &r.ReportedID, &r.ThreadID, |
| &r.Status, &r.Action, &r.ResolvedBy, &r.ResolvedAt, &r.Blob, |
| - &r.ReporterCount, &r.ReportedCount); err != nil { |
| + &r.ReporterCount, &r.ReportedCount, &r.InvitedBy); err != nil { |
| return nil, err |
| } |
| out = append(out, r) |
| @@ -3346,6 +3381,23 @@ func (db *DB) blockersOf(userID string) (map[string]bool, error) { |
| return out, rows.Err() |
| } |
| +// addBan records a banned public key. Idempotent. |
| +func (db *DB) addBan(publicKey, userID, bannedBy string) error { |
| + _, err := db.sql.Exec( |
| + "INSERT OR IGNORE INTO bans(public_key, user_id, banned_by, banned_at) VALUES(?, ?, ?, ?)", |
| + publicKey, userID, bannedBy, time.Now().Unix(), |
| + ) |
| + return err |
| +} |
| + |
| +// keyBanned reports whether a public key is banned — checked wherever a key |
| +// registers (setPubkey covers every invite path, since a member exists only |
| +// once their key does). |
| +func (db *DB) keyBanned(publicKey string) bool { |
| + var x int |
| + return db.sql.QueryRow("SELECT 1 FROM bans WHERE public_key = ?", publicKey).Scan(&x) == nil |
| +} |
| + |
| // setThreadMuted mutes (muted=true) or unmutes (muted=false) a chat for one |
| // user — a per-user marker, synced across their devices via getThreads. The |
| // push fan-out consults it so a muted chat never buzzes any of their devices. |
diff --git a/hub.go b/hub.go| index 923b3d4..334a1fc 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -300,6 +300,11 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| if in.Blob == "" || len(in.Blob) > maxBlobBytes { |
| continue |
| } |
| + // The content-blind message brake (issue #578): far above any |
| + // human pace; a flood just stops landing. Admins exempt. |
| + if s.db.userRole(userID) != "admin" && !s.brake().messages.allow(userID) { |
| + continue |
| + } |
| // Ephemeral: relay to the thread's members and forget — no store, no |
| // push. This generalises the dedicated "typing" frame below for |
| // mini-app presence traffic (the client marks the frame; content |
| @@ -325,6 +330,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| !s.db.messageInThread(in.MessageID, in.ThreadID) { |
| continue |
| } |
| + // Edits share the message bucket — each one broadcasts to the whole |
| + // thread, so edit-churn is just message spam wearing a hat. (Typing |
| + // and ephemeral relays stay unthrottled on purpose: never stored, |
| + // never pushed, and burning message tokens on typing beats would |
| + // starve real sends.) |
| + if s.db.userRole(userID) != "admin" && !s.brake().messages.allow(userID) { |
| + continue |
| + } |
| m, ok, err := s.db.editMessage(in.MessageID, in.Blob, userID, editWindow) |
| if err != nil { |
| log.Println("edit message:", err) |
diff --git a/main.go b/main.go| index e6cf93a..03f04fe 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -90,6 +90,10 @@ type Server struct { |
| // Directory of sideloaded mini-app modules served at /static/apps-custom/ |
| // (LCHAT_APPS_DIR); empty disables the feature. See customapps.go. |
| appsDir string |
| + // brakes are the content-blind moderation rate limits (ratelimit.go); |
| + // lazily nil-safe via s.brake() so tests constructing Server{} literals |
| + // keep working. |
| + brakes *modBrakes |
| // The report-notification throttle (issue #578): one push per quiet |
| // window however many reports land. In-memory on purpose — a restart at |
| // worst re-notifies once, and the queue itself is the durable record. |
| @@ -259,6 +263,7 @@ func main() { |
| maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes), |
| blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes), |
| appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))} |
| + s.brakes = newModBrakes() |
| // Tamper evidence (checkpoint.go): by default the signing seed lives in |
| // the DB config so it survives hibernation wakes and migrations; set |
| @@ -355,6 +360,12 @@ func listen(addr, socketPath string) (net.Listener, string, error) { |
| // handler wires up all routes. It's a method so tests can exercise the full |
| // router via httptest without starting a real listener. |
| func (s *Server) handler() http.Handler { |
| + // The moderation brakes (ratelimit.go) exist before the first request — |
| + // handler() is the one door every construction path (main, tests) passes |
| + // through, so the lazy s.brake() fallback never races concurrent handlers. |
| + if s.brakes == nil { |
| + s.brakes = newModBrakes() |
| + } |
| staticFS, err := fs.Sub(webFS, "web/static") |
| if err != nil { |
| log.Fatalf("static fs: %v", err) |
| @@ -461,6 +472,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/admin/reports/{id}/resolve", s.resolveReportHandler) |
| mux.HandleFunc("POST /api/admin/warn", s.adminWarn) |
| mux.HandleFunc("GET /api/admin/audit", s.adminAudit) |
| + mux.HandleFunc("POST /api/admin/users/{id}/ban", s.banUser) |
| mux.HandleFunc("POST /api/group-description", s.postGroupDescription) |
| mux.HandleFunc("GET /api/threads", s.getThreads) |
| mux.HandleFunc("POST /api/threads", s.postThread) |
| @@ -917,6 +929,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "conversation closed") |
| return |
| } |
| + // Same content-blind message brake as the WebSocket case (issue #578). |
| + if s.db.userRole(userID) != "admin" && !s.brake().messages.allow(userID) { |
| + httpError(w, http.StatusTooManyRequests, "sending too fast — give it a moment") |
| + return |
| + } |
| m, err := s.db.insertMessage(body.ThreadID, body.Blob, userID) |
| if err != nil { |
| log.Println("insert message:", err) |
| @@ -930,6 +947,15 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, m) |
| } |
| +// brake returns the rate-limit set, creating it on first use — test servers |
| +// build Server directly and must brake identically to production. |
| +func (s *Server) brake() *modBrakes { |
| + if s.brakes == nil { |
| + s.brakes = newModBrakes() |
| + } |
| + return s.brakes |
| +} |
| + |
| // tokenOK reports whether a token is a valid participant credential: either a |
| // current non-revoked invite (legacy) or a device token (invite redesign). |
| func (s *Server) tokenOK(token string) bool { |
| @@ -969,6 +995,12 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| // DIFFERENT key — that would orphan every thread wrapped to the old key (e.g. an |
| // admin "device link" that made a new device generate its own keypair). Adding a |
| // device ships the SAME keypair, so re-registering the identical key is fine. |
| + // A banned key can't re-register (issue #578) — this one gate covers every |
| + // invite path, because a member only becomes real once their key does. |
| + if s.db.keyBanned(body.PublicKey) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| firstKey := s.db.userPublicKey(uid) == "" |
| if cur := s.db.userPublicKey(uid); cur != "" && cur != body.PublicKey { |
| httpError(w, http.StatusConflict, "public key already registered") |
| @@ -1208,6 +1240,12 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "dms closed") |
| return |
| } |
| + // The mass-DM brake (issue #578): fresh members get the tighter |
| + // first-day bucket — the classic spam-join opens DMs immediately. |
| + if s.db.userRole(uid) != "admin" && !s.db.isAgent(uid) && !s.allowDM(uid, s.db.userCreatedAt(uid)) { |
| + httpError(w, http.StatusTooManyRequests, "opening chats too fast — give it a moment") |
| + return |
| + } |
| } |
| id, err := s.db.createThreadVis(body.Kind, body.NameBlob, body.Members, body.Visibility, body.OpenKey, body.OpenTier) |
| if err != nil { |
| @@ -3326,6 +3364,36 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// banUser removes a member AND remembers their public key (issue #578), so |
| +// the same key can't re-register through any later invite — admin, member, or |
| +// cohort. removeUser's sweep already covers their content, tokens, queued |
| +// scheduled messages, and level memberships. |
| +func (s *Server) banUser(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + uid := r.PathValue("id") |
| + if !s.db.userExists(uid) { |
| + httpError(w, http.StatusBadRequest, "no such user") |
| + return |
| + } |
| + // Capture the key BEFORE removal deletes the row; a keyless member (a |
| + // pending invitee) has nothing durable to ban — removal alone covers them. |
| + pub := s.db.userPublicKey(uid) |
| + if err := s.db.removeUser(uid); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + if pub != "" { |
| + if err := s.db.addBan(pub, uid, s.adminActor(r)); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + s.db.addAudit(s.adminActor(r), "member.banned", uid, "", "", "") |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // adminActor resolves who performed an admin action for the audit log: the |
| // admin member's uid when the bearer is a device token, else "console" (the |
| // break-glass admin token has no member identity). |
| @@ -3636,6 +3704,12 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "inviting is turned off") |
| return |
| } |
| + // The invite-minting brake (issue #578): content-blind, admin-exempt — |
| + // nobody human mints twenty invites in a day by hand. |
| + if s.db.userRole(uid) != "admin" && !s.brake().invites.allow(uid) { |
| + httpError(w, http.StatusTooManyRequests, "that's a lot of invites — try again tomorrow") |
| + return |
| + } |
| if body.ID == "" || len(body.ID) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 || |
| body.ClaimHash == "" || len(body.ClaimHash) > 128 { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -4235,10 +4309,20 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if body.UserInvites != nil { |
| + was := s.db.usersCanInvite() |
| if err := s.db.setUsersCanInvite(*body.UserInvites); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // The invite pause is the moderation panic switch (issue #578) — |
| + // flips get a paper trail like every other moderation action. |
| + if was != *body.UserInvites { |
| + act := "invites.resumed" |
| + if !*body.UserInvites { |
| + act = "invites.paused" |
| + } |
| + s.db.addAudit(s.adminActor(r), act, "", "", "", "") |
| + } |
| } |
| if body.AIEnabled != nil { |
| if err := s.db.setAIEnabled(*body.AIEnabled); err != nil { |
diff --git a/ratelimit.go b/ratelimit.go| new file mode 100644 |
| index 0000000..f1ca033 |
| --- /dev/null |
| +++ b/ratelimit.go |
| @@ -0,0 +1,94 @@ |
| +package main |
| + |
| +// Content-blind spam brakes (issue #578): per-user token buckets on the few |
| +// endpoints a flood abuses — messages, new DMs, invites. The server enforces |
| +// them without reading anything (pure metadata), admins are exempt, and the |
| +// ceilings are far above any human pace so a legitimate member never meets |
| +// them. Ported from home/main.go's per-IP limiter, keyed by user id here. |
| + |
| +import ( |
| + "sync" |
| + "time" |
| +) |
| + |
| +// modLimiter is a per-key token bucket. Buckets refill continuously and idle |
| +// ones are swept when the map grows, so memory stays bounded. |
| +type modLimiter struct { |
| + mu sync.Mutex |
| + buckets map[string]*modBucket |
| + burst float64 |
| + perSec float64 |
| +} |
| + |
| +type modBucket struct { |
| + tokens float64 |
| + last time.Time |
| +} |
| + |
| +func newModLimiter(burst, perSec float64) *modLimiter { |
| + return &modLimiter{buckets: map[string]*modBucket{}, burst: burst, perSec: perSec} |
| +} |
| + |
| +func (rl *modLimiter) allow(key string) bool { |
| + rl.mu.Lock() |
| + defer rl.mu.Unlock() |
| + now := time.Now() |
| + if len(rl.buckets) > 10000 { |
| + for k, b := range rl.buckets { |
| + if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { |
| + delete(rl.buckets, k) |
| + } |
| + } |
| + } |
| + b, ok := rl.buckets[key] |
| + if !ok { |
| + b = &modBucket{tokens: rl.burst, last: now} |
| + rl.buckets[key] = b |
| + } |
| + b.tokens += now.Sub(b.last).Seconds() * rl.perSec |
| + if b.tokens > rl.burst { |
| + b.tokens = rl.burst |
| + } |
| + b.last = now |
| + if b.tokens < 1 { |
| + return false |
| + } |
| + b.tokens-- |
| + return true |
| +} |
| + |
| +// The suite's brakes. Ceilings, not budgets: bursts absorb honest spikes |
| +// (pasting a few lines, inviting a family), the refill rates are what a flood |
| +// runs into. |
| +type modBrakes struct { |
| + // messages: burst 30, ~30/min sustained — chatty humans never notice; |
| + // a script spraying a group does. |
| + messages *modLimiter |
| + // new DMs opened: burst 8, ~10/hour — the mass-DM spam pattern. |
| + dms *modLimiter |
| + // fresh members get a tighter DM bucket for their first day (probation): |
| + // burst 3, ~3/hour. The classic spam-join opens DMs immediately. |
| + dmsNew *modLimiter |
| + // member invites minted: burst 10, ~20/day. |
| + invites *modLimiter |
| +} |
| + |
| +func newModBrakes() *modBrakes { |
| + return &modBrakes{ |
| + messages: newModLimiter(30, 0.5), |
| + dms: newModLimiter(8, 10.0/3600), |
| + dmsNew: newModLimiter(3, 3.0/3600), |
| + invites: newModLimiter(10, 20.0/86400), |
| + } |
| +} |
| + |
| +// probationWindow is how long a fresh member gets the tighter DM bucket. |
| +const probationWindow = 24 * time.Hour |
| + |
| +// allowDM picks the probation bucket for members younger than a day. |
| +func (s *Server) allowDM(uid string, createdAt int64) bool { |
| + if time.Since(time.Unix(createdAt, 0)) < probationWindow { |
| + return s.brake().dmsNew.allow(uid) |
| + } |
| + return s.brake().dms.allow(uid) |
| +} |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index adb2028..5d493ad 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -928,8 +928,10 @@ export const app = defineApp({ |
| const repeat = |
| (rep.reported_count > 1 ? ` · named in ${rep.reported_count} reports` : "") + |
| (rep.reporter_count > 1 ? ` · reporter has filed ${rep.reporter_count}` : ""); |
| + // Invite hygiene (issue #578): how the reported member got in. |
| + const lineage = rep.invited_by_name ? ` · invited by ${rep.invited_by_name}` : ""; |
| byline.textContent = |
| - `Reported by ${rep.reporter_name}` + (rep.thread_name ? ` in ${rep.thread_name}` : "") + repeat; |
| + `Reported by ${rep.reporter_name}` + (rep.thread_name ? ` in ${rep.thread_name}` : "") + repeat + lineage; |
| box.appendChild(byline); |
| if (rep.content) { |
| @@ -1074,6 +1076,19 @@ export const app = defineApp({ |
| if (!(await bridge.removeFromSpace(rep.reported_id))) return false; |
| return bridge.resolveReport(rep.id, "removed_space"); |
| }); |
| + // The top rung: removal that stays removed — their key can't come |
| + // back through any invite (a fresh identity still could; pausing |
| + // member invites is the companion move during a spam wave). |
| + actBtn("Remove & ban", true, async () => { |
| + if ( |
| + !confirm( |
| + `Ban ${rep.reported_name}? They're removed from the space and can't rejoin with any invite. This can't be undone.`, |
| + ) |
| + ) |
| + return "keep"; |
| + if (!(await bridge.banFromSpace(rep.reported_id))) return false; |
| + return bridge.resolveReport(rep.id, "banned"); |
| + }); |
| } |
| box.appendChild(acts); |
| box.appendChild(detail); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 66c932b..31d07c7 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -5446,6 +5446,7 @@ setSetupBridge({ |
| rep.reported_name = rosterLabel(rep.reported_id) || "A former member"; |
| rep.resolved_name = rep.resolved_by ? rosterLabel(rep.resolved_by) || "an admin" : ""; |
| rep.thread_name = rep.thread_id ? threadName[rep.thread_id] || "" : ""; |
| + rep.invited_by_name = rep.invited_by ? rosterLabel(rep.invited_by) || "a former member" : ""; |
| rep.reported_gone = !roster.some((x) => x.user_id === rep.reported_id); |
| if (rep.blob && session.userPriv) { |
| try { |
| @@ -5512,6 +5513,18 @@ setSetupBridge({ |
| return false; |
| } |
| }, |
| + banFromSpace: async (uid) => { |
| + try { |
| + const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/ban`, { |
| + method: "POST", |
| + headers: adminAuth(), |
| + }); |
| + if (r.ok) await loadRoster(); |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| removeFromSpace: async (uid) => { |
| try { |
| const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/remove`, { |