diff --git a/.env.example b/.env.example
index c24214c..7318d2d 100644
--- a/.env.example
+++ b/.env.example
@@ -103,7 +103,7 @@ STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates . A-records for each customer instance.
HETZNER_DNS_TOKEN=
-CLUPILOT_DNS_ZONE=clupilot.com
+CLUPILOT_DNS_ZONE=clupilot.cloud
# Host names (fsn-01.node.…) are NOT published here — that would leak the
# internal WireGuard subnet. They go into this directory instead, which the
diff --git a/VERSION b/VERSION
index 5bdcf5c..25b22e0 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.3.15
+1.3.16
diff --git a/app/Console/Commands/CheckDnsZone.php b/app/Console/Commands/CheckDnsZone.php
new file mode 100644
index 0000000..8b96866
--- /dev/null
+++ b/app/Console/Commands/CheckDnsZone.php
@@ -0,0 +1,75 @@
+argument('zone') ?? '');
+
+ $this->line('');
+ $this->line(" Current zone: {$current}>");
+ $this->line(' Proposed zone: '.($target !== '' ? "{$target}>" : 'none given'));
+ $this->line('');
+
+ $instances = Instance::query()
+ ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
+ ->get();
+
+ if ($instances->isEmpty()) {
+ $this->info(' No instances in service. The zone can be changed with nothing to migrate.');
+ $this->line('');
+
+ return self::SUCCESS;
+ }
+
+ $this->warn(" {$instances->count()} instance(s) are addressed under the current zone.");
+ $this->line('');
+ $this->line(' Each of these carries the old name in places this setting does NOT reach:');
+ $this->line('');
+
+ $records = DnsRecord::query()->whereIn('instance_id', $instances->pluck('id'))->count();
+ $targets = MonitoringTarget::query()->whereIn('instance_id', $instances->pluck('id'))->count();
+ $verified = $instances->filter(fn (Instance $i) => $i->domainIsVerified())->count();
+
+ $this->table(['What', 'Count', 'Consequence of changing the zone'], [
+ ['DNS records (dns_records.fqdn)', $records, 'still point the OLD name at the host'],
+ ['Monitoring targets', $targets, 'keep watching the old URL and report down'],
+ ['TLS certificates', $instances->count(), 'issued for the old name; the new one has none'],
+ ['Nextcloud trusted_domains', $instances->count(), 'rejects the new name until rewritten'],
+ ['Instances on their own domain', $verified, 'unaffected — they are served at custom_domain'],
+ ]);
+
+ $this->line('');
+ $this->line(' A change now is a migration with an outage, not a setting. Either do it');
+ $this->line(' before the first customer, or plan it as a maintenance window.');
+ $this->line('');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Support/OfficialDomains.php b/app/Support/OfficialDomains.php
new file mode 100644
index 0000000..3849731
--- /dev/null
+++ b/app/Support/OfficialDomains.php
@@ -0,0 +1,92 @@
+
+ */
+ public static function hosts(): array
+ {
+ $entries = [];
+
+ foreach ((array) config('admin_access.site_hosts', []) as $host) {
+ $entries[] = ['host' => (string) $host, 'what' => 'site'];
+ }
+
+ if ($app = (string) config('admin_access.app_host')) {
+ $entries[] = ['host' => $app, 'what' => 'portal'];
+ }
+
+ if ($status = (string) config('admin_access.status_host')) {
+ $entries[] = ['host' => $status, 'what' => 'status'];
+ }
+
+ // The instance zone as a pattern, because the label is per customer and
+ // naming one customer's address on a public page names that customer.
+ if ($zone = ProvisioningSettings::dnsZone()) {
+ $entries[] = ['host' => '*.'.$zone, 'what' => 'instance'];
+ }
+
+ // The console is deliberately absent. It is not an address a customer
+ // has any business visiting, and printing it on a public page tells an
+ // attacker where the operator interface lives.
+ return array_values(array_filter($entries, fn (array $e) => $e['host'] !== ''));
+ }
+
+ /**
+ * The registrable domains behind those hosts — what somebody actually has
+ * to check in the address bar.
+ *
+ * "Everything before the first slash ends in one of these" is a rule a
+ * person can apply under time pressure; a list of eight hostnames is not.
+ *
+ * Keyed by domain, valued by what it is for — so the page never has to
+ * work that out from the order it happens to come back in.
+ *
+ * @return array
+ */
+ public static function registrable(): array
+ {
+ $domains = [];
+
+ foreach (self::hosts() as $entry) {
+ $labels = explode('.', ltrim($entry['host'], '*.'));
+
+ // Last two labels. Not a public-suffix implementation: this runs
+ // against an installation's own configured domains, which are known
+ // quantities, and pulling in a suffix list to be right about
+ // co.uk on a page that says "check the address bar" is not the
+ // trade this needs.
+ if (count($labels) < 2) {
+ continue;
+ }
+
+ $domain = implode('.', array_slice($labels, -2));
+
+ // First writer wins: the site is listed before the instance zone,
+ // so a single-domain installation is described as the company's
+ // rather than as the customers'.
+ $domains[$domain] ??= $entry['what'] === 'instance' ? 'instances' : 'company';
+ }
+
+ return $domains;
+ }
+}
diff --git a/config/provisioning.php b/config/provisioning.php
index b3ef3b9..cbcb30b 100644
--- a/config/provisioning.php
+++ b/config/provisioning.php
@@ -129,6 +129,33 @@ return [
'extra_backups' => ['price_cents' => 500],
'priority_support' => ['price_cents' => 2900],
'collabora_pro' => ['price_cents' => 1900],
+ // The owner's commercial figure, to be adjusted once the first ones are
+ // sold. Listed here because the public sheet has to answer what a plan
+ // without an own domain costs to give one — an unpriced feature can only
+ // be shown as absent, and it was being shown that way to the two plans
+ // we would most like to sell it to.
+ 'custom_domain' => [
+ 'price_cents' => 900,
+ // The packages on which an own domain is not possible AT ALL: not
+ // bookable, not upgradable, not even offered. Named by plan key,
+ // and named HERE, because the catalogue cannot answer this. Plan
+ // versions model what a package HAS — its features and its size —
+ // and nothing in the catalogue models what a package may BUY, so
+ // the only catalogue-derived answers would be proxies, and both
+ // available ones are dishonest:
+ //
+ // - "the lowest tier on sale" quietly hands the right to a
+ // grandfathered Start customer the day the owner stops selling
+ // Start or adds a cheaper package underneath it.
+ // - reading it off `branding` ties two unrelated decisions
+ // together, so letting Start upload a logo would also sell it
+ // a domain.
+ //
+ // A rule this commercial belongs where the owner can see and
+ // change it, next to the price it gates. Read only by
+ // App\Services\Billing\CustomDomainAccess.
+ 'unavailable_on' => ['start'],
+ ],
],
// Feature bullets moved onto plan_versions.features, so that what a version
@@ -138,7 +165,13 @@ return [
'dns' => [
'provider' => 'hetzner',
'token' => env('HETZNER_DNS_TOKEN', ''),
- 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.com'),
+ // Customer instances live on their own registrable domain, NOT on the
+ // one that serves the portal and the console. A Nextcloud is
+ // third-party software that strangers sign in to; on the same
+ // registrable domain as the portal it shares cookie scope,
+ // document.domain and CAA with it. Same reasoning as
+ // googleusercontent.com or vercel.app.
+ 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'),
// Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns
// container's dnsmasq --hostsdir. NOT the public Hetzner zone above:
diff --git a/lang/de/auth.php b/lang/de/auth.php
index e3e7af0..c964cb1 100644
--- a/lang/de/auth.php
+++ b/lang/de/auth.php
@@ -59,4 +59,7 @@ return [
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
'account_closed' => 'Dieses Konto wurde geschlossen.',
'not_an_operator' => 'Dieses Konto hat keinen Zugang zur Konsole.',
+
+ 'phishing_note' => 'Sie sind gerade auf :host. Wir fragen Sie nie per E-Mail oder Telefon nach Ihrem Passwort.',
+ 'phishing_link' => 'Echte Adressen erkennen',
];
diff --git a/lang/de/mail.php b/lang/de/mail.php
index 9b7d14c..ad97d6a 100644
--- a/lang/de/mail.php
+++ b/lang/de/mail.php
@@ -14,4 +14,7 @@ return [
// Der Unterschied zwischen einer Werbemail und einer Kontomail, ausdrücklich
// gesagt: für diese gibt es keinen Abmeldelink, und das ist zulässig.
'transactional_note' => 'Diese Nachricht betrifft Ihr Konto und wird unabhängig von Werbeeinstellungen versendet.',
+
+ 'domains_note' => 'Wir bitten Sie nie per E-Mail um Ihr Passwort. Offiziell sind nur :domains —',
+ 'domains_link' => 'echte Adressen erkennen',
];
diff --git a/lang/de/security.php b/lang/de/security.php
new file mode 100644
index 0000000..fa8eded
--- /dev/null
+++ b/lang/de/security.php
@@ -0,0 +1,43 @@
+ 'Sicherheit',
+ 'title' => 'Echte Adressen erkennen',
+ 'lead' => 'Betrüger bauen Anmeldeseiten nach und schicken Links, die aussehen wie unsere. Sie können sich das ersparen, indem Sie eine einzige Sache prüfen: die Adresse in der Adresszeile.',
+
+ 'domains_title' => 'Diese Domains gehören uns',
+ 'domains_body' => 'Alles andere ist nicht von uns — auch dann nicht, wenn Logo, Farben und Text stimmen. Ein Nachbau ist in zehn Minuten gemacht.',
+ 'domain_company' => 'Website, Kundenbereich und Rechnungen',
+ 'domain_instances' => 'Ihre Cloud selbst',
+ 'domains_rule' => "Die Regel, die immer trägt: Was in der Adresszeile vor dem ersten Schrägstrich steht, muss auf eine dieser Domains enden. „clupilot.com.beispiel.tld“ endet auf beispiel.tld und gehört jemand anderem.",
+
+ 'hosts_title' => 'Adressen, die Sie tatsächlich sehen',
+ 'host_site' => 'unsere Website',
+ 'host_portal' => 'Anmeldung und Kundenbereich',
+ 'host_status' => 'Betriebsstatus',
+ 'host_instance' => 'Ihre eigene Cloud (Ihr Name steht vorne)',
+
+ 'never_title' => 'Was wir nie tun',
+ 'never_password' => 'Wir fragen Sie nie nach Ihrem Passwort — nicht per E-Mail, nicht am Telefon, auch nicht wenn es dringend klingt. Wir kennen es nicht und brauchen es nicht.',
+ 'never_card' => 'Wir schicken Ihnen nie eine E-Mail mit der Bitte, Zahlungsdaten „zu bestätigen“ oder erneut einzugeben. Rechnungen liegen in Ihrem Kundenbereich.',
+ 'never_remote' => 'Wir bitten Sie nie unaufgefordert, ein Fernwartungsprogramm zu installieren oder uns Ihren Bildschirm freizugeben. Wenn Sie uns anrufen, sprechen wir darüber — nie umgekehrt.',
+ 'never_urgency' => 'Wir setzen Ihnen keine Fristen von wenigen Stunden und drohen nicht mit Löschung. Zeitdruck ist das häufigste Werkzeug in solchen Nachrichten.',
+
+ 'compromised_title' => 'Sie haben Ihr Passwort auf einer fremden Seite eingegeben?',
+ 'compromised_lead' => 'Dann handeln Sie jetzt, in dieser Reihenfolge. Das ist unangenehm, aber es ist reparierbar — und je früher, desto vollständiger.',
+
+ 'step_change' => 'Passwort ändern',
+ 'step_change_body' => 'Melden Sie sich bei uns an — aber tippen Sie die Adresse selbst ein, klicken Sie nicht auf einen Link aus einer E-Mail — und vergeben Sie unter Einstellungen ein neues Passwort.',
+ 'step_change_action' => 'Zur Anmeldung',
+
+ 'step_sessions' => 'Alle anderen Sitzungen beenden',
+ 'step_sessions_body' => 'Unter „Angemeldete Geräte“ sehen Sie jede offene Anmeldung. Beenden Sie alle außer der aktuellen: wer Ihr Passwort hatte, ist damit draußen, auch wenn er noch angemeldet war.',
+
+ 'step_others' => 'Dasselbe Passwort anderswo ändern',
+ 'step_others_body' => 'Wenn Sie dieses Passwort auch woanders benutzen, ändern Sie es dort ebenfalls. Angreifer probieren gestohlene Zugangsdaten reihum bei E-Mail-Anbietern und Banken.',
+
+ 'step_tell' => 'Sagen Sie uns Bescheid',
+ 'step_tell_body' => 'Wir sehen dann nach, ob mit Ihrem Zugang etwas passiert ist, und können die Anmeldungen für Sie prüfen. Es ist uns lieber, Sie melden sich einmal zu oft.',
+
+ 'tell_us' => 'Unsicher, ob eine Nachricht echt ist? Schicken Sie sie uns weiter, bevor Sie klicken. Das kostet Sie zwei Minuten und uns nichts.',
+];
diff --git a/lang/en/auth.php b/lang/en/auth.php
index ecce6f2..ae5445a 100644
--- a/lang/en/auth.php
+++ b/lang/en/auth.php
@@ -59,4 +59,7 @@ return [
'account_suspended' => 'Your account is suspended. Please contact support.',
'account_closed' => 'This account has been closed.',
'not_an_operator' => 'This account has no access to the console.',
+
+ 'phishing_note' => 'You are currently on :host. We never ask for your password by email or on the phone.',
+ 'phishing_link' => 'How to recognise our addresses',
];
diff --git a/lang/en/mail.php b/lang/en/mail.php
index e80712a..2c54f53 100644
--- a/lang/en/mail.php
+++ b/lang/en/mail.php
@@ -14,4 +14,7 @@ return [
// The difference between a marketing mail and an account mail, said out
// loud: this one has no unsubscribe link, and that is allowed.
'transactional_note' => 'This message concerns your account and is sent regardless of marketing preferences.',
+
+ 'domains_note' => 'We never ask for your password by email. Only :domains are ours —',
+ 'domains_link' => 'how to recognise our addresses',
];
diff --git a/lang/en/security.php b/lang/en/security.php
new file mode 100644
index 0000000..b2cb96d
--- /dev/null
+++ b/lang/en/security.php
@@ -0,0 +1,43 @@
+ 'Security',
+ 'title' => 'Recognising our real addresses',
+ 'lead' => 'Fraudsters rebuild sign-in pages and send links that look like ours. You can spare yourself all of it by checking one thing: the address in the address bar.',
+
+ 'domains_title' => 'These domains are ours',
+ 'domains_body' => 'Anything else is not from us — not even when the logo, the colours and the wording are right. A copy takes ten minutes to make.',
+ 'domain_company' => 'Website, customer area and invoices',
+ 'domain_instances' => 'Your cloud itself',
+ 'domains_rule' => "The rule that always holds: whatever stands before the first slash in the address bar must END with one of these domains. “clupilot.com.example.tld” ends with example.tld and belongs to somebody else.",
+
+ 'hosts_title' => 'Addresses you will actually see',
+ 'host_site' => 'our website',
+ 'host_portal' => 'sign-in and customer area',
+ 'host_status' => 'service status',
+ 'host_instance' => 'your own cloud (your name goes in front)',
+
+ 'never_title' => 'What we never do',
+ 'never_password' => 'We never ask for your password — not by email, not on the phone, not even when it sounds urgent. We do not know it and do not need it.',
+ 'never_card' => 'We never email you asking to “confirm” or re-enter payment details. Invoices are in your customer area.',
+ 'never_remote' => 'We never ask you out of the blue to install remote-access software or share your screen. If you call us, we discuss it — never the other way round.',
+ 'never_urgency' => 'We do not give you deadlines of a few hours or threaten deletion. Time pressure is the commonest tool in messages like that.',
+
+ 'compromised_title' => 'Did you enter your password on a page that was not ours?',
+ 'compromised_lead' => 'Then act now, in this order. It is unpleasant, but it is repairable — and the sooner, the more completely.',
+
+ 'step_change' => 'Change your password',
+ 'step_change_body' => 'Sign in with us — but type the address yourself, do not click a link from an email — and set a new password under Settings.',
+ 'step_change_action' => 'Go to sign-in',
+
+ 'step_sessions' => 'End every other session',
+ 'step_sessions_body' => 'Under “Signed-in devices” you can see every open session. End all but the current one: whoever had your password is out, even if they were still signed in.',
+
+ 'step_others' => 'Change the same password elsewhere',
+ 'step_others_body' => 'If you use this password anywhere else, change it there too. Attackers try stolen credentials in turn at email providers and banks.',
+
+ 'step_tell' => 'Tell us',
+ 'step_tell_body' => 'We will check whether anything happened to your account and can go through the sign-ins with you. We would much rather hear from you once too often.',
+
+ 'tell_us' => 'Not sure whether a message is genuine? Forward it to us before you click. It costs you two minutes and us nothing.',
+];
diff --git a/resources/views/components/layouts/site.blade.php b/resources/views/components/layouts/site.blade.php
index d08cb57..fdd08e8 100644
--- a/resources/views/components/layouts/site.blade.php
+++ b/resources/views/components/layouts/site.blade.php
@@ -100,6 +100,7 @@
Datenschutz
AGB
Betriebsstatus
+ Echte Adressen erkennen
Anmelden
diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php
index a16e970..3420f0e 100644
--- a/resources/views/components/mail/layout.blade.php
+++ b/resources/views/components/mail/layout.blade.php
@@ -69,6 +69,10 @@
·
{{ __('mail.terms') }}
+
+ {{ __('mail.domains_note', ['domains' => implode(' · ', array_keys(App\Support\OfficialDomains::registrable()))]) }}
+ {{ __('mail.domains_link') }}
+
{{ __('mail.sender_line') }}
{{ __('mail.transactional_note') }}
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php
index c40cd16..4a8568d 100644
--- a/resources/views/livewire/auth/login.blade.php
+++ b/resources/views/livewire/auth/login.blade.php
@@ -29,6 +29,13 @@
{{ __('auth.sign_in') }}
+
+ {{-- The one line that belongs on a sign-in page: this IS the page
+ a phishing kit copies, so the real one says how to tell. --}}
+
+ {{ __('auth.phishing_note', ['host' => request()->getHost()]) }}
+ {{ __('auth.phishing_link') }}
+
{{ __('auth.no_account') }}
{{ __('auth.sign_up') }}
diff --git a/resources/views/security.blade.php b/resources/views/security.blade.php
new file mode 100644
index 0000000..7ffd15b
--- /dev/null
+++ b/resources/views/security.blade.php
@@ -0,0 +1,93 @@
+{{--
+ "Which addresses are really ours, and what to do if you fell for one that
+ was not."
+
+ A public page rather than a paragraph buried in the portal: somebody who
+ has just typed their password into a copy of our sign-in form is not signed
+ in anywhere, and the page they need has to be reachable without an account.
+ It is linked from every mail footer, from the sign-in form and from the
+ site footer — the three places a person is when the question comes up.
+
+ The domain list is derived from configuration, never typed. A stale list
+ here would tell a customer that a phishing domain is one of ours.
+--}}
+
+
+
+ {{ __('security.eyebrow') }}
+ {{ __('security.title') }}
+ {{ __('security.lead') }}
+
+ {{-- ── The addresses ────────────────────────────────────────────────── --}}
+
+ {{ __('security.domains_title') }}
+ {{ __('security.domains_body') }}
+
+
+ @foreach (App\Support\OfficialDomains::registrable() as $domain => $role)
+
+
+ {{ $domain }}
+ {{ __('security.domain_'.$role) }}
+
+ @endforeach
+
+
+ {{ __('security.domains_rule') }}
+
+
+
{{ __('security.hosts_title') }}
+
+ @foreach (App\Support\OfficialDomains::hosts() as $entry)
+ -
+ {{ $entry['host'] }}
+ {{ __('security.host_'.$entry['what']) }}
+
+ @endforeach
+
+
+
+
+ {{-- ── What we never do ─────────────────────────────────────────────── --}}
+
+ {{ __('security.never_title') }}
+
+ @foreach (['password', 'card', 'remote', 'urgency'] as $never)
+
+
+
{{ __('security.never_'.$never) }}
+
+ @endforeach
+
+
+
+ {{-- ── What to do ───────────────────────────────────────────────────── --}}
+
+ {{ __('security.compromised_title') }}
+ {{ __('security.compromised_lead') }}
+
+
+ @foreach (['change', 'sessions', 'others', 'tell'] as $i => $step)
+ -
+ {{ $i + 1 }}
+
+
{{ __('security.step_'.$step) }}
+
{{ __('security.step_'.$step.'_body') }}
+ @if ($step === 'change')
+
{{ __('security.step_change_action') }}
+ @endif
+
+
+ @endforeach
+
+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 18e8f3a..5545d03 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -180,6 +180,11 @@ $publicSite = function () {
// Generated, not a static file: while the site is hidden this has to say
// so, and a crawler that gets a 404 here simply crawls anyway.
+ // Reachable WITHOUT an account, deliberately: somebody who has just typed
+ // their password into a copy of our sign-in form is not signed in anywhere,
+ // and the page they need cannot be behind the thing they lost.
+ Route::get('/sicherheit', fn () => view('security'))->name('security');
+
Route::get('/robots.txt', function () {
$body = App\Support\Settings::bool('site.public', true)
? "User-agent: *\nAllow: /\n"
diff --git a/tests/Feature/OfficialDomainsTest.php b/tests/Feature/OfficialDomainsTest.php
new file mode 100644
index 0000000..07e75a3
--- /dev/null
+++ b/tests/Feature/OfficialDomainsTest.php
@@ -0,0 +1,79 @@
+set('admin_access.site_hosts', ['www.clupilot.com', 'clupilot.com']);
+ config()->set('admin_access.app_host', 'app.clupilot.com');
+ config()->set('admin_access.status_host', 'status.clupilot.com');
+ Settings::set('provisioning.dns_zone', 'clupilot.cloud');
+});
+
+it('names both registrable domains, and says what each is for', function () {
+ // Keyed by domain and valued by role, so the page never works the role out
+ // from the order the list happens to come back in.
+ expect(OfficialDomains::registrable())->toBe([
+ 'clupilot.com' => 'company',
+ 'clupilot.cloud' => 'instances',
+ ]);
+});
+
+it('follows the configuration rather than a list somebody typed', function () {
+ // The whole point. An installation moved to another zone must not go on
+ // vouching for the old one.
+ Settings::set('provisioning.dns_zone', 'kunden.example.test');
+
+ expect(array_keys(OfficialDomains::registrable()))->toContain('example.test')
+ ->and(array_keys(OfficialDomains::registrable()))->not->toContain('clupilot.cloud');
+});
+
+it('shows the instance zone as a pattern, never one customer’s address', function () {
+ $hosts = collect(OfficialDomains::hosts())->pluck('host');
+
+ expect($hosts)->toContain('*.clupilot.cloud');
+});
+
+it('does not print where the operator console lives', function () {
+ // A public page listing the operator interface is a public page telling an
+ // attacker where to aim.
+ config()->set('admin_access.host', 'admin.clupilot.com');
+
+ $hosts = collect(OfficialDomains::hosts())->pluck('host');
+
+ expect($hosts)->not->toContain('admin.clupilot.com');
+});
+
+it('serves the page to somebody who is not signed in', function () {
+ // Somebody who has just given their password to a copy of our sign-in form
+ // is signed in nowhere. The page they need cannot be behind the thing they
+ // lost.
+ $this->get('/sicherheit')
+ ->assertOk()
+ ->assertSee('clupilot.com')
+ ->assertSee('clupilot.cloud')
+ ->assertSee(__('security.compromised_title'));
+});
+
+it('carries the domains in every mail footer', function () {
+ // The mail is the vector, so the line travels with the mail.
+ $layout = Illuminate\Support\Facades\File::get(resource_path('views/components/mail/layout.blade.php'));
+
+ expect($layout)->toContain('OfficialDomains::registrable()')
+ ->toContain("route('security')");
+});
+
+it('says on the sign-in form which host it is', function () {
+ // This is the page a phishing kit copies, so the real one says how to tell.
+ $this->get(route('login'))
+ ->assertOk()
+ ->assertSee(__('auth.phishing_link'));
+});