···11+# Moderation Improvements: Rules as Config, Labels, Permission Middleware
22+33+Exploration of three Osprey-inspired ideas adapted for Arabica's single-binary
44+Go architecture. No new infrastructure required — these build on the existing
55+SQLite + JSON config system.
66+77+## 1. Rules as Config
88+99+### Problem
1010+1111+Automod thresholds are hardcoded constants in `internal/handlers/report.go`:
1212+1313+```go
1414+const (
1515+ AutoHideThreshold = 3 // reports on single record
1616+ AutoHideUserThreshold = 5 // total reports on user's content
1717+ ReportRateLimitPerHour = 10
1818+ MaxReportReasonLength = 500
1919+)
2020+```
2121+2222+Changing these requires a code change and redeploy. There's also no way to
2323+express more nuanced rules like "auto-hide from new users at a lower threshold"
2424+or "auto-blacklist after N auto-hides."
2525+2626+### Proposal
2727+2828+Add an `automod` section to the existing moderators JSON config:
2929+3030+```json
3131+{
3232+ "roles": { ... },
3333+ "users": [ ... ],
3434+ "automod": {
3535+ "rules": [
3636+ {
3737+ "name": "high_report_uri",
3838+ "description": "Auto-hide records with 3+ reports",
3939+ "trigger": "report_created",
4040+ "conditions": {
4141+ "reports_on_uri": { "gte": 3 }
4242+ },
4343+ "action": "hide_record"
4444+ },
4545+ {
4646+ "name": "high_report_user",
4747+ "description": "Auto-hide when user has 5+ reported records",
4848+ "trigger": "report_created",
4949+ "conditions": {
5050+ "reports_on_user": { "gte": 5 }
5151+ },
5252+ "action": "hide_record"
5353+ },
5454+ {
5555+ "name": "repeat_offender",
5656+ "description": "Blacklist users auto-hidden 3+ times",
5757+ "trigger": "record_auto_hidden",
5858+ "conditions": {
5959+ "auto_hides_on_user": { "gte": 3 }
6060+ },
6161+ "action": "blacklist_user"
6262+ }
6363+ ],
6464+ "rate_limit_per_hour": 10,
6565+ "max_reason_length": 500
6666+ }
6767+}
6868+```
6969+7070+### Condition Types
7171+7272+Each condition maps to an existing store query or a simple new one:
7373+7474+| Condition | Store Method | Exists? |
7575+|-----------|-------------|---------|
7676+| `reports_on_uri` | `CountReportsForURI()` | Yes |
7777+| `reports_on_user` | `CountReportsForDID()` / `CountReportsForDIDSince()` | Yes |
7878+| `auto_hides_on_user` | Count hidden records where `subject_did = ?` | New query |
7979+| `has_label` | Label lookup (see section 2) | New |
8080+| `account_age_days` | Would need PDS profile data | Deferred |
8181+8282+### Trigger Types
8383+8484+| Trigger | When Evaluated |
8585+|---------|---------------|
8686+| `report_created` | After `CreateReport()` succeeds (replaces current `checkAutomod`) |
8787+| `record_auto_hidden` | After an auto-hide action completes (enables chained rules) |
8888+8989+### Action Types
9090+9191+| Action | Implementation |
9292+|--------|---------------|
9393+| `hide_record` | Existing `HideRecord()` with `AutoHidden: true` |
9494+| `blacklist_user` | Existing `BlacklistUser()` with `BlacklistedBy: "automod"` |
9595+| `add_label` | New — adds a label to user/record (see section 2) |
9696+| `remove_label` | New — removes a label |
9797+9898+### Go Types
9999+100100+```go
101101+// In internal/moderation/models.go
102102+103103+type AutomodConfig struct {
104104+ Rules []AutomodRule `json:"rules"`
105105+ RateLimitPerHour int `json:"rate_limit_per_hour"`
106106+ MaxReasonLength int `json:"max_reason_length"`
107107+}
108108+109109+type AutomodRule struct {
110110+ Name string `json:"name"`
111111+ Description string `json:"description"`
112112+ Trigger AutomodTrigger `json:"trigger"`
113113+ Conditions []AutomodCondition `json:"conditions"`
114114+ Action AutomodAction `json:"action"`
115115+}
116116+117117+type AutomodTrigger string
118118+119119+const (
120120+ TriggerReportCreated AutomodTrigger = "report_created"
121121+ TriggerRecordAutoHidden AutomodTrigger = "record_auto_hidden"
122122+)
123123+124124+type AutomodCondition struct {
125125+ Type string `json:"type"` // "reports_on_uri", "reports_on_user", etc.
126126+ Operator string `json:"operator"` // "gte", "eq", "lte"
127127+ Threshold int `json:"threshold"`
128128+ Label string `json:"label,omitempty"` // For has_label condition
129129+}
130130+131131+type AutomodAction struct {
132132+ Type string `json:"type"` // "hide_record", "blacklist_user", "add_label"
133133+ Label string `json:"label,omitempty"` // For add_label/remove_label actions
134134+}
135135+```
136136+137137+### Evaluation Engine
138138+139139+Replace `checkAutomod()` with a rule evaluator:
140140+141141+```go
142142+// In internal/moderation/automod.go
143143+144144+type RuleEvaluator struct {
145145+ rules []AutomodRule
146146+ store Store
147147+}
148148+149149+func (e *RuleEvaluator) Evaluate(ctx context.Context, trigger AutomodTrigger, event AutomodEvent) []AutomodResult {
150150+ var results []AutomodResult
151151+ for _, rule := range e.rules {
152152+ if rule.Trigger != trigger {
153153+ continue
154154+ }
155155+ if e.allConditionsMet(ctx, rule.Conditions, event) {
156156+ results = append(results, AutomodResult{
157157+ Rule: rule,
158158+ Action: rule.Action,
159159+ })
160160+ }
161161+ }
162162+ return results
163163+}
164164+```
165165+166166+The handler calls the evaluator instead of checking hardcoded thresholds.
167167+Results chain — if a `hide_record` action fires, it triggers
168168+`record_auto_hidden` rules in the same pass.
169169+170170+### Migration Path
171171+172172+1. Add `AutomodConfig` to `Config` struct with defaults matching current
173173+ constants
174174+2. Move `checkAutomod()` logic into `RuleEvaluator`
175175+3. If no `automod` section in config, use built-in defaults (backward
176176+ compatible)
177177+4. Delete the hardcoded constants
178178+179179+---
180180+181181+## 2. Labels
182182+183183+### Problem
184184+185185+Automod is stateless — it only counts reports at decision time. There's no way
186186+to say "this user was warned" or "this account is new and should have stricter
187187+thresholds." Moderators also can't tag users with notes that affect future
188188+automod behavior.
189189+190190+### Proposal
191191+192192+Add a lightweight label system for entities (users and records). Labels are
193193+key-value tags with optional TTL, stored in SQLite alongside the existing
194194+moderation tables.
195195+196196+### Schema
197197+198198+```sql
199199+CREATE TABLE moderation_labels (
200200+ id TEXT PRIMARY KEY, -- TID
201201+ entity_type TEXT NOT NULL, -- 'user' or 'record'
202202+ entity_id TEXT NOT NULL, -- DID or AT-URI
203203+ label TEXT NOT NULL, -- e.g. 'warned', 'trusted', 'new_account'
204204+ value TEXT DEFAULT '', -- optional value
205205+ created_at TEXT NOT NULL, -- RFC3339Nano
206206+ created_by TEXT NOT NULL, -- DID or 'automod' or 'system'
207207+ expires_at TEXT, -- RFC3339Nano, NULL = permanent
208208+ UNIQUE(entity_type, entity_id, label)
209209+);
210210+211211+CREATE INDEX idx_labels_entity ON moderation_labels(entity_type, entity_id);
212212+CREATE INDEX idx_labels_expires ON moderation_labels(expires_at) WHERE expires_at IS NOT NULL;
213213+```
214214+215215+### Store Interface Additions
216216+217217+```go
218218+// Added to moderation.Store interface
219219+220220+// Labels
221221+AddLabel(ctx context.Context, label Label) error
222222+RemoveLabel(ctx context.Context, entityType, entityID, labelName string) error
223223+HasLabel(ctx context.Context, entityType, entityID, labelName string) (bool, error)
224224+GetLabel(ctx context.Context, entityType, entityID, labelName string) (*Label, error)
225225+ListLabels(ctx context.Context, entityType, entityID string) ([]Label, error)
226226+CleanExpiredLabels(ctx context.Context) (int, error) // Periodic cleanup
227227+```
228228+229229+### Label Model
230230+231231+```go
232232+type Label struct {
233233+ ID string `json:"id"`
234234+ EntityType string `json:"entity_type"` // "user" or "record"
235235+ EntityID string `json:"entity_id"` // DID or AT-URI
236236+ Name string `json:"label"`
237237+ Value string `json:"value,omitempty"`
238238+ CreatedAt time.Time `json:"created_at"`
239239+ CreatedBy string `json:"created_by"`
240240+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
241241+}
242242+243243+func (l *Label) IsExpired() bool {
244244+ return l.ExpiresAt != nil && time.Now().After(*l.ExpiresAt)
245245+}
246246+```
247247+248248+### Predefined Labels
249249+250250+These would be documented but not hardcoded — any string works as a label name:
251251+252252+| Label | Entity | Meaning | Typical TTL |
253253+|-------|--------|---------|-------------|
254254+| `warned` | user | Moderator issued a warning | 30 days |
255255+| `trusted` | user | Exempt from automod | permanent |
256256+| `under_review` | user | Flagged for manual review | 7 days |
257257+| `rate_limited` | user | Temporarily restricted | 24 hours |
258258+| `spam` | record | Identified as spam | permanent |
259259+260260+### Integration with Automod Rules
261261+262262+Labels become a condition type in automod rules:
263263+264264+```json
265265+{
266266+ "name": "strict_threshold_warned_users",
267267+ "trigger": "report_created",
268268+ "conditions": {
269269+ "reports_on_uri": { "gte": 2 },
270270+ "has_label": { "entity": "subject_user", "label": "warned" }
271271+ },
272272+ "action": "hide_record"
273273+}
274274+```
275275+276276+And an action type:
277277+278278+```json
279279+{
280280+ "name": "warn_on_first_autohide",
281281+ "trigger": "record_auto_hidden",
282282+ "conditions": {
283283+ "auto_hides_on_user": { "gte": 1 },
284284+ "not_has_label": { "entity": "subject_user", "label": "warned" }
285285+ },
286286+ "action": { "type": "add_label", "label": "warned", "expires": "30d" }
287287+}
288288+```
289289+290290+### UI Integration
291291+292292+- Admin dashboard shows labels on users/records
293293+- Moderators with `manage_labels` permission can add/remove labels manually
294294+- Label badges appear on reported content for context
295295+296296+### Cleanup
297297+298298+A goroutine runs `CleanExpiredLabels()` periodically (e.g., every hour). This
299299+is a single DELETE query:
300300+301301+```sql
302302+DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ?
303303+```
304304+305305+---
306306+307307+## 3. Permission Middleware
308308+309309+### Problem
310310+311311+Every moderation handler repeats the same auth + permission check boilerplate:
312312+313313+```go
314314+userDID, err := atproto.GetAuthenticatedDID(r.Context())
315315+if err != nil || userDID == "" {
316316+ http.Error(w, "Authentication required", http.StatusUnauthorized)
317317+ return
318318+}
319319+if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionXXX) {
320320+ log.Warn().Str("did", userDID).Msg("Denied: insufficient permissions")
321321+ http.Error(w, "Permission denied", http.StatusForbidden)
322322+ return
323323+}
324324+```
325325+326326+This is ~8 lines repeated in 8 handlers. Changes to auth behavior require
327327+touching every handler.
328328+329329+### Proposal
330330+331331+A `RequirePermission` middleware that wraps handlers with permission checks:
332332+333333+```go
334334+// In internal/middleware/moderation.go
335335+336336+func RequirePermission(
337337+ modService *moderation.Service,
338338+ perm moderation.Permission,
339339+ next http.Handler,
340340+) http.Handler {
341341+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342342+ userDID := atproto.GetAuthenticatedDIDFromContext(r.Context())
343343+ if userDID == "" {
344344+ http.Error(w, "Authentication required", http.StatusUnauthorized)
345345+ return
346346+ }
347347+348348+ if modService == nil || !modService.HasPermission(userDID, perm) {
349349+ log.Warn().
350350+ Str("did", userDID).
351351+ Str("permission", string(perm)).
352352+ Str("path", r.URL.Path).
353353+ Msg("permission denied")
354354+ http.Error(w, "Permission denied", http.StatusForbidden)
355355+ return
356356+ }
357357+358358+ next.ServeHTTP(w, r)
359359+ })
360360+}
361361+362362+func RequireModerator(
363363+ modService *moderation.Service,
364364+ next http.Handler,
365365+) http.Handler {
366366+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
367367+ userDID := atproto.GetAuthenticatedDIDFromContext(r.Context())
368368+ if userDID == "" {
369369+ http.Error(w, "Authentication required", http.StatusUnauthorized)
370370+ return
371371+ }
372372+373373+ if modService == nil || !modService.IsModerator(userDID) {
374374+ http.Error(w, "Access denied", http.StatusForbidden)
375375+ return
376376+ }
377377+378378+ next.ServeHTTP(w, r)
379379+ })
380380+}
381381+```
382382+383383+### Routing Changes
384384+385385+Before:
386386+```go
387387+mux.Handle("POST /_mod/hide", cop.Handler(http.HandlerFunc(h.HandleHideRecord)))
388388+mux.Handle("POST /_mod/unhide", cop.Handler(http.HandlerFunc(h.HandleUnhideRecord)))
389389+mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser)))
390390+// ... each handler checks permissions internally
391391+```
392392+393393+After:
394394+```go
395395+modPerm := middleware.RequirePermission // alias for readability
396396+397397+mux.Handle("POST /_mod/hide",
398398+ cop.Handler(modPerm(modSvc, moderation.PermissionHideRecord,
399399+ http.HandlerFunc(h.HandleHideRecord))))
400400+401401+mux.Handle("POST /_mod/unhide",
402402+ cop.Handler(modPerm(modSvc, moderation.PermissionUnhideRecord,
403403+ http.HandlerFunc(h.HandleUnhideRecord))))
404404+405405+mux.Handle("POST /_mod/block",
406406+ cop.Handler(modPerm(modSvc, moderation.PermissionBlacklistUser,
407407+ http.HandlerFunc(h.HandleBlockUser))))
408408+```
409409+410410+### What Handlers Lose
411411+412412+Each handler drops the first ~8 lines of boilerplate. They still need the
413413+authenticated DID for audit logging, but can get it from context (it's already
414414+validated by the middleware):
415415+416416+```go
417417+func (h *Handler) HandleHideRecord(w http.ResponseWriter, r *http.Request) {
418418+ // DID is guaranteed valid by middleware
419419+ userDID := atproto.GetAuthenticatedDIDFromContext(r.Context())
420420+421421+ // Straight to business logic
422422+ if err := r.ParseForm(); err != nil {
423423+ http.Error(w, "Invalid request body", http.StatusBadRequest)
424424+ return
425425+ }
426426+ // ...
427427+}
428428+```
429429+430430+### Edge Case: Admin Dashboard
431431+432432+`HandleAdmin` uses `IsModerator()` (not a specific permission) and then
433433+conditionally loads data based on individual permissions. This stays as-is — the
434434+middleware handles the gate, the handler still calls `HasPermission()` for
435435+conditional data loading:
436436+437437+```go
438438+mux.HandleFunc("GET /_mod",
439439+ middleware.RequireModerator(modSvc, http.HandlerFunc(h.HandleAdmin)))
440440+441441+// Inside HandleAdmin, permission checks remain for conditional data:
442442+canHide := h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord)
443443+```
444444+445445+---
446446+447447+## Implementation Order
448448+449449+These three features have natural dependencies:
450450+451451+```
452452+Phase 1: Permission Middleware
453453+ └─ Standalone refactor, no new features
454454+ └─ Reduces boilerplate, makes Phase 2/3 cleaner
455455+ └─ ~1 new file, ~8 handler edits, routing changes
456456+457457+Phase 2: Labels
458458+ └─ New table, store methods, model types
459459+ └─ Admin UI for viewing/managing labels
460460+ └─ ~3 new files, store interface additions
461461+462462+Phase 3: Rules as Config
463463+ └─ Depends on Labels for `has_label` condition
464464+ └─ Config schema additions, rule evaluator
465465+ └─ Replace checkAutomod() with evaluator
466466+ └─ ~2 new files, config changes, handler changes
467467+```
468468+469469+Each phase is independently useful and shippable. Phase 1 is pure cleanup.
470470+Phase 2 gives moderators new capabilities. Phase 3 makes automod flexible.
471471+472472+## What This Doesn't Do
473473+474474+- **No new infrastructure** — everything stays in SQLite + JSON config
475475+- **No DSL** — rules are JSON, not a custom language
476476+- **No real-time streaming** — evaluation happens synchronously in handlers
477477+- **No investigation UI** — the existing admin dashboard gets labels, not a
478478+ query engine
479479+- **No ML/AI integration** — rules are deterministic threshold checks
480480+481481+These are deliberate constraints. If the moderation needs outgrow this, that's
482482+the point where Osprey (or a similar system) becomes worth the infrastructure
483483+cost.
+214
docs/osprey-evaluation.md
···11+# Osprey Rules Engine Evaluation
22+33+Evaluation of [Osprey](https://github.com/roostorg/osprey) (by ROOST /
44+internet.dev) as a potential replacement or complement to Arabica's moderation
55+system.
66+77+## What Is Osprey?
88+99+Osprey is a **real-time safety rules engine** for processing event streams and
1010+making automated decisions about user behavior. Originally built at Discord,
1111+open-sourced through ROOST (Robust Open Online Safety Tools). Tagline: "Automate
1212+the obvious and investigate the ambiguous."
1313+1414+Adopted by **Bluesky, Discord, and Matrix.org**. Apache 2.0 licensed, reached
1515+v1.0.1 (March 2026), actively maintained.
1616+1717+### Core Concepts
1818+1919+**SML (Some Madeup Language)** — A Python-subset DSL for writing rules:
2020+2121+```python
2222+# Models: extract features from event JSON
2323+UserId: Entity[str] = EntityJson(type='User', path='$.user.userId')
2424+PostText: str = JsonData(path='$.text')
2525+2626+# Rules: boolean conditions
2727+SpamLinkRule = Rule(
2828+ when_all=[
2929+ PostCount == 1,
3030+ EmbedLink != None,
3131+ ListLength(list=MentionIds) >= 1,
3232+ ],
3333+ description='First post with link embed',
3434+)
3535+3636+# Effects: actions when rules match
3737+WhenRules(
3838+ rules_any=[SpamLinkRule],
3939+ then=[
4040+ DeclareVerdict(verdict='reject'),
4141+ LabelAdd(entity=UserId, label='likely_spammer'),
4242+ ],
4343+)
4444+```
4545+4646+**Labels** — Stateful tags on entities (users, IPs, etc.) that persist across
4747+evaluations. Support expiry (`expires_after=TimeDelta(days=7)`). Enable stateful
4848+rules like "if this user was flagged as a spammer last week, auto-reject."
4949+5050+**Entities** — Special features (UserID, IP, etc.) that can carry labels and
5151+have effects applied to them.
5252+5353+**File Organization** — Rules compose across files via `Import()` and
5454+conditional `Require(require_if=EventType == 'userPost')`.
5555+5656+### Architecture
5757+5858+Osprey is a **multi-service system**, not an embeddable library:
5959+6060+| Component | Language | Purpose |
6161+|-----------|----------|---------|
6262+| Worker | Python | Core rules engine, consumes Kafka events |
6363+| Coordinator | Rust | Distributed deployment coordination (optional) |
6464+| UI | TypeScript/React | Investigation dashboard, querying, labeling |
6565+| UI API | Python/Flask | Backend for the UI, queries Druid |
6666+| RPC | gRPC/Protobuf | Inter-service communication |
6767+6868+**Infrastructure requirements:**
6969+- Kafka (KRaft mode) — event I/O
7070+- PostgreSQL — labels, execution results
7171+- Apache Druid — OLAP for UI queries
7272+- MinIO — object storage for Druid
7373+- Google Bigtable (optional) — labels at scale
7474+7575+**Data flow:**
7676+1. Events arrive on Kafka input topic
7777+2. Worker evaluates SML rules against events
7878+3. Rules produce verdicts and effects (hide, label, reject)
7979+4. Results dispatched to output sinks (Kafka, Labels, stdout)
8080+5. Execution results flow to Druid for UI querying
8181+8282+### Plugin System
8383+8484+Python `pluggy`-based. Plugins can register:
8585+- **UDFs** — Custom functions for SML rules
8686+- **Output Sinks** — Custom result destinations
8787+- **AST Validators** — Custom rule validation
8888+8989+## Current Arabica Moderation System
9090+9191+For comparison, here's what Arabica has today (~2,100 lines across 3 layers):
9292+9393+### Capabilities
9494+9595+| Feature | Implementation |
9696+|---------|---------------|
9797+| Role-based access | JSON config with admin/moderator roles, 8 granular permissions |
9898+| Record hiding | Manual + automod (3 reports → auto-hide) |
9999+| User blacklisting | Manual ban/unban by moderators |
100100+| Reports | User submission with rate limiting (10/hr), duplicate detection |
101101+| Automod | Threshold-based: 3 reports/URI or 5 reports/user → auto-hide |
102102+| Audit log | All actions logged including automod flag |
103103+| Admin dashboard | HTMX-powered with stats, reports, hidden records, blacklist |
104104+| Feed filtering | Batch-loads hidden URIs for efficient filtering |
105105+106106+### Code Organization
107107+108108+```
109109+internal/moderation/
110110+ models.go # Roles, permissions, data types
111111+ service.go # Thread-safe permission checks
112112+ store.go # 16-method store interface
113113+internal/database/sqlitestore/
114114+ moderation.go # SQLite implementation
115115+internal/handlers/
116116+ admin.go # Dashboard + mod actions (662 lines)
117117+ report.go # Report submission + automod (260 lines)
118118+```
119119+120120+### What Works Well
121121+122122+- Optional design — gracefully degrades without config
123123+- Thread-safe service with RWMutex
124124+- Comprehensive audit trail
125125+- CSRF protection on all mutations
126126+- Efficient batch feed filtering
127127+- Flexible role/permission model
128128+129129+### Pain Points
130130+131131+- Automod thresholds are hardcoded constants
132132+- Permission checks are manual boilerplate in every handler
133133+- Report enrichment makes unbatched PDS calls
134134+- Config changes require server restart
135135+- No rule composition or conditional logic beyond fixed thresholds
136136+137137+## Fit Assessment
138138+139139+### Where Osprey Shines vs Arabica's Needs
140140+141141+**Osprey's strengths:**
142142+- Sophisticated rule composition (AND/OR, conditional loading, labels)
143143+- Stateful rules across evaluations (labels with TTL)
144144+- Investigation UI for T&S teams
145145+- Built for high-throughput event streams
146146+- Plugin system for custom detection logic
147147+148148+**What Arabica could use:**
149149+- More flexible automod rules (not just hardcoded thresholds)
150150+- Rule composition (e.g., "new user + link + mention → suspicious")
151151+- Stateful tracking (e.g., "user had 3 reports dismissed this month")
152152+- Easier rule iteration without code deploys
153153+154154+### Why It Doesn't Fit Today
155155+156156+| Concern | Detail |
157157+|---------|--------|
158158+| **Massive infrastructure overhead** | Kafka + Postgres + Druid + MinIO is orders of magnitude more infra than Arabica's SQLite-based stack. Arabica runs as a single Go binary. |
159159+| **No Go SDK** | Osprey is Python-native. No Go client library exists. Integration would require Kafka as middleware or raw gRPC proto compilation. |
160160+| **Scale mismatch** | Osprey was built for Discord-scale (millions of events/sec). Arabica is a small community app. The operational complexity is not justified. |
161161+| **Python dependency** | Arabica is a pure Go project. Adding a Python rules engine (plus its infra) contradicts the project's preference for stdlib solutions and minimal dependencies. |
162162+| **Overlapping concerns** | Osprey would replace ~2,100 lines of straightforward Go code with a multi-service deployment. The current system is well-structured and maintainable. |
163163+164164+### What Could Be Borrowed (Ideas, Not Code)
165165+166166+Even though deploying Osprey doesn't make sense, some of its concepts are worth
167167+adopting in Arabica's existing moderation code:
168168+169169+1. **Configurable thresholds** — Move automod constants (3 reports/URI, 5
170170+ reports/user) into the moderators JSON config so they're tunable without
171171+ deploys.
172172+173173+2. **Labels / stateful tags** — Add a lightweight label system for users (e.g.,
174174+ `new_account`, `warned`, `trusted`). Labels could influence automod behavior:
175175+ a `warned` user might have a lower auto-hide threshold.
176176+177177+3. **Rule composition** — Express automod rules as config rather than code:
178178+ ```json
179179+ {
180180+ "automod_rules": [
181181+ {
182182+ "name": "high_report_volume",
183183+ "conditions": {"reports_on_uri": {"gte": 3}},
184184+ "action": "hide_record"
185185+ },
186186+ {
187187+ "name": "repeated_offender",
188188+ "conditions": {"reports_on_user": {"gte": 5}, "user_label": "warned"},
189189+ "action": "blacklist_user"
190190+ }
191191+ ]
192192+ }
193193+ ```
194194+195195+4. **Permission middleware** — Replace per-handler permission boilerplate with
196196+ middleware that checks permissions based on route patterns.
197197+198198+5. **TTL-based labels** — Osprey's label expiry is useful for temporary states
199199+ like "under review" or "rate-limited for 24h."
200200+201201+## Recommendation
202202+203203+**Don't integrate Osprey.** The infrastructure and language mismatch is too
204204+large, and Arabica's moderation needs are well-served by its current
205205+~2,100-line Go implementation.
206206+207207+**Do consider** extracting the best ideas (configurable thresholds, labels,
208208+rule composition as config) into the existing system. This would address the
209209+current pain points (hardcoded thresholds, no stateful tracking) without the
210210+operational burden of a multi-service Python deployment.
211211+212212+If Arabica ever grows to need a dedicated T&S team with investigation tooling,
213213+Osprey becomes worth revisiting — but that's a fundamentally different scale
214214+than today.
+7
internal/atproto/oauth.go
···189189 contextKeySessionData contextKey = "sessionData"
190190)
191191192192+// ContextWithAuthDID returns a context with the given DID set as the
193193+// authenticated user. This is useful for testing middleware and handlers that
194194+// call GetAuthenticatedDID.
195195+func ContextWithAuthDID(ctx context.Context, did string) context.Context {
196196+ return context.WithValue(ctx, contextKeyUserDID, did)
197197+}
198198+192199// GetAuthenticatedDID retrieves the authenticated user's DID from the request context
193200func GetAuthenticatedDID(ctx context.Context) (string, error) {
194201 did, ok := ctx.Value(contextKeyUserDID).(string)
+15-104
internal/handlers/admin.go
···2626}
27272828// HandleHideRecord handles POST /admin/hide
2929+// Auth and permission checks are handled by RequirePermission middleware.
2930func (h *Handler) HandleHideRecord(w http.ResponseWriter, r *http.Request) {
3030- // Check authentication
3131- userDID, err := atproto.GetAuthenticatedDID(r.Context())
3232- if err != nil || userDID == "" {
3333- http.Error(w, "Authentication required", http.StatusUnauthorized)
3434- return
3535- }
3636-3737- // Check permission
3838- if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) {
3939- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/hide").Msg("Denied: insufficient permissions")
4040- http.Error(w, "Permission denied", http.StatusForbidden)
4141- return
4242- }
3131+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
43324444- // Parse form data only (JSON is rejected to prevent CSRF bypass)
4533 if err := r.ParseForm(); err != nil {
4634 http.Error(w, "Invalid request body", http.StatusBadRequest)
4735 return
···9583}
96849785// HandleUnhideRecord handles POST /admin/unhide
8686+// Auth and permission checks are handled by RequirePermission middleware.
9887func (h *Handler) HandleUnhideRecord(w http.ResponseWriter, r *http.Request) {
9999- // Check authentication
100100- userDID, err := atproto.GetAuthenticatedDID(r.Context())
101101- if err != nil || userDID == "" {
102102- http.Error(w, "Authentication required", http.StatusUnauthorized)
103103- return
104104- }
105105-106106- // Check permission
107107- if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) {
108108- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unhide").Msg("Denied: insufficient permissions")
109109- http.Error(w, "Permission denied", http.StatusForbidden)
110110- return
111111- }
8888+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
11289113113- // Parse form data only (JSON is rejected to prevent CSRF bypass)
11490 if err := r.ParseForm(); err != nil {
11591 http.Error(w, "Invalid request body", http.StatusBadRequest)
11692 return
···257233}
258234259235// HandleAdminPartial renders just the admin dashboard content (for HTMX refresh)
236236+// Auth and moderator checks are handled by RequireModerator middleware.
260237func (h *Handler) HandleAdminPartial(w http.ResponseWriter, r *http.Request) {
261261- userDID, err := atproto.GetAuthenticatedDID(r.Context())
262262- if err != nil || userDID == "" {
263263- http.Error(w, "Authentication required", http.StatusUnauthorized)
264264- return
265265- }
266266-267267- if h.moderationService == nil || !h.moderationService.IsModerator(userDID) {
268268- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/content").Msg("Denied: not a moderator")
269269- http.Error(w, "Access denied", http.StatusForbidden)
270270- return
271271- }
272272-238238+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
273239 adminProps := h.buildAdminProps(r.Context(), userDID)
274240275241 if err := pages.AdminDashboardBody(adminProps).Render(r.Context(), w); err != nil {
···366332}
367333368334// HandleBlockUser handles POST /_mod/block
335335+// Auth and permission checks are handled by RequirePermission middleware.
369336func (h *Handler) HandleBlockUser(w http.ResponseWriter, r *http.Request) {
370370- // Check authentication
371371- userDID, err := atproto.GetAuthenticatedDID(r.Context())
372372- if err != nil || userDID == "" {
373373- http.Error(w, "Authentication required", http.StatusUnauthorized)
374374- return
375375- }
337337+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
376338377377- // Check permission
378378- if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) {
379379- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/block").Msg("Denied: insufficient permissions")
380380- http.Error(w, "Permission denied", http.StatusForbidden)
381381- return
382382- }
383383-384384- // Parse form data only (JSON is rejected to prevent CSRF bypass)
385339 if err := r.ParseForm(); err != nil {
386340 http.Error(w, "Invalid request body", http.StatusBadRequest)
387341 return
···433387}
434388435389// HandleUnblockUser handles POST /_mod/unblock
390390+// Auth and permission checks are handled by RequirePermission middleware.
436391func (h *Handler) HandleUnblockUser(w http.ResponseWriter, r *http.Request) {
437437- // Check authentication
438438- userDID, err := atproto.GetAuthenticatedDID(r.Context())
439439- if err != nil || userDID == "" {
440440- http.Error(w, "Authentication required", http.StatusUnauthorized)
441441- return
442442- }
443443-444444- // Check permission
445445- if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) {
446446- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unblock").Msg("Denied: insufficient permissions")
447447- http.Error(w, "Permission denied", http.StatusForbidden)
448448- return
449449- }
392392+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
450393451451- // Parse form data only (JSON is rejected to prevent CSRF bypass)
452394 if err := r.ParseForm(); err != nil {
453395 http.Error(w, "Invalid request body", http.StatusBadRequest)
454396 return
···492434493435// HandleResetAutoHide handles POST /_mod/reset-autohide
494436// Resets the per-user auto-hide report counter so that only future reports count toward the threshold.
437437+// Auth and permission checks are handled by RequirePermission middleware.
495438func (h *Handler) HandleResetAutoHide(w http.ResponseWriter, r *http.Request) {
496496- userDID, err := atproto.GetAuthenticatedDID(r.Context())
497497- if err != nil || userDID == "" {
498498- http.Error(w, "Authentication required", http.StatusUnauthorized)
499499- return
500500- }
501501-502502- if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) {
503503- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/reset-autohide").Msg("Denied: insufficient permissions")
504504- http.Error(w, "Permission denied", http.StatusForbidden)
505505- return
506506- }
439439+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
507440508441 if err := r.ParseForm(); err != nil {
509442 http.Error(w, "Invalid request body", http.StatusBadRequest)
···545478}
546479547480// HandleDismissReport handles POST /_mod/dismiss-report
481481+// Auth and permission checks are handled by RequirePermission middleware.
548482func (h *Handler) HandleDismissReport(w http.ResponseWriter, r *http.Request) {
549549- // Check authentication
550550- userDID, err := atproto.GetAuthenticatedDID(r.Context())
551551- if err != nil || userDID == "" {
552552- http.Error(w, "Authentication required", http.StatusUnauthorized)
553553- return
554554- }
555555-556556- // Check permission
557557- if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) {
558558- log.Warn().Str("did", userDID).Str("endpoint", "/_mod/dismiss-report").Msg("Denied: insufficient permissions")
559559- http.Error(w, "Permission denied", http.StatusForbidden)
560560- return
561561- }
483483+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
562484563563- // Parse request
564485 if err := r.ParseForm(); err != nil {
565486 http.Error(w, "Invalid request body", http.StatusBadRequest)
566487 return
···641562}
642563643564// HandleAdminStats renders the stats partial for HTMX refresh.
565565+// Auth and admin checks are handled by RequireAdmin middleware.
644566func (h *Handler) HandleAdminStats(w http.ResponseWriter, r *http.Request) {
645645- userDID, err := atproto.GetAuthenticatedDID(r.Context())
646646- if err != nil || userDID == "" {
647647- http.Error(w, "Authentication required", http.StatusUnauthorized)
648648- return
649649- }
650650-651651- if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) {
652652- http.Error(w, "Access denied", http.StatusForbidden)
653653- return
654654- }
655655-656567 stats := h.collectAdminStats(r.Context())
657568658569 if err := pages.AdminStatsContent(stats).Render(r.Context(), w); err != nil {
+4-18
internal/handlers/join.go
···9797}
98989999// HandleCreateInvite creates a PDS invite code and emails it to the requester.
100100+// Auth and admin checks are handled by RequireAdmin middleware.
100101func (h *Handler) HandleCreateInvite(w http.ResponseWriter, r *http.Request) {
101101- userDID, err := atproto.GetAuthenticatedDID(r.Context())
102102- if err != nil || userDID == "" {
103103- http.Error(w, "Authentication required", http.StatusUnauthorized)
104104- return
105105- }
106106- if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) {
107107- http.Error(w, "Access denied", http.StatusForbidden)
108108- return
109109- }
102102+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
110103111104 if err := r.ParseForm(); err != nil {
112105 http.Error(w, "Invalid request", http.StatusBadRequest)
···207200}
208201209202// HandleDismissJoinRequest removes a join request without sending an invite.
203203+// Auth and admin checks are handled by RequireAdmin middleware.
210204func (h *Handler) HandleDismissJoinRequest(w http.ResponseWriter, r *http.Request) {
211211- userDID, err := atproto.GetAuthenticatedDID(r.Context())
212212- if err != nil || userDID == "" {
213213- http.Error(w, "Authentication required", http.StatusUnauthorized)
214214- return
215215- }
216216- if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) {
217217- http.Error(w, "Access denied", http.StatusForbidden)
218218- return
219219- }
205205+ userDID, _ := atproto.GetAuthenticatedDID(r.Context())
220206221207 if err := r.ParseForm(); err != nil {
222208 http.Error(w, "Invalid request", http.StatusBadRequest)
+76
internal/middleware/moderation.go
···11+package middleware
22+33+import (
44+ "net/http"
55+66+ "arabica/internal/atproto"
77+ "arabica/internal/moderation"
88+99+ "github.com/rs/zerolog/log"
1010+)
1111+1212+// RequirePermission returns middleware that checks the authenticated user has
1313+// the given permission. Returns 401 if unauthenticated, 403 if not permitted.
1414+func RequirePermission(svc *moderation.Service, perm moderation.Permission, next http.Handler) http.Handler {
1515+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1616+ userDID, err := atproto.GetAuthenticatedDID(r.Context())
1717+ if err != nil || userDID == "" {
1818+ http.Error(w, "Authentication required", http.StatusUnauthorized)
1919+ return
2020+ }
2121+2222+ if svc == nil || !svc.HasPermission(userDID, perm) {
2323+ log.Warn().
2424+ Str("did", userDID).
2525+ Str("permission", string(perm)).
2626+ Str("path", r.URL.Path).
2727+ Msg("Denied: insufficient permissions")
2828+ http.Error(w, "Permission denied", http.StatusForbidden)
2929+ return
3030+ }
3131+3232+ next.ServeHTTP(w, r)
3333+ })
3434+}
3535+3636+// RequireModerator returns middleware that checks the authenticated user is a
3737+// moderator (any role). Returns 401 if unauthenticated, 403 if not a moderator.
3838+func RequireModerator(svc *moderation.Service, next http.Handler) http.Handler {
3939+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4040+ userDID, err := atproto.GetAuthenticatedDID(r.Context())
4141+ if err != nil || userDID == "" {
4242+ http.Error(w, "Authentication required", http.StatusUnauthorized)
4343+ return
4444+ }
4545+4646+ if svc == nil || !svc.IsModerator(userDID) {
4747+ log.Warn().
4848+ Str("did", userDID).
4949+ Str("path", r.URL.Path).
5050+ Msg("Denied: not a moderator")
5151+ http.Error(w, "Access denied", http.StatusForbidden)
5252+ return
5353+ }
5454+5555+ next.ServeHTTP(w, r)
5656+ })
5757+}
5858+5959+// RequireAdmin returns middleware that checks the authenticated user is an admin.
6060+// Returns 401 if unauthenticated, 403 if not an admin.
6161+func RequireAdmin(svc *moderation.Service, next http.Handler) http.Handler {
6262+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6363+ userDID, err := atproto.GetAuthenticatedDID(r.Context())
6464+ if err != nil || userDID == "" {
6565+ http.Error(w, "Authentication required", http.StatusUnauthorized)
6666+ return
6767+ }
6868+6969+ if svc == nil || !svc.IsAdmin(userDID) {
7070+ http.Error(w, "Access denied", http.StatusForbidden)
7171+ return
7272+ }
7373+7474+ next.ServeHTTP(w, r)
7575+ })
7676+}