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/)