diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php
index 4bd99be..2291742 100644
--- a/app/Devnull/Account.php
+++ b/app/Devnull/Account.php
@@ -34,6 +34,8 @@ final class Account
private const string TimezoneKey = 'devnull.timezone';
+ private const string TermsKey = 'devnull.terms-to-accept';
+
public function user(): ?User
{
$attributes = Cache::get(self::CacheKey);
@@ -191,8 +193,30 @@ public function rememberTimezone(?string $timezone): void
}
/**
- * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, every answer, image and
- * voice note kept for a quick start, and your unsent drafts and recordings.
+ * The version of the terms devnull last said you have yet to accept, so the app opens on them (see AcceptTerms),
+ * or null when you're up to date.
+ */
+ public function termsToAccept(): ?string
+ {
+ $version = Cache::get(self::TermsKey);
+
+ return is_string($version) && $version !== '' ? $version : null;
+ }
+
+ public function rememberTermsToAccept(?string $version): void
+ {
+ if ($version === null) {
+ Cache::forget(self::TermsKey);
+
+ return;
+ }
+
+ Cache::forever(self::TermsKey, $version);
+ }
+
+ /**
+ * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, the terms you owe, every
+ * answer, image and voice note kept for a quick start, and your unsent drafts and recordings.
*/
public function forget(): void
{
@@ -209,5 +233,6 @@ public function forget(): void
Cache::forget(self::ProfileKey);
Cache::forget(self::PinWarningKey);
Cache::forget(self::TimezoneKey);
+ Cache::forget(self::TermsKey);
}
}
diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php
index 28d9d7e..dccb518 100644
--- a/app/Devnull/Client.php
+++ b/app/Devnull/Client.php
@@ -123,6 +123,11 @@
public const string SettingsPath = 'me/settings';
+ /**
+ * Where the signed-in account accepts the terms and privacy policy in force.
+ */
+ public const string TermsPath = 'me/terms';
+
/**
* The commits the signed-in account scheduled, as the web's Scheduled
* commits page lists them.
@@ -184,16 +189,24 @@ public static function pastelPath(string $id): string
* Trades the code dev-null.sh sent back, with the PKCE verifier only this
* phone knows, for the user it approved this phone for. It's signed like
* every request, so the code only works for the key it was made for.
+ *
+ * No sign-in starts until the terms are accepted, so this says they were.
+ * devnull works out which version that means from when it hears.
*/
public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier): User
{
- return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: ['code' => $code, 'code_verifier' => $verifier])));
+ return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [
+ 'code' => $code,
+ 'code_verifier' => $verifier,
+ 'terms_accepted' => true,
+ ])));
}
/**
* Signs in on a phone devnull doesn't know yet with an Apple ID linked to
* an account, which approves this phone's key. Apple's identity token
* carries sha256 of the nonce, and devnull checks it against the raw one.
+ * It says the terms were accepted, as redeemSignIn() does.
*/
public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName): User
{
@@ -201,6 +214,7 @@ public function signInWithApple(#[SensitiveParameter] string $identityToken, #[S
'apple_identity_token' => $identityToken,
'nonce' => $nonce,
'device_name' => $deviceName,
+ 'terms_accepted' => true,
])));
}
@@ -232,6 +246,19 @@ public function me(): CurrentUser
return CurrentUser::fromArray($this->get('me'));
}
+ /**
+ * Accepts the terms and privacy policy, naming the version devnull said is in force. When they changed again
+ * since, devnull refuses with a 422 rather than take a yes to a version nobody showed you. It answers as
+ * `GET /me` does, and every kept copy of you takes it.
+ */
+ public function acceptTerms(string $version): CurrentUser
+ {
+ $json = $this->json($this->send('PUT', self::TermsPath, payload: ['version' => $version]));
+ $this->cache->meChanged($json);
+
+ return CurrentUser::fromArray($json);
+ }
+
/**
* Deletes the account this phone is signed in as, the way the web's Delete
* account does. devnull deletes its devices with it, so this phone's key
diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php
index c082edb..9fead8f 100644
--- a/app/Devnull/Data/CurrentUser.php
+++ b/app/Devnull/Data/CurrentUser.php
@@ -15,6 +15,10 @@
* it only was when you signed in). It has whether pastéis mode is on, how
* many pastéis you can still give today, and how many others have given you,
* and your Remotes settings, or null while you have Remotes off.
+ *
+ * And the terms: the version in force, which is the moment it took effect, when
+ * you last accepted them, and whether that was this version. A devnull too old
+ * to say leaves all three null.
*/
final readonly class CurrentUser
{
@@ -30,6 +34,9 @@ public function __construct(
public Pasteis $pasteisToday = new Pasteis,
public int $pasteisReceived = 0,
public ?Remotes $remotes = null,
+ public ?string $termsVersion = null,
+ public ?CarbonImmutable $termsAcceptedAt = null,
+ public ?bool $termsAccepted = null,
) {}
/**
@@ -59,9 +66,28 @@ public static function fromArray(array $json): self
pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0,
// Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts.
remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes,
+ termsVersion: is_string($data['terms_version'] ?? null) && $data['terms_version'] !== '' ? $data['terms_version'] : null,
+ termsAcceptedAt: Commit::date($data['terms_accepted_at'] ?? null),
+ termsAccepted: is_bool($data['terms_accepted'] ?? null) ? $data['terms_accepted'] : null,
);
}
+ /**
+ * The version of the terms devnull is waiting for you to accept, exactly as it sent it, or null when you have.
+ */
+ public function termsToAccept(): ?string
+ {
+ return $this->termsAccepted === false ? $this->termsVersion : null;
+ }
+
+ /**
+ * When the terms in force took effect.
+ */
+ public function termsTookEffect(): ?CarbonImmutable
+ {
+ return Commit::date($this->termsVersion);
+ }
+
/**
* You as this phone remembers you, until devnull says the rest.
*/
diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php
index 441cc2d..43fbd5f 100644
--- a/app/Devnull/Display.php
+++ b/app/Devnull/Display.php
@@ -138,6 +138,14 @@ public static function fullDateTime(CarbonInterface $date, ?string $timezone = n
return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('g:i A · M j, Y');
}
+ /**
+ * The day something happened, with no time: "Nov 1, 2026", in your timezone.
+ */
+ public static function day(CarbonInterface $date, ?string $timezone = null): string
+ {
+ return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('M j, Y');
+ }
+
/**
* When a scheduled commit is due, as the web's Scheduled commits page
* prints it: "Oct 16, 2026, 10:30 AM", in the phone's timezone.
diff --git a/app/NativeComponents/AcceptTerms.php b/app/NativeComponents/AcceptTerms.php
new file mode 100644
index 0000000..9804ae4
--- /dev/null
+++ b/app/NativeComponents/AcceptTerms.php
@@ -0,0 +1,161 @@
+version = app(Account::class)->termsToAccept();
+
+ if ($this->version === null) {
+ $this->replace('/log');
+
+ return;
+ }
+
+ $kept = $this->cached('me');
+ $this->acceptedBefore = $kept !== null && CurrentUser::fromArray($kept)->termsAcceptedAt !== null;
+
+ $this->revalidate();
+ }
+
+ protected function revalidate(): void
+ {
+ $this->refresh('me', [], fn (array $json) => $this->takeTerms(CurrentUser::fromArray($json)), quiet: true);
+ }
+
+ protected function showsContent(): bool
+ {
+ return true;
+ }
+
+ public function accept(): void
+ {
+ if ($this->version === null) {
+ return;
+ }
+
+ try {
+ $current = app(Client::class)->acceptTerms($this->version);
+ } catch (Unauthenticated $exception) {
+ $this->signOut($exception);
+
+ return;
+ } catch (ApiException $exception) {
+ if ($exception->status !== 422) {
+ $this->error = $exception->firstError();
+
+ return;
+ }
+
+ // The version in force moved on since devnull said which, so this asks it for the new one.
+ $this->error = self::ChangedAgain;
+ $this->retry();
+
+ return;
+ } catch (KeychainUnavailable $exception) {
+ $this->error = $exception->getMessage();
+
+ return;
+ }
+
+ $this->error = null;
+ $this->takeTerms($current);
+ }
+
+ #[On(ButtonPressed::class)]
+ public function onAlertButton(string $label = '', ?string $id = null): void
+ {
+ if ($id === self::SignOutAlert && $label === 'Sign out') {
+ $this->forget();
+ }
+
+ if ($id === self::DeleteAccountAlert && $label === 'Delete') {
+ $this->deleteAccount();
+ }
+ }
+
+ public function render(): View
+ {
+ $tookEffect = Commit::date($this->version);
+
+ return view('native.accept-terms', [
+ 'logo' => TabsLayout::logo(),
+ 'title' => $this->acceptedBefore ? 'We updated our terms' : 'Accept our terms',
+ 'explanation' => $this->acceptedBefore && $tookEffect !== null
+ ? 'Our terms of service changed on '.Display::day($tookEffect).'. Read them, and accept them to keep using /dev/null.'
+ : 'Read the terms of service and privacy policy, and accept them to keep using /dev/null.',
+ ]);
+ }
+
+ /**
+ * Takes devnull's word on the terms: on to the log once you're up to date with them, as after accepting them
+ * here or on the web, or else the version it's waiting for now.
+ */
+ protected function takeTerms(CurrentUser $current): void
+ {
+ if ($current->termsAccepted === null || $current->user->username === '') {
+ return;
+ }
+
+ app(Account::class)->remember($current->user);
+ app(Account::class)->rememberTermsToAccept($current->termsToAccept());
+
+ if ($current->termsToAccept() === null) {
+ $this->replace('/log');
+
+ return;
+ }
+
+ $this->version = $current->termsToAccept();
+ $this->acceptedBefore = $current->termsAcceptedAt !== null;
+ }
+}
diff --git a/app/NativeComponents/Concerns/LeavesDevnull.php b/app/NativeComponents/Concerns/LeavesDevnull.php
new file mode 100644
index 0000000..5dfb31b
--- /dev/null
+++ b/app/NativeComponents/Concerns/LeavesDevnull.php
@@ -0,0 +1,81 @@
+ 'Cancel', 'style' => 'cancel'],
+ ['label' => 'Sign out', 'style' => 'destructive'],
+ ],
+ )->id(self::SignOutAlert);
+ }
+
+ public function confirmDeleteAccount(): void
+ {
+ Dialog::alert(
+ 'Delete your account?',
+ 'Your profile, commits, replies, stars and follows are removed from /dev/null. There is no undo.',
+ [
+ ['label' => 'Cancel', 'style' => 'cancel'],
+ ['label' => 'Delete', 'style' => 'destructive'],
+ ],
+ )->id(self::DeleteAccountAlert);
+ }
+
+ /**
+ * Signs out: the key goes from this phone, and with it who you are and
+ * every answer kept for you (see Account::forget).
+ */
+ public function forget(): void
+ {
+ app(DeviceKey::class)->forget();
+ app(Account::class)->forget();
+
+ $this->replace('/onboarding');
+ }
+
+ /**
+ * Deletes the account on devnull, which takes its devices with it, then
+ * signs out. When devnull can't, the error shows and you stay signed in.
+ */
+ public function deleteAccount(): void
+ {
+ $deleted = $this->attempt(function (Client $devnull): bool {
+ $devnull->deleteAccount();
+
+ return true;
+ });
+
+ if ($deleted === true) {
+ $this->forget();
+ }
+ }
+}
diff --git a/app/NativeComponents/Concerns/OpensLegalPages.php b/app/NativeComponents/Concerns/OpensLegalPages.php
new file mode 100644
index 0000000..97a0fb5
--- /dev/null
+++ b/app/NativeComponents/Concerns/OpensLegalPages.php
@@ -0,0 +1,22 @@
+remember($current->user);
app(Account::class)->rememberPasteis($current->pasteis());
app(Account::class)->rememberRemotes($current->remotes);
+ $this->takeTerms($current);
}
app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount);
}, quiet: true);
}
+ /**
+ * Keeps whether devnull is waiting for you to accept its terms, when its
+ * answer says, and sends you to accept them the moment it is, whichever
+ * screen you're on (see AcceptTerms).
+ */
+ protected function takeTerms(CurrentUser $current): void
+ {
+ if ($current->termsAccepted === null) {
+ return;
+ }
+
+ app(Account::class)->rememberTermsToAccept($current->termsToAccept());
+
+ if ($current->termsToAccept() !== null) {
+ $this->replace(AcceptTerms::Path);
+ }
+ }
+
/**
* Asks devnull for what only you see in the web's settings, and keeps the
* timezone you chose there, which `/me` doesn't carry, so the app shows
diff --git a/app/NativeComponents/Launch.php b/app/NativeComponents/Launch.php
index fb249a6..4c55633 100644
--- a/app/NativeComponents/Launch.php
+++ b/app/NativeComponents/Launch.php
@@ -9,7 +9,8 @@
use Native\Mobile\Edge\NativeComponent;
/**
- * The first screen: straight to the log for a verified device, otherwise onboarding.
+ * The first screen: straight to the log for a verified device, otherwise onboarding. A verified device whose account
+ * owed terms when devnull last said goes to accept them first (see AcceptTerms).
*
* A verified device whose key has gone, as on a new phone restored from a
* backup, or when the build's signing team changed and the Keychain with it,
@@ -35,7 +36,7 @@ public function mount(Account $account, DeviceKey $deviceKey): void
return;
}
- $this->replace('/log');
+ $this->replace($account->termsToAccept() === null ? '/log' : AcceptTerms::Path);
}
public function render(): View
diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php
index 02b85b2..2b3cef0 100644
--- a/app/NativeComponents/Onboarding.php
+++ b/app/NativeComponents/Onboarding.php
@@ -10,6 +10,7 @@
use App\Devnull\Exceptions\ApiException;
use App\Devnull\Exceptions\KeychainUnavailable;
use App\Devnull\SignIn;
+use App\NativeComponents\Concerns\OpensLegalPages;
use App\NativeComponents\Concerns\SignsInWithApple;
use App\NativeComponents\Layouts\TabsLayout;
use Illuminate\Support\Facades\Log;
@@ -40,6 +41,7 @@
*/
class Onboarding extends NativeComponent
{
+ use OpensLegalPages;
use SignsInWithApple;
public const string NotStartedHere = "fatal: that sign-in didn't start on this phone, so it was ignored.";
@@ -54,6 +56,12 @@ class Onboarding extends NativeComponent
*/
public ?string $appleError = null;
+ /**
+ * Whether the box for the terms and privacy policy is ticked. Neither way in starts until it is. Don't read it when
+ * a sign-in finishes: the browser's answer can land on a new screen, where it's false again.
+ */
+ public bool $acceptedTerms = false;
+
private string $deviceName = '';
/**
@@ -94,13 +102,18 @@ public function onResume(): void
$this->finishSignIn();
}
+ public function toggleTerms(): void
+ {
+ $this->acceptedTerms = ! $this->acceptedTerms;
+ }
+
/**
* Opens dev-null.sh with a fresh verifier and state. Dismissing the sheet
* sends nothing back, so the screen simply stays as it is.
*/
public function signIn(): void
{
- if ($this->fingerprint === '') {
+ if ($this->fingerprint === '' || ! $this->acceptedTerms) {
return;
}
@@ -118,7 +131,7 @@ public function signIn(): void
*/
public function signInWithApple(): void
{
- if ($this->fingerprint === '' || ! AppleSignIn::isAvailable()) {
+ if ($this->fingerprint === '' || ! $this->acceptedTerms || ! AppleSignIn::isAvailable()) {
return;
}
@@ -239,8 +252,9 @@ private function finishSignIn(): void
/**
* A key devnull already knows and approved signs in with nothing to do,
- * as after the app's stored sign-in was lost. Any other answer leaves
- * onboarding as it is, without an error: there was nothing to ask for.
+ * as after the app's stored sign-in was lost, then asks for any terms
+ * still to accept. Any other answer leaves onboarding as it is, without
+ * an error: there was nothing to ask for.
*/
private function signInWithExistingKey(): void
{
@@ -258,8 +272,9 @@ private function signInWithExistingKey(): void
app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount);
app(Account::class)->rememberPasteis($current->pasteis());
app(Account::class)->rememberRemotes($current->remotes);
+ app(Account::class)->rememberTermsToAccept($current->termsToAccept());
$this->keepProfile($current->user);
- $this->replace('/log');
+ $this->replace($current->termsToAccept() === null ? '/log' : AcceptTerms::Path);
}
/**
diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php
index 1630f33..f8516be 100644
--- a/app/NativeComponents/ThisDevice.php
+++ b/app/NativeComponents/ThisDevice.php
@@ -15,6 +15,8 @@
use App\Devnull\ResponseCache;
use App\Devnull\Timezone;
use App\Devnull\WhoToFollow;
+use App\NativeComponents\Concerns\LeavesDevnull;
+use App\NativeComponents\Concerns\OpensLegalPages;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\SignsInWithApple;
use App\NativeComponents\Concerns\SyncsOutbox;
@@ -33,7 +35,8 @@
* Your account, reached from your own profile, laid out like the web's profile
* settings: your profile as devnull mirrors it from GitHub, with a sync, your
* colour, your timezone, pastéis mode, Remotes and their settings, the people
- * you mute and block, then this phone's fingerprint, signing out and deleting
+ * you mute and block, then this phone's fingerprint, signing out, the terms
+ * and privacy policy with which version you accepted and when, and deleting
* the account.
* Where the app talks to only shows when it isn't dev-null.sh.
*
@@ -47,18 +50,16 @@
*/
class ThisDevice extends NativeComponent
{
+ use LeavesDevnull;
+ use OpensLegalPages;
use RefreshesInBackground;
use SignsInWithApple;
use SyncsOutbox;
use TalksToDevnull;
use WritesInBackground;
- public const string SignOutAlert = 'sign-out';
-
public const string UnlinkAppleAlert = 'unlink-apple';
- public const string DeleteAccountAlert = 'delete-account';
-
/**
* Where Edit on GitHub goes, as on the web.
*/
@@ -583,30 +584,6 @@ public function openDevices(): void
Browser::inApp(rtrim((string) config('devnull.url'), '/').'/settings/devices');
}
- public function confirmSignOut(): void
- {
- Dialog::alert(
- 'Sign out?',
- 'This deletes the key from this phone. The phone stays listed on dev-null.sh until you remove it there, and signing in again makes a new key.',
- [
- ['label' => 'Cancel', 'style' => 'cancel'],
- ['label' => 'Sign out', 'style' => 'destructive'],
- ],
- )->id(self::SignOutAlert);
- }
-
- public function confirmDeleteAccount(): void
- {
- Dialog::alert(
- 'Delete your account?',
- 'Your profile, commits, replies, stars and follows are removed from /dev/null. There is no undo.',
- [
- ['label' => 'Cancel', 'style' => 'cancel'],
- ['label' => 'Delete', 'style' => 'destructive'],
- ],
- )->id(self::DeleteAccountAlert);
- }
-
/**
* Opens Apple's sheet. Its answer arrives as an event (see SignsInWithApple).
*/
@@ -661,35 +638,6 @@ public function onAlertButton(string $label = '', ?string $id = null): void
}
}
- /**
- * Signs out: the key goes from this phone, and with it who you are and
- * every answer kept for you (see Account::forget).
- */
- public function forget(): void
- {
- app(DeviceKey::class)->forget();
- app(Account::class)->forget();
-
- $this->replace('/onboarding');
- }
-
- /**
- * Deletes the account on devnull, which takes its devices with it, then
- * signs out. When devnull can't, the error shows and you stay signed in.
- */
- public function deleteAccount(): void
- {
- $deleted = $this->attempt(function (Client $devnull): bool {
- $devnull->deleteAccount();
-
- return true;
- });
-
- if ($deleted === true) {
- $this->forget();
- }
- }
-
public function render(): View
{
$current = $this->current;
@@ -716,9 +664,33 @@ public function render(): View
false => 'not linked',
null => 'unknown',
},
+ 'terms' => $current === null ? null : self::terms($current),
]);
}
+ /**
+ * Which terms you accepted and when, each version named by the day it took effect. devnull keeps only when you
+ * accepted, so a version older than the one in force can't be named. Null until devnull has said.
+ */
+ private static function terms(CurrentUser $current): ?string
+ {
+ $tookEffect = $current->termsTookEffect();
+
+ if ($tookEffect === null || $current->termsAccepted === null) {
+ return null;
+ }
+
+ $terms = Display::day($tookEffect);
+ $acceptedOn = $current->termsAcceptedAt === null ? null : Display::day($current->termsAcceptedAt);
+
+ return match (true) {
+ $current->termsAccepted && $acceptedOn !== null => "You accepted the terms of {$terms} on {$acceptedOn}.",
+ $current->termsAccepted => "You accepted the terms of {$terms}.",
+ $acceptedOn !== null => "You accepted an earlier version on {$acceptedOn}. The terms changed on {$terms}.",
+ default => "You haven't accepted the terms of {$terms} yet.",
+ };
+ }
+
/**
* What devnull mirrors from GitHub, in the web's order, with null for what GitHub doesn't have.
*
@@ -913,6 +885,7 @@ private function takeMe(array $json): void
app(Account::class)->rememberPasteis($current->pasteis());
app(Account::class)->rememberRemotes($current->remotes);
app(ResponseCache::class)->meChanged($json);
+ $this->takeTerms($current);
}
/**
diff --git a/resources/views/native/accept-terms.blade.php b/resources/views/native/accept-terms.blade.php
new file mode 100644
index 0000000..aee64a7
--- /dev/null
+++ b/resources/views/native/accept-terms.blade.php
@@ -0,0 +1,31 @@
+@use('App\Icons\Lucide')
+
+
+
+
+
+
+ {{ $title }}
+ {{ $explanation }}
+ There's no tolerance for objectionable content or abusive users.
+ If you don't accept them, you can sign out, or delete your account.
+
+
+ Terms of service
+ Privacy policy
+
+
+
+
+
+ @if ($error)
+ {{ $error }}
+ @endif
+
+ Accept the terms
+ Sign out
+
+ Delete account
+
+
+
diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php
index 9c45eb3..563a8b8 100644
--- a/resources/views/native/onboarding.blade.php
+++ b/resources/views/native/onboarding.blade.php
@@ -16,12 +16,34 @@
{{ $appleError }}
@endif
+ {{-- What Apple asks of an app with user-generated content: agree to the terms before you're in. Neither button works until it's ticked. --}}
+
+
+
+ @if ($acceptedTerms)
+
+ @endif
+
+
+
+ I agree to the terms of service and privacy policy. There's no tolerance for objectionable content or abusive users.
+
+
+ Terms of service
+
+
+ Privacy policy
+
+
+
+
+
{{-- Commit Blue, large, as the web's primary button. Nobody's lane colours it here: primary is always Commit Blue. --}}
- {{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in on dev-null.sh' }}
+ {{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in on dev-null.sh' }}
{{-- Apple's button in Apple's colours, the theme's accent, with its logo, as big as the dev-null.sh one, as Apple's guidelines ask. --}}
@if ($offersApple)
- Sign in with Apple
+ Sign in with Apple
@endif
diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php
index cededae..93c613b 100644
--- a/resources/views/native/this-device.blade.php
+++ b/resources/views/native/this-device.blade.php
@@ -318,6 +318,15 @@
Deletes the key from this phone and signs you out.
+
+ @include('native.partials.settings-heading', ['title' => 'Terms and privacy', 'description' => $terms])
+
+
+ Terms of service
+ Privacy policy
+
+
+
@include('native.partials.settings-heading', [
'title' => 'Delete account',
diff --git a/routes/mobile.php b/routes/mobile.php
index c444401..764b9a2 100644
--- a/routes/mobile.php
+++ b/routes/mobile.php
@@ -1,6 +1,7 @@
and($user->username)->toBe('local_dev');
Http::assertSent(function (Request $request): bool {
- $body = '{"code":"the-code","code_verifier":"the-verifier"}';
+ $body = '{"code":"the-code","code_verifier":"the-verifier","terms_accepted":true}';
expect($request->method())->toBe('POST')
->and($request->url())->toBe('http://devnull.test/api/v1/'.Client::SignInPath)
@@ -677,7 +677,7 @@ function receivedMessage(Request $request): string
expect($user->username)->toBe('local_dev');
Http::assertSent(function (Request $request): bool {
- $body = '{"apple_identity_token":"the.identity.token","nonce":"raw-nonce","device_name":"devnull on iPhone"}';
+ $body = '{"apple_identity_token":"the.identity.token","nonce":"raw-nonce","device_name":"devnull on iPhone","terms_accepted":true}';
expect($request->method())->toBe('POST')
->and($request->url())->toBe('http://devnull.test/api/v1/'.Client::SignInPath)
diff --git a/tests/Feature/Screens/AcceptTermsTest.php b/tests/Feature/Screens/AcceptTermsTest.php
new file mode 100644
index 0000000..fc10116
--- /dev/null
+++ b/tests/Feature/Screens/AcceptTermsTest.php
@@ -0,0 +1,205 @@
+ 'http://devnull.test']);
+ signIn();
+ app(Account::class)->rememberTimezone('Europe/Lisbon');
+});
+
+/**
+ * devnull said, the last time it was asked, that the terms in force since Nov 1, 2026 are still to be accepted.
+ */
+function oweTerms(?string $acceptedAt = '2026-10-05T10:00:00Z'): void
+{
+ keep('me', currentUserTermsJson(false, $acceptedAt));
+ app(Account::class)->rememberTermsToAccept('2026-11-01T14:35:00Z');
+}
+
+/**
+ * devnull's answer to a version that isn't the one in force.
+ *
+ * @return array{0: array, 1: int}
+ */
+function staleTermsVersion(): array
+{
+ return [['message' => 'The selected version is invalid.', 'errors' => ['version' => ['The selected version is invalid.']]], 422];
+}
+
+it('sends you to accept the terms the moment devnull says you owe them', function () {
+ fakeApi([
+ 'GET /api/v1/log/following' => [pageJson([commitJson()])],
+ 'GET /api/v1/me' => [currentUserTermsJson(false)],
+ ]);
+
+ Native::visit('/log')->assertReplacedWith(AcceptTerms::Path);
+
+ expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z');
+});
+
+it('sends you to accept them from your account too', function () {
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]);
+
+ Native::visit('/profile/device')->assertReplacedWith(AcceptTerms::Path);
+});
+
+it('lets you carry on, and forgets terms owed before, once devnull says you accepted them', function () {
+ app(Account::class)->rememberTermsToAccept('2026-10-01T00:00:00Z');
+ fakeApi([
+ 'GET /api/v1/log/following' => [pageJson([commitJson()])],
+ 'GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')],
+ ]);
+
+ Native::visit('/log')->assertNoNavigation();
+
+ expect(app(Account::class)->termsToAccept())->toBeNull();
+});
+
+it('asks for nothing when devnull does not say which terms are in force', function () {
+ fakeApi([
+ 'GET /api/v1/log/following' => [pageJson([commitJson()])],
+ 'GET /api/v1/me' => [currentUserJson()],
+ ]);
+
+ Native::visit('/log')->assertNoNavigation();
+
+ expect(app(Account::class)->termsToAccept())->toBeNull();
+});
+
+it('says the terms changed, and when, to someone who accepted them before', function () {
+ oweTerms();
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]);
+
+ Native::visit(AcceptTerms::Path)
+ ->assertNoNavigation()
+ ->assertSee('We updated our terms')
+ ->assertSee('Our terms of service changed on Nov 1, 2026. Read them, and accept them to keep using /dev/null.')
+ ->assertSee("There's no tolerance for objectionable content or abusive users.")
+ ->assertSee("If you don't accept them, you can sign out, or delete your account.")
+ ->assertSee('Accept the terms')
+ ->assertSee('Sign out')
+ ->assertSee('Delete account');
+});
+
+it('only asks someone who never accepted any', function () {
+ oweTerms(acceptedAt: null);
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, null)]]);
+
+ Native::visit(AcceptTerms::Path)
+ ->assertSee('Accept our terms')
+ ->assertSee('Read the terms of service and privacy policy, and accept them to keep using /dev/null.')
+ ->assertDontSee('We updated our terms');
+});
+
+it('accepts the version devnull said, then carries on to the log', function () {
+ oweTerms();
+ fakeApi([
+ 'GET /api/v1/me' => [currentUserTermsJson(false)],
+ 'PUT /api/v1/me/terms' => [currentUserTermsJson(true, '2026-11-03T12:00:00Z')],
+ ]);
+
+ Native::visit(AcceptTerms::Path)->tap('accept')->assertReplacedWith('/log');
+
+ expect(sentTo('PUT', 'me/terms'))->toBe([json_encode(['version' => '2026-11-01T14:35:00Z'])])
+ ->and(app(Account::class)->termsToAccept())->toBeNull()
+ ->and(CurrentUser::fromArray(kept('me'))->termsAccepted)->toBeTrue();
+});
+
+it('asks again when the terms changed while it was open, then accepts the new ones', function () {
+ oweTerms();
+ serveApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'PUT /api/v1/me/terms' => staleTermsVersion()]);
+
+ $screen = Native::visit(AcceptTerms::Path);
+
+ serveApi(['GET /api/v1/me' => [currentUserTermsJson(false, version: '2026-12-01T09:00:00Z')], 'PUT /api/v1/me/terms' => staleTermsVersion()]);
+
+ $screen->tap('accept')
+ ->assertNoNavigation()
+ ->assertSee(AcceptTerms::ChangedAgain)
+ ->assertSet('version', '2026-12-01T09:00:00Z')
+ ->assertSee('Our terms of service changed on Dec 1, 2026. Read them, and accept them to keep using /dev/null.');
+
+ serveApi(['GET /api/v1/me' => [currentUserTermsJson(false, version: '2026-12-01T09:00:00Z')], 'PUT /api/v1/me/terms' => [currentUserTermsJson(true, '2026-12-02T09:00:00Z', '2026-12-01T09:00:00Z')]]);
+
+ $screen->tap('accept')->assertReplacedWith('/log');
+
+ expect(sentTo('PUT', 'me/terms'))->toBe([
+ json_encode(['version' => '2026-11-01T14:35:00Z']),
+ json_encode(['version' => '2026-12-01T09:00:00Z']),
+ ]);
+});
+
+it('says why when devnull cannot take the yes, and stays', function () {
+ oweTerms();
+ serveApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'PUT /api/v1/me/terms' => [['message' => 'Server Error'], 500]]);
+
+ Native::visit(AcceptTerms::Path)
+ ->tap('accept')
+ ->assertNoNavigation()
+ ->assertSet('error', 'Server Error')
+ ->assertSee('Server Error');
+
+ expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z');
+});
+
+it('lets you straight through when you accepted them on the web since', function () {
+ oweTerms();
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]);
+
+ Native::visit(AcceptTerms::Path)->assertReplacedWith('/log');
+
+ expect(app(Account::class)->termsToAccept())->toBeNull();
+});
+
+it('goes to the log when there are no terms to accept', function () {
+ fakeApi([]);
+
+ Native::visit(AcceptTerms::Path)->assertReplacedWith('/log');
+
+ Http::assertNothingSent();
+});
+
+it('opens the terms and privacy policy in the app', function () {
+ oweTerms();
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]);
+
+ Native::visit(AcceptTerms::Path)
+ ->tap('terms-link')
+ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms')
+ ->tap('privacy-link')
+ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy');
+});
+
+it('signs out after a confirm, and forgets the terms owed', function () {
+ oweTerms();
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]);
+
+ Native::visit(AcceptTerms::Path)
+ ->tap('sign-out')
+ ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Sign out?')
+ ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Sign out', 'id' => AcceptTerms::SignOutAlert])
+ ->assertReplacedWith('/onboarding');
+
+ expect(app(Account::class)->user())->toBeNull()
+ ->and(app(Account::class)->termsToAccept())->toBeNull();
+});
+
+it('deletes the account after a confirm', function () {
+ oweTerms();
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'DELETE /api/v1/me' => [null, 204]]);
+
+ Native::visit(AcceptTerms::Path)
+ ->press('delete-account')
+ ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Delete your account?')
+ ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => AcceptTerms::DeleteAccountAlert])
+ ->assertReplacedWith('/onboarding');
+
+ expect(sentTo('DELETE', 'me'))->toHaveCount(1)
+ ->and(app(Account::class)->user())->toBeNull();
+});
diff --git a/tests/Feature/Screens/AccountTermsTest.php b/tests/Feature/Screens/AccountTermsTest.php
new file mode 100644
index 0000000..74f0083
--- /dev/null
+++ b/tests/Feature/Screens/AccountTermsTest.php
@@ -0,0 +1,59 @@
+ 'http://devnull.test']);
+ signIn();
+ app(Account::class)->rememberTimezone('Europe/Lisbon');
+});
+
+it('says which terms you accepted and when, just before deleting the account', function (?string $acceptedAt, string $sentence) {
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, $acceptedAt)]]);
+
+ $screen = Native::visit('/profile/device')
+ ->assertNoNavigation()
+ ->assertSee('Terms and privacy')
+ ->assertSee($sentence);
+
+ $sections = findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['sign-out', 'terms-section', 'delete-section'], true));
+
+ expect(array_column($sections, 'ref'))->toBe(['sign-out', 'terms-section', 'delete-section']);
+})->with([
+ 'with the day' => ['2026-11-02T09:00:00Z', 'You accepted the terms of Nov 1, 2026 on Nov 2, 2026.'],
+ 'without the day' => [null, 'You accepted the terms of Nov 1, 2026.'],
+]);
+
+it('says when you have not accepted the terms in force, until devnull sends you to accept them', function (?string $acceptedAt, string $sentence) {
+ keep('me', currentUserTermsJson(false, $acceptedAt));
+ holdRefreshes();
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, $acceptedAt)]]);
+
+ $screen = Native::visit('/profile/device')->assertSee($sentence);
+
+ finishRefreshes($screen)->assertReplacedWith(AcceptTerms::Path);
+})->with([
+ 'an earlier version' => ['2026-10-05T10:00:00Z', 'You accepted an earlier version on Oct 5, 2026. The terms changed on Nov 1, 2026.'],
+ 'never accepted' => [null, "You haven't accepted the terms of Nov 1, 2026 yet."],
+]);
+
+it('shows only the links when devnull does not say which terms are in force', function () {
+ fakeApi(['GET /api/v1/me' => [currentUserJson()]]);
+
+ Native::visit('/profile/device')
+ ->assertSee('Terms of service')
+ ->assertSee('Privacy policy')
+ ->assertDontSee('accepted');
+});
+
+it('opens the terms and privacy policy in the app', function () {
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]);
+
+ Native::visit('/profile/device')
+ ->tap('terms-link')
+ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms')
+ ->tap('privacy-link')
+ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy');
+});
diff --git a/tests/Feature/Screens/LaunchTest.php b/tests/Feature/Screens/LaunchTest.php
index 77c5a8d..57ea065 100644
--- a/tests/Feature/Screens/LaunchTest.php
+++ b/tests/Feature/Screens/LaunchTest.php
@@ -2,6 +2,7 @@
use App\Devnull\Account;
use App\Devnull\Data\User;
+use App\NativeComponents\AcceptTerms;
use App\NativeComponents\Launch;
use Native\Mobile\Testing\Native;
@@ -17,6 +18,13 @@
Native::visit('/')->assertReplacedWith('/log');
});
+it('sends a verified device to accept the terms first when devnull last said they are owed', function () {
+ signIn();
+ app(Account::class)->rememberTermsToAccept('2026-11-01T14:35:00Z');
+
+ Native::visit('/')->assertReplacedWith(AcceptTerms::Path);
+});
+
it('stays signed in across a rebuild, which keeps the key and the stored sign-in', function () {
$bridge = signIn();
diff --git a/tests/Feature/Screens/OnboardingAppleTest.php b/tests/Feature/Screens/OnboardingAppleTest.php
index f297ccf..8c2460e 100644
--- a/tests/Feature/Screens/OnboardingAppleTest.php
+++ b/tests/Feature/Screens/OnboardingAppleTest.php
@@ -30,8 +30,8 @@
$buttons = findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button');
expect(array_column($buttons, 'ref'))->toBe(['sign-in', 'sign-in-apple'])
- ->and(childRefs($screen, 'actions'))->toBe(['sign-in', 'sign-in-apple'])
- ->and(shownText($screen))->toBe([])
+ ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in', 'sign-in-apple'])
+ ->and(shownText($screen))->toBe(termsText())
->and($buttons[1]['props'])->toMatchArray(['label' => 'Sign in with Apple', 'variant' => 'accent', 'size' => 'lg', 'leading_icon' => 'apple.logo'])
->and($buttons[0]['props'])->toMatchArray(['variant' => 'primary', 'size' => 'lg'])
->and($buttons[1]['layout'])->toEqual($buttons[0]['layout'])->toEqual(['width' => 'fill'])
@@ -49,7 +49,7 @@
expect(AppleSignIn::isAvailable())->toBeFalse()
->and((new NativeServiceProvider(app()))->plugins())->not->toContain(NativephpSocialAuthServiceProvider::class)
- ->and(childRefs($screen, 'actions'))->toBe(['sign-in']);
+ ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']);
fakeApi(['GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0, 'apple_linked' => false]]]);
signIn();
@@ -64,14 +64,14 @@
$bridge = appleBridge();
$screen = Native::visit('/onboarding', platform: 'ios');
- $screen->tap('sign-in-apple')->assertNoNavigation();
+ $screen->set('acceptedTerms', true)->tap('sign-in-apple')->assertNoNavigation();
$first = appleSheetParams($bridge);
expect($first['nonce'])->toBe(heldAppleNonce())->toMatch('/^[0-9a-f]{64}$/')
->and($first['scopes'])->toBe([])
->and($first['state'])->toBeNull();
- $screen->tap('sign-in-apple');
+ $screen->set('acceptedTerms', true)->tap('sign-in-apple');
$second = appleSheetParams($bridge);
expect($second['nonce'])->toBe(heldAppleNonce())->not->toBe($first['nonce']);
@@ -86,7 +86,7 @@
'POST /api/v1/devices' => [['data' => userWithCountsJson()]],
'GET /api/v1/users/local_dev' => [['data' => userWithCountsJson()]],
]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$nonce = heldAppleNonce();
appleAnswers($screen, $nonce)->assertReplacedWith('/log');
@@ -99,7 +99,7 @@
Http::assertSentCount(2);
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath
- && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'iPhone'])
+ && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'iPhone', 'terms_accepted' => true])
&& $request->hasHeader('X-SSH-Signature'));
// Apple's token carries sha256(nonce) as hex, which is what devnull checks against the raw nonce.
@@ -110,7 +110,7 @@
appleBridge();
$message = ErrorCode::AppleNotLinked->message();
fakeApi(['POST /api/v1/devices' => [['message' => 'No /dev/null account is linked to this Apple ID.', 'code' => 'apple_not_linked'], 404]]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$screen = appleAnswers($screen, heldAppleNonce())
->assertNoNavigation()
@@ -119,8 +119,8 @@
->assertSet('error', null)
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in on dev-null.sh');
- expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'sign-in', 'sign-in-apple'])
- ->and(shownText($screen))->toBe([$message]);
+ expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'terms', 'sign-in', 'sign-in-apple'])
+ ->and(shownText($screen))->toBe([$message, ...termsText()]);
expect(app(Account::class)->user())->toBeNull();
});
@@ -128,7 +128,7 @@
it("says when devnull can't verify Apple's token, without turning dev-null.sh into Try again", function () {
appleBridge();
fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
appleAnswers($screen, heldAppleNonce())
->assertNoNavigation()
@@ -142,7 +142,7 @@
it("returns quietly when Apple's sheet is closed", function () {
appleBridge();
fakeApi([]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'Sign in with Apple was cancelled.', 'errorCode' => AppleSignIn::Cancelled])
->assertNoNavigation()
@@ -150,7 +150,7 @@
->assertSet('error', null)
->assertDontSee('cancelled');
- expect(shownText($screen))->toBe([]);
+ expect(shownText($screen))->toBe(termsText());
expect(heldAppleNonce())->toBeNull();
Http::assertNothingSent();
@@ -159,7 +159,7 @@
it("says so when Apple's sign-in fails another way, and ignores Google's failures", function () {
appleBridge();
fakeApi([]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$screen->emitNative(SignInFailed::class, ['provider' => 'google', 'error' => 'Nope.', 'errorCode' => 'SIGN_IN_FAILED'])
->assertSet('appleError', null);
@@ -176,7 +176,7 @@
it('rejects an answer whose nonce is not the one it sent, and sends nothing', function (?string $nonce) {
appleBridge();
fakeApi([]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
appleAnswers($screen, '', ['nonce' => $nonce])
->assertNoNavigation()
@@ -201,7 +201,7 @@
it('spends an attempt on its first answer, so a replayed one is rejected', function () {
appleBridge();
fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$nonce = heldAppleNonce();
appleAnswers($screen, $nonce);
@@ -213,7 +213,7 @@
it('says so when Apple sends no identity token', function () {
appleBridge();
fakeApi([]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
appleAnswers($screen, heldAppleNonce(), ['identityToken' => null])->assertSet('appleError', AppleSignIn::NoToken);
@@ -224,7 +224,7 @@
fakeKeychain(testSeed())->respondTo('NativephpSocialAuth.AppleSignIn', null);
Native::visit('/onboarding', platform: 'ios')
- ->tap('sign-in-apple')
+ ->set('acceptedTerms', true)->tap('sign-in-apple')
->assertSee(AppleSignIn::CouldNotOpen);
expect(heldAppleNonce())->toBeNull();
@@ -238,7 +238,7 @@
});
$bridge = appleBridge();
fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$nonce = heldAppleNonce();
expect(print_r(app(AppleSignIn::class), true))->not->toContain($nonce)
@@ -267,7 +267,7 @@
Native::visit('/onboarding', platform: 'ios')
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in-apple' && ($node['props']['disabled'] ?? false) === true)
- ->call('signInWithApple');
+ ->set('acceptedTerms', true)->call('signInWithApple');
$bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn');
});
@@ -277,7 +277,7 @@
$screen = Native::visit('/onboarding', platform: 'android')
->assertDontSee('Sign in with Apple')
- ->call('signInWithApple');
+ ->set('acceptedTerms', true)->call('signInWithApple');
expect(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button'))->toHaveCount(1);
$bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn');
@@ -286,7 +286,7 @@
it("asks whether the phone has an Apple Account when Apple's error says nothing more", function () {
appleBridge();
fakeApi([]);
- $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
+ $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
// What closing iOS's "Sign in to your Apple Account" alert sends back.
$screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1000.) (ASAuthorizationError.unknown)', 'errorCode' => 'SIGN_IN_FAILED'])
diff --git a/tests/Feature/Screens/OnboardingSignInAgainTest.php b/tests/Feature/Screens/OnboardingSignInAgainTest.php
index fec81c5..91a4a9b 100644
--- a/tests/Feature/Screens/OnboardingSignInAgainTest.php
+++ b/tests/Feature/Screens/OnboardingSignInAgainTest.php
@@ -3,6 +3,7 @@
use App\Devnull\Account;
use App\Devnull\DeviceKey;
use App\Devnull\ErrorCode;
+use App\NativeComponents\AcceptTerms;
use App\NativeComponents\Launch;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
@@ -35,6 +36,18 @@
$bridge->assertNotCalled('SecureStorage.Set');
});
+it('signs straight back in to the terms when they are still to be accepted', function () {
+ fakeKeychain(testSeed());
+ fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]);
+
+ $screen = Native::visit('/onboarding');
+
+ $this->travel(1)->seconds();
+ $screen->firePoll('tick')->assertReplacedWith(AcceptTerms::Path);
+
+ expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z');
+});
+
it('asks only once, and says nothing, when devnull does not know the key', function (int $status) {
fakeKeychain(testSeed());
fakeApi(['GET /api/v1/me' => [['message' => 'Unauthenticated.'], $status]]);
diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php
index c358a3a..05b279c 100644
--- a/tests/Feature/Screens/OnboardingTest.php
+++ b/tests/Feature/Screens/OnboardingTest.php
@@ -59,7 +59,7 @@ function openedAuthQuery(FakeBridge $bridge): array
*/
function tapSignIn(TestableComponent $screen, FakeBridge $bridge): string
{
- $screen->tap('sign-in');
+ $screen->set('acceptedTerms', true)->tap('sign-in');
return openedAuthQuery($bridge)['state'];
}
@@ -96,7 +96,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
$screen = Native::visit('/onboarding');
- expect(shownText($screen))->toBe([]);
+ expect(shownText($screen))->toBe(termsText());
$screen->assertDontSee('This phone makes its own key')
->assertSet('fingerprint', app(DeviceKey::class)->current()->fingerprint())
@@ -128,7 +128,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
->and($space['layout'])->toMatchArray(['width' => 'fill', 'flex_grow' => 1, 'align_items' => AlignItems::Center->value, 'justify_content' => JustifyContent::Center->value])
->and(array_column($screen->tree()['children'], 'ref'))->toBe(['logo-space', 'actions'])
->and($screen->tree()['layout'])->toMatchArray(['height' => 'fill', 'safe_area' => 1])
- ->and(childRefs($screen, 'actions'))->toBe(['sign-in'])
+ ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in'])
// Drawn at up to 293pt on the widest iPhone, so it's rendered 300pt wide at @3x, at the lockup's own 15:4.
->and([$width, $height])->toBe([900, 240])
->and(getimagesize(public_path(TabsLayout::DarkLogo))[0])->toBe(900);
@@ -153,7 +153,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
$bridge = signInBridge(testSeed());
$screen = Native::visit('/onboarding', platform: 'ios');
- $screen->tap('sign-in')->assertNoNavigation();
+ $screen->set('acceptedTerms', true)->tap('sign-in')->assertNoNavigation();
$url = $bridge->callsTo('Browser.OpenAuth')[0]['params']['url'];
$query = openedAuthQuery($bridge);
@@ -182,7 +182,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
config(['nativephp.app_id' => 'com.nativephp.devnull']);
$bridge = signInBridge(testSeed());
- Native::visit('/onboarding', platform: 'android')->tap('sign-in');
+ Native::visit('/onboarding', platform: 'android')->set('acceptedTerms', true)->tap('sign-in');
expect(openedAuthQuery($bridge))->toMatchArray(['app_name' => '/dev/null for Android', 'app_id' => 'android:com.nativephp.devnull']);
});
@@ -191,7 +191,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
config(['nativephp.app_id' => null]);
$bridge = signInBridge(testSeed());
- Native::visit('/onboarding', platform: 'ios')->tap('sign-in');
+ Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in');
expect(openedAuthQuery($bridge)['app_id'])->toBe('ios:com.nativephp.devnull');
});
@@ -227,7 +227,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
Http::assertSentCount(2);
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath
- && $request->body() === json_encode(['code' => 'the-code', 'code_verifier' => $verifier])
+ && $request->body() === json_encode(['code' => 'the-code', 'code_verifier' => $verifier, 'terms_accepted' => true])
&& $request->hasHeader('X-SSH-Signature'));
});
@@ -236,6 +236,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]);
$screen = Native::visit('/onboarding');
$state = tapSignIn($screen, $bridge);
+ $verifier = pendingAttempt()->verifier();
// On a phone the callback screen is pushed over onboarding, so it pops back rather than replacing.
$callback = new AuthCallback;
@@ -250,6 +251,9 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
expect($callback->getNavigationIntent()?->type)->toBe(NavigationIntent::BACK);
$screen->call('onResume')->assertReplacedWith('/log');
+
+ Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
+ && $request->body() === json_encode(['code' => 'c', 'code_verifier' => $verifier, 'terms_accepted' => true]));
});
it('rejects a callback whose state is not the one it sent, and redeems nothing', function () {
@@ -297,7 +301,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
->assertNoNavigation()
->assertSet('error', null);
- expect(shownText($screen))->toBe([]);
+ expect(shownText($screen))->toBe(termsText());
$screen
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in on dev-null.sh');
@@ -316,8 +320,8 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Try again');
// Just above the buttons, and the only text on the screen.
- expect(childRefs($screen, 'actions'))->toBe(['error', 'sign-in'])
- ->and(shownText($screen))->toBe(['Something broke on our side.']);
+ expect(childRefs($screen, 'actions'))->toBe(['error', 'terms', 'sign-in'])
+ ->and(shownText($screen))->toBe(['Something broke on our side.', ...termsText()]);
$retry = tapSignIn($screen, $bridge);
@@ -398,7 +402,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
it('says so when the auth browser does not open', function () {
fakeKeychain(testSeed())->respondTo('Browser.OpenAuth', ['success' => false]);
- Native::visit('/onboarding')->tap('sign-in')->assertSee('fatal: could not open dev-null.sh to sign in.');
+ Native::visit('/onboarding')->set('acceptedTerms', true)->tap('sign-in')->assertSee('fatal: could not open dev-null.sh to sign in.');
});
it("doesn't say why a 401 sent it here: the buttons are enough", function (string $message) {
@@ -406,8 +410,8 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
$screen = Native::visit('/onboarding', ['message' => $message])->assertDontSee($message);
- expect(shownText($screen))->toBe([])
- ->and(childRefs($screen, 'actions'))->toBe(['sign-in']);
+ expect(shownText($screen))->toBe(termsText())
+ ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']);
})->with([
'a key devnull no longer takes' => ['This key is no longer accepted.'],
'a phone to approve' => [ErrorCode::DeviceNotApproved->message()],
@@ -423,7 +427,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
->assertSee('fatal: could not read the device key from the Keychain. Device is locked.')
->assertSet('fingerprint', '')
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && ($node['props']['disabled'] ?? false) === true)
- ->call('signIn');
+ ->set('acceptedTerms', true)->call('signIn');
$bridge->assertNotCalled('SecureStorage.Set')->assertNotCalled('Browser.OpenAuth');
});
@@ -473,3 +477,29 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent
->and(file_get_contents(base_path('.env.example')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->toContain("NATIVEPHP_DEEPLINK_SCHEME=devnull\n")
->and(file_get_contents(base_path('README.md')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->not->toContain('device flow');
});
+
+it('keeps both ways in shut until the terms and privacy policy are agreed to, and links to them', function () {
+ $bridge = signInBridge();
+ $bridge->respondTo('Browser.OpenInApp', ['success' => true]);
+
+ $screen = Native::visit('/onboarding');
+
+ $disabled = fn (string $ref): bool => (bool) ($screen->tree() && (nodeByRef($screen, $ref)['props']['disabled'] ?? false));
+
+ expect($disabled('sign-in'))->toBeTrue();
+
+ $screen->call('signIn');
+
+ expect($bridge->callsTo('Browser.OpenAuth'))->toBe([]);
+
+ $screen->press('accept-terms')->assertSet('acceptedTerms', true)->call('signIn');
+
+ expect($bridge->callsTo('Browser.OpenAuth'))->toHaveCount(1);
+
+ $screen->press('terms-link')
+ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms')
+ ->press('privacy-link')
+ ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy');
+
+ $screen->press('accept-terms')->assertSet('acceptedTerms', false);
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 107be23..6583077 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -459,6 +459,17 @@ function currentUserJson(array $overrides = [], array $extra = []): array
];
}
+/**
+ * `GET /me` with the terms in force since Nov 1, 2026, or the version given, for someone who last accepted the terms
+ * when given (null for never), and whether that was this version.
+ *
+ * @return array
+ */
+function currentUserTermsJson(bool $accepted, ?string $acceptedAt = '2026-10-05T10:00:00Z', string $version = '2026-11-01T14:35:00Z'): array
+{
+ return currentUserJson(['terms_version' => $version, 'terms_accepted_at' => $acceptedAt, 'terms_accepted' => $accepted]);
+}
+
/**
* A commit as the API sends it to a signed request. The short hash and the link
* follow the id and the author, and the body's segments follow the body, unless
@@ -1021,6 +1032,20 @@ function sentTo(string $method, string $path): array
->all();
}
+/**
+ * What the sign-in screen says about the terms, above its buttons.
+ *
+ * @return list
+ */
+function termsText(): array
+{
+ return [
+ 'I agree to the terms of service and privacy policy. There\'s no tolerance for objectionable content or abusive users.',
+ 'Terms of service',
+ 'Privacy policy',
+ ];
+}
+
/**
* A commit you scheduled, as devnull lists it: still waiting, until a test says otherwise. Its body's segments
* follow its body, as the server works them out.