Files
denkfabrik-li f424fe5365 Decide what is an API request from the route, not from the caller's headers
Two places asked "is this the API?" and got it wrong in opposite ways.

EnsureCapability asked $request->expectsJson(). Whether a feature exists
in this installation's edition is a property of the installation, not of
what the caller is willing to parse, so the same route answered
differently per header: `Accept: application/json` got the 403
`capability_unavailable` routes/api.php promises, `Accept: */*` -- curl's
default -- got a bare 404 `not_found`. The mirror image is worse: an
Inertia visit to a capability-gated *web* screen accepts JSON, so it got
403 with Laravel's default error body, naming the exception class, where
the point of the 404 is that an unavailable feature is absent rather than
teased.

ProblemDetails asked $request->is('api/*'). Two staff pages live under
that prefix -- the API dashboard at /api and the OpenAPI reference at
/api/docs, both registered in routes/web.php -- so a signed-out visitor to
either got 401 problem+json, "Send a valid API token in the Authorization
header as \"Bearer <token>\"", instead of the login redirect every other
page gives them.

Both now ask App\Support\ApiSurface: under the API prefix, and not part of
the `web` middleware group. The group is what actually separates the two
-- sessions and CSRF on one side, tokens on the other -- and it keeps
answering correctly for a future /api/v2 without being edited. An
unmatched path has no route to ask, which is the API's answer anyway: a
404 under its prefix is one it should describe in its own format, and the
existing test for that stays green.

Three tests, in the two files that already own these rules. Without the
fix all three go red.
2026-08-28 06:40:44 +02:00

52 lines
1.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Http\Middleware;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
use App\Support\ApiSurface;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Route-level capability enforcement: `->middleware('capability:users.manage')`.
*
* API requests get a machine-readable 403; web requests get a 404 so
* unavailable features are absent, not teased.
*
* Which of the two a request is comes from the route (see ApiSurface), not
* from its Accept header. Whether an endpoint exists in this edition is a
* property of the installation; deciding it from what the caller is
* willing to parse answered the same API route 403 or 404 depending on
* nothing but a header, and routes/api.php promises the 403.
*
* The API half throws CapabilityUnavailable rather than returning a body,
* so the refusal goes through ProblemDetails like every other API error
* instead of being the one response shaped differently from the rest.
*/
class EnsureCapability
{
public function __construct(
private readonly CapabilityRegistry $capabilities,
) {}
public function handle(Request $request, Closure $next, string $capabilityKey): Response
{
$capability = Capability::from($capabilityKey);
if ($this->capabilities->has($capability)) {
return $next($request);
}
if (ApiSurface::matches($request)) {
throw new CapabilityUnavailable($capability, $this->capabilities->edition());
}
abort(404);
}
}