eleven / ocho Public

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>
Paul Campbell c19f8e3869c171ab0f1d08f9864be276c14294be parent 399cf87
8 files changed, +416 −3
  • ban_test.go +134 −0
  • changelog/2026-07-12-ban-brakes.md +8 −0
  • db.go +54 −2
  • hub.go +13 −0
  • main.go +84 −0
  • ratelimit.go +94 −0
  • web/static/apps/admin.js +16 −1
  • web/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`, {