.yaml` paired with a * `user/config/.yaml`. Admin-classic showed those as config tabs * automatically; admin2's API used to reject them because every downstream * handler hardcoded the 6-scope whitelist. * * {@see isCustom()} is the single gate those handlers now share. It deliberately * keys off a *user/environment-authored* blueprint, NOT the merged * `blueprints://config` stream: core ships system blueprints there too (e.g. * `streams.yaml`), and those must never become writable through the generic * config permission. Requiring the blueprint to live under user:// or * environment:// limits custom scopes to ones the site itself defined. */ final class ConfigScopes { /** * Config scopes the API handles with explicit, individually-guarded arms. * Custom scopes can never collide with these — the explicit arms win first. */ public const CORE = ['system', 'site', 'media', 'security', 'scheduler', 'backups']; /** * True when $scope is a site-authored top-level config (the cookbook custom * yaml recipe). * * A valid custom scope is a flat slug (no slashes or dots — this also blocks * path traversal through the `/config/{scope:.+}` route), is not one of the * explicitly-handled CORE scopes, and has its config blueprint under the * user:// or environment:// blueprints stream. */ public static function isCustom(Grav $grav, ?string $scope): bool { if ($scope === null || !preg_match('/^[a-z0-9][a-z0-9_-]*$/', $scope)) { return false; } if (in_array($scope, self::CORE, true)) { return false; } $locator = $grav['locator']; foreach (['user://blueprints/config/', 'environment://blueprints/config/'] as $base) { if ($locator->findResource($base . $scope . '.yaml', true)) { return true; } } return false; } }