Let the console keep its recovery hostnames without breaking route caching
tests / pest (push) Successful in 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Successful in 4s Details

Registering the console once per accepted hostname reused one set of route
names, and Laravel refuses to serialise two routes under the same name — so
`artisan optimize` failed on the live server the moment the separation was
switched on, mid-deploy.

Only the canonical registration carries the `admin.` names now; the alternates
answer under `admin.viaN.*`. They exist to be MATCHED — they are the addresses
someone locked out reaches for — never to have URLs generated for them, so
route() keeps producing the canonical hostname.

That renaming then broke every exact route-name check, which is how the console
navigation decides what is active: reached through a recovery address, nothing
in the sidebar was marked, exactly when something is already going wrong. The
check goes through AdminArea now and matches either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design tested-20260727-0501-233fc73
Claude 2026-07-27 06:20:44 +02:00
parent 492b1925fb
commit 233fc73430
5 changed files with 81 additions and 9 deletions

View File

@ -86,6 +86,8 @@ class RestrictAdminHost
{
$name = $request->route()?->getName();
// Covers admin.* and the alternate-host registrations (admin.viaN.*),
// which are the same console reached by a recovery hostname.
return $name !== null && str_starts_with($name, 'admin.');
}
}

View File

@ -144,6 +144,23 @@ final class AdminArea
return self::isExclusive() ? '' : self::FALLBACK_PREFIX;
}
/**
* Is the current request on this console route, whichever hostname it came
* through?
*
* The alternates are registered under `admin.viaN.*` so route caching can
* serialise them, which means an exact name check stops matching the moment
* someone reaches the console through a recovery address and the whole
* navigation quietly loses its active state exactly when things are already
* going wrong.
*/
public static function routeIs(string $name): bool
{
$suffix = str_starts_with($name, 'admin.') ? substr($name, 6) : $name;
return (bool) request()->routeIs('admin.'.$suffix, 'admin.via*.'.$suffix);
}
/** Where the console's own front page lives, as a path. */
public static function home(): string
{

View File

@ -41,14 +41,14 @@
['admin.revenue', 'trending-up', 'revenue', null],
] as [$route, $icon, $key, $capability])
@continue($capability !== null && ! auth()->user()?->can($capability))
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
<x-ui.nav-item :href="route($route)" :active="\App\Support\AdminArea::routeIs($route)">
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
{{ __('admin.nav.'.$key) }}
</x-ui.nav-item>
@endforeach
</nav>
<div class="mt-auto space-y-1 border-t border-line pt-4">
<x-ui.nav-item :href="route('admin.settings')" :active="request()->routeIs('admin.settings')">
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
{{ __('admin.nav.settings') }}
</x-ui.nav-item>

View File

@ -30,21 +30,32 @@ use Illuminate\Support\Facades\Route;
| both modes, so nothing that generates a URL has to know which mode it is in.
*/
if (AdminArea::isExclusive()) {
// Once per accepted hostname, and the canonical one LAST.
// Once per accepted hostname, canonical LAST.
//
// ADMIN_HOSTS may list alternates — a bare IP, a second name — and those
// are the recovery paths someone locked out reaches for. Binding only the
// first would make the console 404 through every one of them. Registering
// the canonical host last means route() generates URLs for it, while all of
// them still match.
// first would 404 the console through every one of them.
//
// Only the canonical registration carries the `admin.` names. Route caching
// refuses to serialise two routes under one name, and the alternates exist
// to be MATCHED, never to have URLs generated for them: route() should
// always produce the canonical hostname.
$hosts = AdminArea::hosts();
foreach (array_reverse($hosts) as $host) {
Route::domain($host)
$canonical = array_shift($hosts);
foreach ($hosts as $index => $alternate) {
Route::domain($alternate)
->middleware(['admin.host', 'auth', 'admin'])
->prefix(AdminArea::prefix())
->name('admin.')
->name("admin.via{$index}.")
->group(base_path('routes/admin.php'));
}
Route::domain($canonical)
->middleware(['admin.host', 'auth', 'admin'])
->prefix(AdminArea::prefix())
->name('admin.')
->group(base_path('routes/admin.php'));
} else {
// Shared with the portal: no hostname binding, and the /admin prefix keeps
// the two apart by path.

View File

@ -120,3 +120,45 @@ it('never treats a request as the console just because a hostname is configured'
->and(AdminArea::isConsole(Request::create('http://admin.example.test/cloud')))->toBeFalse()
->and(AdminArea::isConsole(Request::create('http://admin.example.test/admin')))->toBeTrue();
});
it('can cache its routes with several console hostnames configured', function () {
// Registering the console once per accepted hostname reused one set of
// names, and route caching refuses to serialise two routes under one name —
// which took `artisan optimize` down on the live server, mid-deploy.
config()->set('admin_access.hosts', ['admin.example.test', '127.0.0.1', 'localhost']);
config()->set('admin_access.exclusive', true);
$router = new Illuminate\Routing\Router(app('events'), app());
Illuminate\Support\Facades\Route::swap($router);
require base_path('routes/web.php');
$router->getRoutes()->refreshNameLookups();
$names = [];
foreach ($router->getRoutes() as $route) {
if (($name = $route->getName()) !== null) {
expect($names)->not->toContain($name, "duplicate route name: {$name}");
$names[] = $name;
}
}
// The canonical host still owns the plain names, so route() builds URLs
// for it and not for a recovery address.
expect($names)->toContain('admin.overview')
->and($router->getRoutes()->getByName('admin.overview')->getDomain())->toBe('admin.example.test');
});
it('keeps the navigation marked on a recovery hostname', function () {
// The alternates are registered as admin.viaN.* so route caching can
// serialise them — which silently breaks every exact name check, and the
// navigation loses its active state exactly when something is already wrong.
$named = fn (string $name) => tap(request(), fn ($r) => $r->setRouteResolver(
fn () => (new Illuminate\Routing\Route(['GET'], '/', fn () => null))->name($name),
));
$named('admin.overview');
expect(AdminArea::routeIs('admin.overview'))->toBeTrue();
$named('admin.via0.overview');
expect(AdminArea::routeIs('admin.overview'))->toBeTrue()
->and(AdminArea::routeIs('admin.settings'))->toBeFalse();
});