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> */ @@ -60,6 +62,7 @@ public function rules(): array return [ 'code' => ['required', 'string', 'max:255'], 'code_verifier' => ['required', 'string', 'regex:/^[A-Za-z0-9._~-]{43,128}$/'], + 'terms_version' => ['sometimes', 'string', Rule::in([config('legal.terms_version')])], ]; } diff --git a/app/Http/Requests/UpdateApiUserTermsRequest.php b/app/Http/Requests/UpdateApiUserTermsRequest.php new file mode 100644 index 000000000..d9cdd6dd7 --- /dev/null +++ b/app/Http/Requests/UpdateApiUserTermsRequest.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return [ + 'version' => ['required', 'string', Rule::in([config('legal.terms_version')])], + ]; + } +} diff --git a/app/Http/Resources/CurrentUserResource.php b/app/Http/Resources/CurrentUserResource.php index 2446ec764..2c80c9ce5 100644 --- a/app/Http/Resources/CurrentUserResource.php +++ b/app/Http/Resources/CurrentUserResource.php @@ -12,7 +12,7 @@ /** * The signed-in user: their public profile, plus what only they see on the web's profile settings. That is the lane * or custom color they chose (`null` for Automatic) and the one Automatic gives them, their GitHub Sponsors page, and when their profile - * was last mirrored from GitHub. Their Remotes are `null` while turned off, as the web hides them, and otherwise say who + * was last mirrored from GitHub. The version of the terms in force, when they accepted, and whether it was this version, so the app knows to ask. Their Remotes are `null` while turned off, as the web hides them, and otherwise say who * can push to them, whether they share pulls, what the site lets them choose, and the unread count the web's nav shows. * * @mixin User @@ -36,6 +36,9 @@ public function toArray(Request $request): array 'automatic_lane' => $this->automaticLane(), 'sponsors_url' => $this->has_sponsors_listing ? "https://github.com/sponsors/{$this->handle}" : null, 'github_synced_at' => $this->github_synced_at?->toIso8601ZuluString(), + 'terms_version' => config('legal.terms_version'), + 'terms_accepted_at' => $this->terms_accepted_at?->toIso8601ZuluString(), + 'terms_accepted' => $this->hasAcceptedCurrentTerms(), 'remotes' => $this->remotes_enabled && $user instanceof User ? [ 'from' => $this->remotes_from, 'pull_receipts' => $this->pull_receipts, diff --git a/app/Models/User.php b/app/Models/User.php index a1705a27d..708f85646 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -64,6 +64,7 @@ * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface|null $last_seen_at * @property-read CarbonInterface|null $banned_at + * @property-read CarbonInterface|null $terms_accepted_at * @property-read CarbonInterface|null $commit_rules_accepted_at * @property-read CarbonInterface|null $emails_unsubscribed_at * @property-read CarbonInterface $created_at @@ -115,6 +116,7 @@ 'last_seen_at', 'banned_at', 'commit_rules_accepted_at', + 'terms_accepted_at', 'emails_unsubscribed_at', ])] final class User extends Authenticatable @@ -163,6 +165,7 @@ final class User extends Authenticatable 'last_seen_at' => null, 'banned_at' => null, 'commit_rules_accepted_at' => null, + 'terms_accepted_at' => null, 'emails_unsubscribed_at' => null, ]; @@ -209,6 +212,7 @@ public function casts(): array 'last_seen_at' => 'date', 'banned_at' => 'datetime', 'commit_rules_accepted_at' => 'datetime', + 'terms_accepted_at' => 'datetime', 'emails_unsubscribed_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -411,6 +415,14 @@ public function getAuthPassword(): string return ''; } + /** + * Whether they have accepted the terms and privacy policy as they stand now. + */ + public function hasAcceptedCurrentTerms(): bool + { + return $this->terms_accepted_at?->gte(config()->string('legal.terms_version')) ?? false; + } + public function isOnboarded(): bool { return $this->onboarded_at !== null; diff --git a/config/legal.php b/config/legal.php new file mode 100644 index 000000000..d4a1f06d2 --- /dev/null +++ b/config/legal.php @@ -0,0 +1,20 @@ + '2026-10-25T00:00:00Z', + +]; diff --git a/database/migrations/2026_10_23_120000_add_terms_acceptance_to_users_table.php b/database/migrations/2026_10_23_120000_add_terms_acceptance_to_users_table.php new file mode 100644 index 000000000..9b0ebf498 --- /dev/null +++ b/database/migrations/2026_10_23_120000_add_terms_acceptance_to_users_table.php @@ -0,0 +1,21 @@ +timestamp('terms_accepted_at')->nullable(); + }); + } +}; diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index a2c085b65..26c1adeec 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -54,6 +54,15 @@ export default function Show() { listing, your public SSH key fingerprints, and who you follow and who follows you. , + <> + + From Apple, when you sign in with Apple in + the iOS app: + {' '} + your Apple account identifier, and the name and + email address you choose to share, which may be + Apple's private relay address. + , <> From the commit script, when you run it: @@ -115,6 +124,14 @@ export default function Show() { you use the service, such as the last day you used it. , + <> + From the iOS app: a key the app + makes on your phone to sign its requests, which + never leaves it, and the name of your phone. The + app asks for your photos or camera only when you + attach an image, and for your microphone only + when you record a voice note. + , <> Cookies and browser storage{' '} needed for the service to work, such as for @@ -127,8 +144,8 @@ export default function Show() { others give us about you, or that is generated when you use the service. New features may involve other data, which this policy covers once it is updated to describe - it. Signing in with GitHub is required to use the - service. + it. Signing in with GitHub, or with Apple in the iOS + app, is required to use the service.

@@ -234,10 +251,11 @@ export default function Show() {

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 . diff --git a/resources/js/pages/terms/show.tsx b/resources/js/pages/terms/show.tsx index 049bf6a0c..7e6f9d913 100644 --- a/resources/js/pages/terms/show.tsx +++ b/resources/js/pages/terms/show.tsx @@ -55,10 +55,10 @@ export default function Show() {

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.

@@ -152,9 +152,15 @@ export default function Show() { ]} />

- To report content you believe is illegal or infringes - your rights, write to . We decide how to handle - reports. + There is no place on the service for objectionable + content or abusive users, and we have zero tolerance for + them. Report a commit with Report in its menu, and use + Mute and Block on a profile to stop seeing someone. To + report content you believe is illegal or infringes your + rights, or anything else, write to . We review + reports and act on content that breaks these rules + within 24 hours, by removing it and restricting or + banning the account that posted it.

@@ -170,11 +176,11 @@ export default function Show() {

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(); +});