From 7d21e83536c38d6e386e44523ce283b3733f917e Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Fri, 23 Oct 2026 12:51:44 -0400
Subject: [PATCH 1/2] feat: record terms acceptance from the app, and meet
Apple's rules for user-generated content
The app asks people to accept the terms and privacy policy before it sends them to sign in. PUT /api/v1/me/terms records the version it showed, POST /api/v1/devices takes the same version so sign-in records it in one step, and GET /api/v1/me says which version is in force, when they accepted and whether it was that one, so a new version asks again.
The terms and privacy policy now say what App Review looks for: zero tolerance for objectionable content, in-app reporting, muting and blocking, action on reports within 24 hours, bans that cover accounts signed in with Apple, Apple's standard clauses for apps with their own terms, and what Sign in with Apple and the iOS app collect.
Co-Authored-By: Claude Sonnet 5.5
---
app/Actions/UpdateUserTermsAcceptance.php | 21 ++++++++
app/Http/Controllers/Api/DeviceController.php | 7 ++-
.../Controllers/Api/UserTermsController.php | 27 ++++++++++
app/Http/Requests/CreateApiDeviceRequest.php | 5 +-
.../Requests/UpdateApiUserTermsRequest.php | 30 +++++++++++
app/Http/Resources/CurrentUserResource.php | 5 +-
app/Models/User.php | 15 ++++++
config/legal.php | 19 +++++++
...00_add_terms_acceptance_to_users_table.php | 22 ++++++++
resources/js/lib/legal.ts | 4 +-
resources/js/pages/privacy/show.tsx | 24 +++++++--
resources/js/pages/terms/show.tsx | 38 +++++++++++---
routes/api.php | 2 +
.../Api/CurrentUserControllerTest.php | 3 ++
.../Controllers/Api/DeviceControllerTest.php | 20 +++++++
.../Api/UserTermsControllerTest.php | 52 +++++++++++++++++++
16 files changed, 280 insertions(+), 14 deletions(-)
create mode 100644 app/Actions/UpdateUserTermsAcceptance.php
create mode 100644 app/Http/Controllers/Api/UserTermsController.php
create mode 100644 app/Http/Requests/UpdateApiUserTermsRequest.php
create mode 100644 config/legal.php
create mode 100644 database/migrations/2026_10_23_120000_add_terms_acceptance_to_users_table.php
create mode 100644 tests/Feature/Controllers/Api/UserTermsControllerTest.php
diff --git a/app/Actions/UpdateUserTermsAcceptance.php b/app/Actions/UpdateUserTermsAcceptance.php
new file mode 100644
index 000000000..d28f23782
--- /dev/null
+++ b/app/Actions/UpdateUserTermsAcceptance.php
@@ -0,0 +1,21 @@
+update([
+ 'terms_accepted_at' => now(),
+ 'terms_version' => config('legal.terms_version'),
+ ]);
+ }
+}
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 34c43b24b..270b82e6b 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->username}" : 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 085bb8f76..29d6ee1f4 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -58,6 +58,8 @@
* @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 string|null $terms_version
* @property-read CarbonInterface|null $commit_rules_accepted_at
* @property-read CarbonInterface $created_at
* @property-read CarbonInterface $updated_at
@@ -105,6 +107,8 @@
'last_seen_at',
'banned_at',
'commit_rules_accepted_at',
+ 'terms_accepted_at',
+ 'terms_version',
])]
final class User extends Authenticatable
{
@@ -145,6 +149,8 @@ final class User extends Authenticatable
'last_seen_at' => null,
'banned_at' => null,
'commit_rules_accepted_at' => null,
+ 'terms_accepted_at' => null,
+ 'terms_version' => null,
];
/**
@@ -196,6 +202,7 @@ public function casts(): array
'last_seen_at' => 'date',
'banned_at' => 'datetime',
'commit_rules_accepted_at' => 'datetime',
+ 'terms_accepted_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
@@ -365,6 +372,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 !== null && $this->terms_version === config('legal.terms_version');
+ }
+
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..98c08298a
--- /dev/null
+++ b/config/legal.php
@@ -0,0 +1,19 @@
+ '2026-10-23',
+
+];
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..665dae4d6
--- /dev/null
+++ b/database/migrations/2026_10_23_120000_add_terms_acceptance_to_users_table.php
@@ -0,0 +1,22 @@
+timestamp('terms_accepted_at')->nullable();
+ $table->string('terms_version', 10)->nullable();
+ });
+ }
+};
diff --git a/resources/js/lib/legal.ts b/resources/js/lib/legal.ts
index d7bca1214..905ee5346 100644
--- a/resources/js/lib/legal.ts
+++ b/resources/js/lib/legal.ts
@@ -1,6 +1,6 @@
-export const PRIVACY_EFFECTIVE_DATE = '20 October 2026';
+export const PRIVACY_EFFECTIVE_DATE = '23 October 2026';
-export const TERMS_EFFECTIVE_DATE = '20 October 2026';
+export const TERMS_EFFECTIVE_DATE = '23 October 2026';
export const COMPANY_NAME = 'Nuno Maduro, Unipessoal Lda';
diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx
index 93f09b6ae..32fc89e45 100644
--- a/resources/js/pages/privacy/show.tsx
+++ b/resources/js/pages/privacy/show.tsx
@@ -51,6 +51,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:
@@ -92,6 +101,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
@@ -104,8 +121,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.
@@ -211,7 +228,8 @@ export default function Show() {
You can export your data or delete your account from{' '}
- settings. Under
+ 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
diff --git a/resources/js/pages/terms/show.tsx b/resources/js/pages/terms/show.tsx
index c5a04b546..c92bc2ea3 100644
--- a/resources/js/pages/terms/show.tsx
+++ b/resources/js/pages/terms/show.tsx
@@ -52,7 +52,7 @@ 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
+ 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.
@@ -128,9 +128,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.
@@ -147,9 +153,10 @@ 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,
+ 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 GitHub account they apply to,
+ timeouts and bans, and the account they apply to,
after your account is deleted.
+ 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.
+
You can export your data or delete your account from{' '}
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
+ 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 c92bc2ea3..69e1f21f6 100644
--- a/resources/js/pages/terms/show.tsx
+++ b/resources/js/pages/terms/show.tsx
@@ -52,10 +52,10 @@ export default function Show() {
You must be at least 16 years old, sign in with your own
- 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.
+ 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,12 +152,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
- 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.
+ 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.
@@ -182,8 +181,8 @@ 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
+ 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
diff --git a/tests/Feature/Controllers/Api/UserTermsControllerTest.php b/tests/Feature/Controllers/Api/UserTermsControllerTest.php
index 8119fec33..b86578908 100644
--- a/tests/Feature/Controllers/Api/UserTermsControllerTest.php
+++ b/tests/Feature/Controllers/Api/UserTermsControllerTest.php
@@ -15,7 +15,7 @@
->assertJsonPath('data.terms_accepted_at', null)
->assertJsonPath('data.terms_accepted', false);
- $user->update(['terms_accepted_at' => now(), 'terms_version' => '2020-01-01']);
+ $user->update(['terms_accepted_at' => '2026-10-22 23:59:59']);
signedApiRequest($keyPair, 'GET', route('api-current-user.show'))->assertJsonPath('data.terms_accepted', false);
});
@@ -33,8 +33,7 @@
->assertJsonPath('data.terms_accepted_at', '2026-10-23T12:00:00Z')
->assertExactJson(signedApiRequest($keyPair, 'GET', route('api-current-user.show'))->json());
- expect($user->refresh()->hasAcceptedCurrentTerms())->toBeTrue()
- ->and($user->terms_version)->toBe(config('legal.terms_version'));
+ 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 {