From ee631f3380c20ff14ca4cd58b89b18899008d540 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:04:22 +0100 Subject: [PATCH 1/3] feat: start polls from the API A new commit or a quote from the app can carry a poll, with the web's rules for its options and duration. A reply can't, as on the web. --- PRODUCT.md | 2 +- README.md | 2 + app/Http/Controllers/Api/CommitController.php | 6 +- app/Http/Requests/CreateApiCommitRequest.php | 28 ++++++- .../Controllers/Api/CommitControllerTest.php | 75 +++++++++++++++++++ 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index 43b6a160b..ca57bbe06 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,7 +23,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Sign-in is GitHub OAuth only. Profile changes happen on GitHub ("Edit on GitHub"), then sync at next login or with "Sync from GitHub" (once a minute). - Everything except the landing page, the public read API, and profile feeds requires login. - Public read API: read-only JSON under `/api/v1`, no authentication, for public profiles (with counts), a user's commits or replies, and single commits. It returns exactly what a guest's link preview may show: approved, non-deleted commits only (with their images), approved reply counts, and a parent only while it is approved. Never emails or other hidden profile fields. 60 requests a minute per IP. -- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, notifications, saved commits, mutes and blocks, and can commit, reply, quote, delete, follow, star, save, mute, block switch pastéis mode and give pastéis, and report commits, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. +- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, notifications, saved commits, mutes and blocks, and can commit, reply, quote, start polls, delete, follow, star, save, mute, block switch pastéis mode and give pastéis, and report commits, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. - Profile feeds: every profile has an Atom feed of its latest 50 approved top-level commits at `/@{username}/feed.atom`, their images as enclosures, advertised with `` on the profile's guest preview. - First sign-in leads to a short setup at `/onboarding`: confirm the mirrored profile, pick a color, follow a few suggested developers, learn the composer, `?` shortcuts and terminal commits, then land on the log (or the page the user was headed to). Skippable at any step (Skip setup or Esc); finishing or skipping ends it for good. Accounts that existed before it never see it. - Profile links (`/@{username}`) and links to approved commits (`/@{username}/commits/{id}`) unfurl with a link preview (the author and their bio or counts, or the commit body and reply count) for guests and crawlers; a guest opening one sees the landing page, and the profile or commit itself stays behind login. Pending and rejected commits are a 404 for guests. diff --git a/README.md b/README.md index fcc948e5b..ed47aeaff 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `POST /api/v1/commits` takes a `body`, and either a `parent_id` to reply to a commit or a `quoted_commit_id` to quote one, but not both. Returns a `201` on successful commit creation. +A new commit or a quote can also start a poll, as on the web, but a reply can't: `poll` takes 2 to 4 `options` of up to 50 characters, no two the same, and a `duration` in minutes, one of `60`, `1440`, `4320` or `10080`. The poll opens when the commit is approved, so a pending commit's poll has no `closes_at` yet. + `POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. `GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 305287228..4603501e8 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -40,12 +40,12 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren } /** - * Answers with the new commit as its page shows it, with the commit it quotes, pending review unless the user is an - * admin. + * Answers with the new commit as its page shows it, with the commit it quotes and its poll, pending review unless + * the user is an admin. */ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), poll: $request->poll()); return CommitResource::make($page->get($commit, $user)) ->response() diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php index 4715e3889..c7e59c462 100644 --- a/app/Http/Requests/CreateApiCommitRequest.php +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -4,15 +4,20 @@ namespace App\Http\Requests; +use App\Dto\NewPoll; +use App\Enums\PollDuration; use App\Models\Commit; use App\Models\User; use App\Rules\VisibleCommit; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rules\Dimensions; +use Illuminate\Validation\Rules\Enum; +use Illuminate\Validation\Rules\File; /** - * A commit from the mobile app, sent as JSON. It may reply to a commit or quote one, as on the web, but not both. It has - * no images or poll yet. + * A commit from the mobile app, sent as JSON. It may reply to a commit or quote one, as on the web, but not both. Like a + * commit on the web, it may come with a poll, but not as a reply. It has no images yet. */ final class CreateApiCommitRequest extends FormRequest { @@ -30,17 +35,22 @@ public function prepareForValidation(): void } /** - * @return array> + * @return array> */ public function rules(): array { $user = $this->user(); assert($user instanceof User); + $attachmentRules = CreateCommitRequest::attachmentRules(); return [ 'body' => CreateCommitRequest::BODY_RULES, 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], 'quoted_commit_id' => ['bail', 'nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, __('That commit is no longer available to quote.'))], + 'poll' => $attachmentRules['poll'], + 'poll.options' => $attachmentRules['poll.options'], + 'poll.options.*' => $attachmentRules['poll.options.*'], + 'poll.duration' => $attachmentRules['poll.duration'], ]; } @@ -66,4 +76,16 @@ public function quotedCommit(): ?Commit ? Commit::query()->visibleTo($user)->find($this->string('quoted_commit_id')->value()) : null; } + + public function poll(): ?NewPoll + { + if (! $this->filled('poll')) { + return null; + } + + /** @var list $options */ + $options = $this->array('poll.options'); + + return new NewPoll($options, PollDuration::from($this->integer('poll.duration'))); + } } diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index d7fccf84d..1855db5d9 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\CommitStatus; +use App\Enums\PollDuration; use App\Http\Requests\CreateApiCommitRequest; use App\Jobs\ModerateCommit; use App\Models\Bookmark; @@ -660,6 +661,80 @@ expect($quoted->quotes()->count())->toBe(0); })->with(['blocking' => true, 'blocked' => false]); +it('commits a poll from a signed request, which waits for the commit to be approved', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Tabs or spaces?', + 'poll' => ['options' => ['Tabs', ' Spaces ', 'Both :tada:'], 'duration' => PollDuration::SevenDays->value], + ]); + + $poll = $user->commits()->sole()->poll; + + $response->assertCreated() + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.poll.id', $poll->id) + ->assertJsonPath('data.poll.closes_at', null) + ->assertJsonPath('data.poll.options.*.label', ['Tabs', 'Spaces', 'Both 🎉']); + + expect($poll->duration)->toBe(PollDuration::SevenDays) + ->and($poll->opened_at)->toBeNull(); +}); + +it('opens the poll of an admin right away', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + config(['app.admins' => [$user->username]]); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Tabs or spaces?', + 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => PollDuration::OneHour->value], + ]) + ->assertCreated() + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.poll.closes_at', now()->startOfSecond()->addHour()->toJSON()) + ->assertJsonPath('data.poll.is_closed', false); +}); + +it('quotes a commit with a poll from a signed request', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $quoted = Commit::factory()->create(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Which is it?', + 'quoted_commit_id' => $quoted->id, + 'poll' => ['options' => ['DNS', 'Not DNS'], 'duration' => PollDuration::OneDay->value], + ]) + ->assertCreated() + ->assertJsonPath('data.quoted_commit_id', $quoted->id) + ->assertJsonPath('data.poll.options.*.label', ['DNS', 'Not DNS']); +}); + +it('validates the poll of a commit from a signed request as the web does', function (Closure $data, string $field, string $message): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'Tabs or spaces?', ...$data()]) + ->assertUnprocessable() + ->assertJsonValidationErrors([$field => $message]); + + expect($user->commits()->exists())->toBeFalse() + ->and(Poll::query()->exists())->toBeFalse(); +})->with([ + 'one option' => [fn (): array => ['poll' => ['options' => ['Tabs'], 'duration' => 1440]], 'poll.options', 'The poll.options field must have at least 2 items.'], + 'five options' => [fn (): array => ['poll' => ['options' => ['A', 'B', 'C', 'D', 'E'], 'duration' => 1440]], 'poll.options', 'The poll.options field must not have more than 4 items.'], + 'empty option' => [fn (): array => ['poll' => ['options' => ['Tabs', ' '], 'duration' => 1440]], 'poll.options.1', 'The poll.options.1 field is required.'], + 'long option' => [fn (): array => ['poll' => ['options' => ['Tabs', str_repeat('a', 51)], 'duration' => 1440]], 'poll.options.1', 'The poll.options.1 field must not be greater than 50 characters.'], + 'duplicate options' => [fn (): array => ['poll' => ['options' => ['Tabs', 'tabs'], 'duration' => 1440]], 'poll.options.1', 'The poll.options.1 field has a duplicate value.'], + 'unknown duration' => [fn (): array => ['poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 30]], 'poll.duration', 'The selected poll.duration is invalid.'], + 'no duration' => [fn (): array => ['poll' => ['options' => ['Tabs', 'Spaces']]], 'poll.duration', 'The poll.duration field is required when poll is present.'], + 'no options' => [fn (): array => ['poll' => ['duration' => 1440]], 'poll.options', 'The poll.options field is required when poll is present.'], + 'a reply' => [fn (): array => ['parent_id' => Commit::factory()->create()->id, 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440]], 'poll', 'The poll field prohibits parent id / images from being present.'], +]); + it('sends a commit from a signed request to the ai moderation the web uses', function (): void { config(['services.openai.key' => 'sk-test']); Queue::fake(); From cacba1592ba51c46a54ca4532d925f92461bc718 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Oct 2026 07:32:14 +0200 Subject: [PATCH 2/3] docs: document poll creation in the signed API --- KNOWLEDGE_BASE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d68c799fa..406169ff8 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -439,7 +439,7 @@ A signed request sees what the web shows you, such as your own pending commits. - commit, reply, and quote, with images, and delete your commits; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; - star or unstar, and save or unsave; -- vote on polls; +- create polls on commits and quotes, and vote on polls; - change Remotes settings, read conversations and mark them pulled, react to messages, find people, and start orgs; - stash or reopen conversations and orgs, merge or drop message requests, and send typing indicators; - switch Pastéis mode and give pastéis; @@ -447,7 +447,7 @@ A signed request sees what the web shows you, such as your own pending commits. - sync from GitHub and save your lane color; - delete your account. -Signed request bodies are JSON, or multipart when they carry images, so app commits can have images but no polls or link-card choice. Sending messages in Remotes remains web-only, as do pinning, scheduling, search, and translations. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. +Signed request bodies are JSON, or multipart when they carry images, so app commits can have images or a poll but no link-card choice. Sending messages in Remotes remains web-only, as do pinning, scheduling, search, and translations. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. Sources: [signed requests](app/Services/SignedRequests.php), [sign-in page](app/Http/Controllers/MobileAuthorizationController.php), [devices settings](resources/js/pages/device/index.tsx), [API routes](routes/api.php). From 1a999b289beb683e02049833ca380aa234f50aea Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Oct 2026 07:42:50 +0200 Subject: [PATCH 3/3] fix: reject polls on API quotes --- KNOWLEDGE_BASE.md | 2 +- app/Http/Requests/CreateApiCommitRequest.php | 2 +- tests/Feature/Controllers/Api/CommitControllerTest.php | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 406169ff8..163ffe25d 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -439,7 +439,7 @@ A signed request sees what the web shows you, such as your own pending commits. - commit, reply, and quote, with images, and delete your commits; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; - star or unstar, and save or unsave; -- create polls on commits and quotes, and vote on polls; +- create polls on commits that are neither replies nor quotes, and vote on polls; - change Remotes settings, read conversations and mark them pulled, react to messages, find people, and start orgs; - stash or reopen conversations and orgs, merge or drop message requests, and send typing indicators; - switch Pastéis mode and give pastéis; diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php index c47495db0..c60dc0deb 100644 --- a/app/Http/Requests/CreateApiCommitRequest.php +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -48,7 +48,7 @@ public function rules(): array 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], 'quoted_commit_id' => ['bail', 'nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, __('That commit is no longer available to quote.'))], - 'poll' => $attachmentRules['poll'], + 'poll' => [...$attachmentRules['poll'], 'prohibits:quoted_commit_id'], 'poll.options' => $attachmentRules['poll.options'], 'poll.options.*' => $attachmentRules['poll.options.*'], 'poll.duration' => $attachmentRules['poll.duration'], diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index c1d11a727..48cf1f4be 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -696,7 +696,7 @@ ->assertJsonPath('data.poll.is_closed', false); }); -it('quotes a commit with a poll from a signed request', function (): void { +it('rejects a poll on a quote from a signed request', function (): void { $user = User::factory()->create(); $keyPair = apiKeyPairFor($user); $quoted = Commit::factory()->create(); @@ -706,9 +706,11 @@ 'quoted_commit_id' => $quoted->id, 'poll' => ['options' => ['DNS', 'Not DNS'], 'duration' => PollDuration::OneDay->value], ]) - ->assertCreated() - ->assertJsonPath('data.quoted_commit_id', $quoted->id) - ->assertJsonPath('data.poll.options.*.label', ['DNS', 'Not DNS']); + ->assertUnprocessable() + ->assertJsonValidationErrors(['poll' => 'The poll field prohibits quoted commit id from being present.']); + + expect($user->commits()->exists())->toBeFalse() + ->and(Poll::query()->exists())->toBeFalse(); }); it('validates the poll of a commit from a signed request as the web does', function (Closure $data, string $field, string $message): void {