From 768137fcceee400763d5498aaf06bb0d9b0c4b4f Mon Sep 17 00:00:00 2001 From: Adam Hosker Date: Tue, 20 Oct 2026 10:58:57 +0000 Subject: [PATCH 1/2] Require daemon-level public XP for moderation bypass --- KNOWLEDGE_BASE.md | 2 +- app/Models/User.php | 7 +++-- tests/Unit/Actions/CreateCommitTest.php | 41 +++++++++++++++++++++++-- tests/Unit/Jobs/ModerateCommitTest.php | 26 ++++++++++++---- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 0dc9b3b56..17fd2d302 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -465,7 +465,7 @@ Sources: [account deletion confirmation](resources/js/components/delete-user.tsx ### Moderation -Commits and replies may be pending, approved, or rejected. A commit is approved at once when its author is an admin, or has a good reputation: an account older than an hour, at least one approved commit, at least 10 followers, and no undismissed report filed against any of their commits in the last seven days (deleted and rejected commits included). Other commits start pending. When OpenAI is configured, automated moderation checks the commits of authors without a good reputation: clean commits are approved, near-certain violations are rejected without a reason, and anything else waits for a moderator. While an author has an undismissed recent report, none of their commits is approved automatically: authors with a good reputation skip the automated check, and a clean verdict for anyone else still waits for a moderator. Pending and rejected content is not publicly visible; its author can still see its status. On a pending commit, a badge tells the author whether it is still being checked automatically (usually a few seconds, polled for about half a minute) or waiting for a moderator; when the check approves it while the author is looking, the badge shows a check mark. Approval makes content eligible for public lists and releases reply, quote, and mention notifications, but does not notify the author. Rejection notifies the author, with the moderator's reason when one is given. Moderation applies to browser, scheduled, terminal, and app submissions alike. +Commits and replies may be pending, approved, or rejected. A commit is approved at once when its author is an admin, or has a good reputation: an account older than an hour, at least one approved commit, at least 10 followers, at least **daemon rank (50 XP)** based on publicly visible activity, and no undismissed report filed against any of their commits in the last seven days (deleted and rejected commits included). Pending, rejected, and deleted commits do not contribute qualifying XP, even when the author's own profile shows a higher rank. Other commits start pending. When OpenAI is configured, automated moderation checks the commits of authors without a good reputation: clean commits are approved, near-certain violations are rejected without a reason, and anything else waits for a moderator. While an author has an undismissed recent report, none of their commits is approved automatically: authors with a good reputation skip the automated check, and a clean verdict for anyone else still waits for a moderator. Pending and rejected content is not publicly visible; its author can still see its status. On a pending commit, a badge tells the author whether it is still being checked automatically (usually a few seconds, polled for about half a minute) or waiting for a moderator; when the check approves it while the author is looking, the badge shows a check mark. Approval makes content eligible for public lists and releases reply, quote, and mention notifications, but does not notify the author. Rejection notifies the author, with the moderator's reason when one is given. Moderation applies to browser, scheduled, terminal, and app submissions alike. Moderators are the site administrators, set by GitHub username in the `ADMINS` environment variable. They have a moderation queue at `/moderation` for reviewing pending commits oldest first, with the automated verdict and a translation: approve or reject each (with an optional reason up to 280 characters), or approve up to 100 at once. A reports queue at `/moderation/reports` lists user-reported content. Moderators can dismiss reports or reject reported commits. These are restricted tools, not controls every account receives. diff --git a/app/Models/User.php b/app/Models/User.php index 20040cec6..93084c67f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,8 @@ use App\Enums\LivePlatform; use App\Enums\Log; use App\Enums\RemotesFrom; +use App\Enums\UnixRank; +use App\Queries\UserAchievementsQuery; use Carbon\CarbonInterface; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -207,13 +209,14 @@ public function hasRecentReports(): bool } /** - * Ten followers are enough, so the check stops at the tenth instead of counting every follower. + * Requires daemon-level public XP. Stop at the tenth follower instead of counting every follower. */ public function hasGoodReputation(): bool { return $this->created_at->lt(now()->subHour()) && $this->commits()->approved()->exists() - && $this->followers()->skip(9)->exists(); + && $this->followers()->skip(9)->exists() + && resolve(UserAchievementsQuery::class)->get($this, null)['xp'] >= UnixRank::Daemon->minXp(); } /** diff --git a/tests/Unit/Actions/CreateCommitTest.php b/tests/Unit/Actions/CreateCommitTest.php index b708c731a..364c6aa13 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -15,6 +15,7 @@ use App\Models\LinkPreview; use App\Models\Notification; use App\Models\User; +use App\Queries\UserAchievementsQuery; use App\Services\TheAlgorithm; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; @@ -68,10 +69,10 @@ followersOf($user, $followers); if ($previousStatus instanceof CommitStatus) { - $previous = Commit::factory()->for($user)->create(['status' => $previousStatus]); + $previous = Commit::factory()->for($user)->count(4)->create(['status' => $previousStatus]); if ($deleted) { - $previous->delete(); + $previous->each->delete(); } } @@ -99,6 +100,41 @@ 'deleted approved history' => [3601, 10, CommitStatus::Approved, true, false], ]); +it('requires at least daemon public xp to skip ai review', function (int $commits, int $starsGiven, int $starsReceived, int $xp, bool $approved): void { + config(['services.openai.key' => 'sk-test']); + Queue::fake(); + + $user = User::factory()->create(['created_at' => now()->subHours(2)]); + followersOf($user, 10); + $history = Commit::factory()->for($user)->count($commits)->create(); + $user->starredCommits()->attach(Commit::factory()->count($starsGiven)->create()->modelKeys()); + foreach (User::factory()->count($starsReceived)->create() as $stargazer) { + $stargazer->starredCommits()->attach($history->first()); + } + + // Private activity must not boost moderation eligibility, even when the queued job rechecks it. + Commit::factory()->for($user)->pending()->count(5)->create(); + Commit::factory()->for($user)->rejected()->count(5)->create(); + $deleted = Commit::factory()->for($user)->create(); + $deleted->delete(); + + expect(resolve(UserAchievementsQuery::class)->get($user, null)['xp'])->toBe($xp); + + $commit = resolve(CreateCommit::class)->handle($user, 'A new commit'); + + expect($commit->status)->toBe($approved ? CommitStatus::Approved : CommitStatus::Pending); + + if ($approved) { + Queue::assertNotPushed(ModerateCommit::class); + } else { + Queue::assertPushed(ModerateCommit::class); + } +})->with([ + 'below daemon' => [3, 2, 0, 49, false], + 'exactly daemon' => [3, 0, 1, 50, true], + 'higher rank' => [14, 0, 0, 155, true], +]); + it('notifies the parent author about replies of admins', function (): void { config(['app.admins' => ['nunomaduro']]); @@ -479,6 +515,7 @@ $reported = Commit::factory()->for($user)->create(); if ($reputable) { followersOf($user, 10); + Commit::factory()->for($user)->count(3)->create(); } CommitReport::factory()->for($reported)->create(['dismissed_at' => $dismissed ? now() : null]); diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index 211caaf23..2793f79b2 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -377,11 +377,20 @@ function reviewResponses(array $responses = []): Closure expect($commit->refresh()->ai_decision)->toBeNull(); }); -it('approves queued commits without calling the ai when the author has good reputation', function (): void { - Http::fake(); +it('only skips ai review for queued commits with daemon public xp', function (bool $daemon): void { + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); $user = User::factory()->create(['created_at' => now()->subHour()->subSecond()]); - Commit::factory()->for($user)->create(); + $history = Commit::factory()->for($user)->count(3)->create(); + if ($daemon) { + User::factory()->create()->starredCommits()->attach($history->first()); + } else { + $user->starredCommits()->attach(Commit::factory()->count(2)->create()->modelKeys()); + } + Commit::factory()->for($user)->rejected()->count(5)->create(); $commit = Commit::factory()->for($user)->pending()->create(); followersOf($user, 10); @@ -389,10 +398,14 @@ function reviewResponses(array $responses = []): Closure expect($commit->refresh()) ->status->toBe(CommitStatus::Approved) - ->ai_decision->toBeNull(); + ->ai_decision->toBe($daemon ? null : CommitStatus::Approved); - Http::assertNothingSent(); -}); + if ($daemon) { + Http::assertNothingSent(); + } else { + Http::assertSentCount(2); + } +})->with([false, true]); it('skips commits that were deleted in the meantime', function (): void { Http::fake(); @@ -489,6 +502,7 @@ function reviewResponses(array $responses = []): Closure $user = User::factory()->create(['created_at' => now()->subHours(2)]); $reported = Commit::factory()->for($user)->create(); followersOf($user, 10); + Commit::factory()->for($user)->count(3)->create(); $commit = Commit::factory()->for($user)->pending()->create(); CommitReport::factory()->for($reported)->create(); From b1eefdbab44296c840202370c5f694ed07d45903 Mon Sep 17 00:00:00 2001 From: Adam Hosker Date: Tue, 20 Oct 2026 11:27:19 +0000 Subject: [PATCH 2/2] style: satisfy moderation test spacing rule --- tests/Unit/Jobs/ModerateCommitTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index 2793f79b2..001f52f81 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -390,6 +390,7 @@ function reviewResponses(array $responses = []): Closure } else { $user->starredCommits()->attach(Commit::factory()->count(2)->create()->modelKeys()); } + Commit::factory()->for($user)->rejected()->count(5)->create(); $commit = Commit::factory()->for($user)->pending()->create(); followersOf($user, 10);