Problem
A custom route exposes data or actions that should not be public. You want to require either a logged-in editor user or an API key in a header before the handler runs, and you want the guard to live in one place so every protected route can reuse it.
Recipe
Scriptor's editor sets two session keys on successful login
(loggedin => true, userid => the user id; see
AuthModule::login()).
Read the same SessionStore from a custom route to check whether
the request is signed in:
namespace Acme\Site\Auth;
use Imanager\Http\SessionStore;
use Scriptor\Boot\App;
final class Guard
{
public static function requireEditor(): bool
{
$session = App::container()->get(SessionStore::class);
if ((bool) $session->get('loggedin') === true) {
return true;
}
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'login required'], JSON_THROW_ON_ERROR);
return false;
}
public static function requireApiKey(string $expected): bool
{
$given = $_SERVER['HTTP_X_API_KEY'] ?? '';
if (hash_equals($expected, $given) === true) {
return true;
}
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'bad or missing api key'], JSON_THROW_ON_ERROR);
return false;
}
}
Two guards, two failure modes (401 + JSON for both, since this
recipe pairs with JSON routes). The hash_equals call is the part
that matters; a plain === against an attacker-supplied string
leaks the key length and prefix through timing differences.
Use it at the top of the route handler:
// themes/<your-theme>/routes.php
use Acme\Site\Auth\Guard;
use Bigins\ScriptorSimpleRouter\Request;
use Bigins\ScriptorSimpleRouter\Response;
$router = \Bigins\ScriptorSimpleRouter\Router::instance();
$router->get('/api/admin/stats', static function (Request $req): ?Response {
if (! Guard::requireEditor()) return null;
return Response::json([
'pages' => 42,
'plugins' => 7,
]);
});
$router->post('/api/webhook/build', static function (Request $req): ?Response {
if (! Guard::requireApiKey($_ENV['BUILD_WEBHOOK_KEY'] ?? '')) return null;
// ... handle the webhook
return Response::json(['ok' => true]);
});
The handler returns null when the guard fails; the guard itself
has already written the response, so null tells the router "the
response is already on the wire, do nothing else". Adapt the
null-vs-Response contract to whatever your router expects (the
hand-written Simple Router and the FastRoute-wrap recipe both
treat a null-ish return as "stop here").
Variants
Redirect to the editor login instead of 401
When the protected route serves HTML (not JSON), a redirect is
friendlier than a 401 body:
public static function requireEditorOrRedirect(string $current): bool
{
$session = App::container()->get(SessionStore::class);
if ((bool) $session->get('loggedin') === true) {
return true;
}
$back = '?after=' . rawurlencode($current);
header('Location: /editor/auth/' . $back, true, 302);
return false;
}
The editor's AuthModule does not pick up an after= query
parameter by default; the redirect lands on the editor home after
login. If you need the post-login bounce, that is one of the
small editor-side patches the editor-module-crud recipe walks
through.
Specific role / capability check
If your site stores per-user capabilities, swap the loggedin
check for the capability lookup. Scriptor's stock install only
has one role (admin), so the check is binary; sites with iManager
extensions that add a users collection can pull the user record
and inspect a role or capabilities field:
$userId = (int) $session->get('userid');
$user = $imanager->getItemRepository('users')->getItem($userId);
if (! in_array('api', $user->capabilities ?? [], strict: true)) {
http_response_code(403);
return false;
}
See also
- Controller-with-DI via factory closure:
put the
Guard::requireEditor()call inside the controller's constructor when the whole controller is gated - Wrap FastRoute or league/route in a plugin: league/route's middleware chain is the heavier alternative to this in-handler guard when you have a dozen routes to gate
Editor: the auth surface the editor half of the application exposes; this recipe re-uses the same session keys (loggedin,userid) editor login writes- Concept: Editor extensions:
for the inverse case (a plugin module that mounts inside the
editor and inherits the login gate by being under
/editor/)