diff --git a/app/Actions/UpdateUserTermsAcceptance.php b/app/Actions/UpdateUserTermsAcceptance.php
new file mode 100644
index 000000000..c356d1fa5
--- /dev/null
+++ b/app/Actions/UpdateUserTermsAcceptance.php
@@ -0,0 +1,18 @@
+update(['terms_accepted_at' => now()]);
+ }
+}
diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php
index b3f786582..7df41a813 100644
--- a/app/Http/Controllers/Api/DeviceController.php
+++ b/app/Http/Controllers/Api/DeviceController.php
@@ -5,6 +5,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\CreateDevice;
+use App\Actions\UpdateUserTermsAcceptance;
use App\Exceptions\ApiException;
use App\Http\Requests\CreateApiDeviceRequest;
use App\Http\Resources\CurrentUserResource;
@@ -19,7 +20,7 @@
* Approves the key that signed the request as a device, for the user who approved the app on the web, and answers
* as GET /api/v1/me does. A code redeemed by another request in the meantime gets the same answer as a wrong one.
*/
- public function store(CreateApiDeviceRequest $request, CreateDevice $action, NotificationGroupsQuery $notifications): CurrentUserResource|JsonResponse
+ public function store(CreateApiDeviceRequest $request, CreateDevice $action, UpdateUserTermsAcceptance $acceptTerms, NotificationGroupsQuery $notifications): CurrentUserResource|JsonResponse
{
$device = $action->handle($request->authorizationCode());
@@ -27,6 +28,10 @@ public function store(CreateApiDeviceRequest $request, CreateDevice $action, Not
return ApiException::invalidGrant()->render();
}
+ if ($request->has('terms_version')) {
+ $acceptTerms->handle($device->user);
+ }
+
Auth::setUser($device->user);
return CurrentUserResource::make($device->user->loadCounts())->additional([
diff --git a/app/Http/Controllers/Api/UserTermsController.php b/app/Http/Controllers/Api/UserTermsController.php
new file mode 100644
index 000000000..62cf2d70c
--- /dev/null
+++ b/app/Http/Controllers/Api/UserTermsController.php
@@ -0,0 +1,27 @@
+handle($user);
+
+ return CurrentUserResource::make($user->loadCounts())->additional([
+ 'unread_notifications_count' => $notifications->unreadCount($user),
+ ]);
+ }
+}
diff --git a/app/Http/Requests/CreateApiDeviceRequest.php b/app/Http/Requests/CreateApiDeviceRequest.php
index e100a5541..c11f02263 100644
--- a/app/Http/Requests/CreateApiDeviceRequest.php
+++ b/app/Http/Requests/CreateApiDeviceRequest.php
@@ -12,6 +12,7 @@
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Validation\Rule;
use InvalidArgumentException;
/**
@@ -51,7 +52,8 @@ public function authorize(SignedRequests $signedRequests): bool
}
/**
- * The verifier is the one the app made its PKCE challenge from (RFC 7636).
+ * The verifier is the one the app made its PKCE challenge from (RFC 7636). An app that showed the terms and privacy
+ * policy before sending the person to sign in says which version, and it is recorded as accepted.
*
* @return array
You can export your data or delete your account from{' '}
- settings. Under
- the GDPR, you may also have the right to access,
- correct, erase, restrict, or port your data, to object
- to its processing, and to lodge a complaint with a data
+ settings, or
+ delete it in the iOS app under Settings. Under the GDPR,
+ you may also have the right to access, correct, erase,
+ restrict, or port your data, to object to its
+ processing, and to lodge a complaint with a data
protection authority, such as Portugal's CNPD. You may
object at any time to processing based on our legitimate
interests. To exercise these rights, write to
You must be at least 16 years old, sign in with your own
- account, and be allowed to use the service under the
- laws that apply to you, including sanctions and export
- laws. If you use it for an organization, you accept
- these terms on its behalf.
+ GitHub or Apple account, and be allowed to use the
+ service under the laws that apply to you, including
+ sanctions and export laws. If you use it for an
+ organization, you accept these terms on its behalf.
- To report content you believe is illegal or infringes
- your rights, write to
If you break these terms, we may stop you from posting - for a while (a timeout) or ban you. A ban applies to - your GitHub account, so you can't sign up again with it, - even after deleting your account. We keep a record of - timeouts and bans, and the GitHub account they apply to, - after your account is deleted. + for a while (a timeout) or ban you. A ban applies to the + GitHub or Apple account you signed in with, so you can't + sign up again with it, even after deleting your account. + We keep a record of timeouts and bans, and the account + they apply to, after your account is deleted.
@@ -197,6 +203,25 @@ export default function Show() { ++ If you use the iOS app, these terms are between you and + us only, not Apple. Apple is not responsible for the app + or its content, and has no obligation to provide + maintenance or support for it. If the app fails to + conform to a warranty, you may tell Apple, and Apple + will refund the purchase price, if any; Apple has no + other warranty obligation for the app. We, not Apple, + answer any claim about the app, including product + liability, legal or regulatory compliance, and consumer + protection. Apple and its subsidiaries are third-party + beneficiaries of these terms and may enforce them + against you once you accept them. You confirm that you + are not in a country under a U.S. government embargo or + listed as a restricted party. +
+The service is provided "as is" and "as available", diff --git a/routes/api.php b/routes/api.php index e36e4a655..5f408c30a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -56,6 +56,7 @@ use App\Http\Controllers\Api\UserProfileController; use App\Http\Controllers\Api\UserRemotePreferencesController; use App\Http\Controllers\Api\UserRemotesController; +use App\Http\Controllers\Api\UserTermsController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; use App\Http\Middleware\AuthenticateSignedRequest; @@ -118,6 +119,7 @@ // Pastéis... Route::put('me/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('api-user-pasteis-mode.update'); + Route::put('me/terms', [UserTermsController::class, 'update'])->name('api-user-terms.update'); // User Profile: mirrored from GitHub again, as Sync from GitHub does on the web... Route::put('me/github', [UserProfileController::class, 'update']) diff --git a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php index 4c1049f99..2c32a0b5d 100644 --- a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -60,6 +60,9 @@ 'automatic_lane' => $user->automaticLane(), 'sponsors_url' => null, 'github_synced_at' => null, + 'terms_version' => config('legal.terms_version'), + 'terms_accepted_at' => null, + 'terms_accepted' => false, 'remotes' => [ 'from' => 'following', 'pull_receipts' => true, diff --git a/tests/Feature/Controllers/Api/DeviceControllerTest.php b/tests/Feature/Controllers/Api/DeviceControllerTest.php index b7619b3b1..056f28747 100644 --- a/tests/Feature/Controllers/Api/DeviceControllerTest.php +++ b/tests/Feature/Controllers/Api/DeviceControllerTest.php @@ -26,6 +26,26 @@ )); }); +it('records the terms as accepted when the app says which version it showed before sign-in', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => $this->code, 'code_verifier' => $this->verifier, 'terms_version' => config('legal.terms_version')]) + ->assertOk() + ->assertJsonPath('data.terms_accepted', true); + + expect($this->user->refresh()->hasAcceptedCurrentTerms())->toBeTrue(); +}); + +it('leaves the terms alone when the app does not say, and refuses a version that is not in force', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => $this->code, 'code_verifier' => $this->verifier, 'terms_version' => '1999-01-01']) + ->assertUnprocessable() + ->assertJsonValidationErrors('terms_version'); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => $this->code, 'code_verifier' => $this->verifier]) + ->assertOk() + ->assertJsonPath('data.terms_accepted', false); + + expect($this->user->refresh()->terms_accepted_at)->toBeNull(); +}); + it('approves the key that redeems the code as a device, and answers like me', function (): void { $response = signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => $this->code, 'code_verifier' => $this->verifier]); diff --git a/tests/Feature/Controllers/Api/UserTermsControllerTest.php b/tests/Feature/Controllers/Api/UserTermsControllerTest.php new file mode 100644 index 000000000..c0761b341 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserTermsControllerTest.php @@ -0,0 +1,51 @@ +create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.terms_version', config('legal.terms_version')) + ->assertJsonPath('data.terms_accepted_at', null) + ->assertJsonPath('data.terms_accepted', false); + + $user->update(['terms_accepted_at' => '2026-10-24 23:59:59']); + + signedApiRequest($keyPair, 'GET', route('api-current-user.show'))->assertJsonPath('data.terms_accepted', false); +}); + +it('records that the terms were accepted, and answers as the current user endpoint does', function (): void { + Date::setTestNow('2026-10-25 12:00:00'); + + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'PUT', route('api-user-terms.update'), ['version' => config('legal.terms_version')]); + + $response->assertOk() + ->assertJsonPath('data.terms_accepted', true) + ->assertJsonPath('data.terms_accepted_at', '2026-10-25T12:00:00Z') + ->assertExactJson(signedApiRequest($keyPair, 'GET', route('api-current-user.show'))->json()); + + expect($user->refresh()->hasAcceptedCurrentTerms())->toBeTrue(); +}); + +it('only accepts the version in force, so an old build cannot accept terms it never showed', function (mixed $version): void { + $user = User::factory()->create(); + + signedApiRequest(apiKeyPairFor($user), 'PUT', route('api-user-terms.update'), $version === null ? [] : ['version' => $version]) + ->assertUnprocessable() + ->assertJsonValidationErrors('version'); + + expect($user->refresh()->terms_accepted_at)->toBeNull(); +})->with([null, '2020-01-01', '2026-10-24']); + +it('rejects requests that are not signed', function (): void { + $this->putJson(route('api-user-terms.update'), ['version' => config('legal.terms_version')])->assertUnauthorized(); +});