area, status (ok|warn|fail), message */ protected array $results = []; public function handle(): int { $areas = [ 'framework' => fn () => $this->checkFramework(), 'profiles' => fn () => $this->checkProfiles(), 'permissions' => fn () => $this->checkPermissions(), 'white-label' => fn () => $this->checkWhiteLabel(), 'platform' => fn () => $this->checkPlatform(), 'payments' => fn () => $this->checkPayments(), 'search' => fn () => $this->checkSearch(), 'background' => fn () => $this->checkBackground(), ]; $only = array_filter(array_map('trim', explode(',', (string) $this->option('only')))); foreach ($areas as $area => $check) { if ($only && !in_array($area, $only, true)) { continue; } try { $check(); } catch (\Throwable $e) { $this->fail_($area, 'check crashed: ' . get_class($e) . ': ' . $e->getMessage()); } } $symbols = ['ok' => '✓', 'warn' => '!', 'fail' => '✗']; $current = null; foreach ($this->results as [$area, $status, $message]) { if ($area !== $current) { $this->newLine(); $this->line("{$area}"); $current = $area; } $this->line(" {$symbols[$status]} {$message}"); } $counts = array_count_values(array_column($this->results, 1)); $this->newLine(); $this->line(sprintf('%d ok, %d warnings, %d failed', $counts['ok'] ?? 0, $counts['warn'] ?? 0, $counts['fail'] ?? 0)); return ($counts['fail'] ?? 0) > 0 ? self::FAILURE : self::SUCCESS; } protected function ok(string $area, string $message): void { $this->results[] = [$area, 'ok', $message]; } protected function warn_(string $area, string $message): void { $this->results[] = [$area, 'warn', $message]; } protected function fail_(string $area, string $message): void { $this->results[] = [$area, 'fail', $message]; } protected function checkFramework(): void { $this->ok('framework', 'Laravel ' . app()->version() . ', PHP ' . PHP_VERSION); Artisan::call('config:merge', ['--all' => true, '--dry-run' => true]); $output = Artisan::output(); if (preg_match_all('/Found (\d+) new configuration key/', $output, $m)) { $this->warn_('framework', array_sum($m[1]) . ' new config keys are waiting: run php artisan config:merge --all'); } else { $this->ok('framework', 'config files have every key of their templates'); } } protected function checkProfiles(): void { $guards = array_keys(config('auth.guards', [])); $missing = array_diff(['web', 'organization', 'bank', 'admin'], $guards); $missing ? $this->fail_('profiles', 'guards missing: ' . implode(', ', $missing)) : $this->ok('profiles', 'guards web, organization, bank and admin are configured'); foreach ([User::class, Organization::class, Bank::class, Admin::class] as $model) { $count = $model::count(); $name = class_basename($model); if ($count === 0) { $this->warn_('profiles', "no {$name} profiles in the database"); continue; } $model::query()->first() ? $this->ok('profiles', "{$name}: {$count} profiles, loading works") : $this->fail_('profiles', "{$name}: could not load a profile"); } foreach (['getActiveProfile', 'getActiveProfileType', 'timebank_config', 'platform_name', 'theme_email_logo'] as $helper) { if (!function_exists($helper)) { $this->fail_('profiles', "helper {$helper}() is missing"); } } } protected function checkPermissions(): void { // organization_roles is created by `php artisan database:update`, not by a migration $organizationRoles = collect(); if (Schema::hasTable('organization_roles')) { $organizationRoles = OrganizationRole::all()->keyBy('name'); } else { $this->warn_('permissions', 'table organization_roles is missing: run php artisan database:update'); } $definitions = [ 'Admin' => AdminRole::all()->keyBy('name'), 'Organization' => $organizationRoles, 'Bank' => $organizationRoles, ]; $scoped = 0; $legacy = []; $mismatched = []; $undefined = []; foreach (Role::with('permissions')->get() as $role) { $parts = explode('\\', $role->name); if (count($parts) !== 3 || !isset($definitions[$parts[0]])) { continue; // base roles without a scope } if (preg_match(AdminRolesMigrate::LEGACY_PATTERN, $role->name)) { $legacy[] = $role->name; continue; } $scoped++; $definition = $definitions[$parts[0]]->get($parts[2]); if (!$definition) { $undefined[] = $role->name; continue; } $expected = collect($definition->permissions ?? [])->sort()->values()->all(); $actual = $role->permissions->pluck('name')->sort()->values()->all(); if ($expected !== $actual) { $mismatched[] = $role->name; } } if ($mismatched) { $this->fail_('permissions', count($mismatched) . " scoped roles differ from their definition: " . implode(', ', array_slice($mismatched, 0, 5)) . ' (re-sync: see AGENTS.md, Roles and permissions)'); } else { $this->ok('permissions', "{$scoped} scoped roles match admin_roles / organization_roles"); } if ($legacy) { $this->warn_('permissions', count($legacy) . ' legacy roles: ' . implode(', ', array_slice($legacy, 0, 5)) . ' (replace them: php artisan admin-roles:migrate --dry-run, then without --dry-run)'); } if ($undefined) { $this->warn_('permissions', count($undefined) . ' scoped roles have no definition: ' . implode(', ', array_slice($undefined, 0, 5))); } } protected function checkWhiteLabel(): void { $name = (string) config('app.timebank_config'); // The platform folder's config.php replaces config/.php (PlatformServiceProvider) $file = is_file(platform_path('config.php')) ? 'platform/config.php' : "config/{$name}.php"; if ($name === '' || ($file !== 'platform/config.php' && !is_file(config_path("{$name}.php")))) { $this->fail_('white-label', "platform config config/{$name}.php does not exist (TIMEBANK_CONFIG), and there is no platform/config.php"); return; } $this->ok('white-label', "platform config: {$file}"); $platform = platform_name(); if ($platform === '' || $platform === 'Example Timebank') { $this->warn_('white-label', "platform name is '{$platform}': set platform_translations in {$file}"); } else { $this->ok('white-label', "platform name: {$platform}"); } theme_color('brand') ? $this->ok('white-label', 'theme ' . config('themes.active') . ': brand colour ' . theme_color('brand')) : $this->fail_('white-label', 'theme ' . config('themes.active') . ' has no brand colour'); foreach (['local_pages', 'android_app'] as $key) { timebank_config($key) === null ? $this->warn_('white-label', "setting {$key} is missing: run php artisan config:merge {$name}") : $this->ok('white-label', "setting {$key} is present"); } } /** The platform folder and its overrides: the same checks as php artisan platform:check. */ protected function checkPlatform(): void { foreach (app(\App\Services\PlatformOverrides::class)->check() as $finding) { match ($finding['level']) { 'fail' => $this->fail_('platform', $finding['message']), 'warn' => $this->warn_('platform', $finding['message']), default => $this->ok('platform', $finding['message']), }; } } protected function checkPayments(): void { $database = (string) DB::connection()->getDatabaseName(); $grants = collect(DB::select('SHOW GRANTS FOR CURRENT_USER()'))->map(fn ($row) => (string) array_values((array) $row)[0]); $allowed = []; foreach ($grants as $grant) { if (!preg_match('/^GRANT (.+?) ON (\S+)\.(\S+) TO /i', $grant, $m)) { continue; } $privileges = strtoupper($m[1]); $db = str_replace(['`', '\\_', '\\%'], ['', '_', '%'], $m[2]); $table = trim($m[3], '`'); $dbMatches = $db === '*' || $db === $database || fnmatch(str_replace('%', '*', $db), $database); if (!$dbMatches) { continue; } $writes = str_contains($privileges, 'ALL PRIVILEGES') || preg_match('/\b(UPDATE|DELETE)\b/', $privileges); if (!$writes) { continue; } foreach (self::IMMUTABLE_TABLES as $immutable) { if ($table === '*' || $table === $immutable) { $allowed[$immutable] = true; } } } $allowed ? $this->fail_('payments', 'the database user ' . DB::selectOne('SELECT CURRENT_USER() AS u')->u . ' may UPDATE or DELETE ' . implode(', ', array_keys($allowed)) . ': financial records are not protected') : $this->ok('payments', 'transactions, transaction_types and poll_ledger cannot be changed or deleted by the app user'); } protected function checkSearch(): void { $models = ['User', 'Organization', 'Bank', 'Post', 'Call', 'Tag', 'Transaction']; foreach ($models as $model) { $class = "App\\Models\\{$model}"; $raw = $class::search('')->take(1)->raw(); $total = (int) data_get($raw, 'hits.total.value', data_get($raw, 'hits.total', 0)); $index = (new $class)->searchableAs(); if ($total > 0) { $this->ok('search', "{$model}: index {$index} answers, " . ($total >= 10000 ? '10000+' : $total) . ' documents'); } elseif ($this->hasSearchableRows($class)) { $this->fail_('search', "{$model}: index {$index} is empty while the table has rows to index: re-index (scripts/re-index-search.sh)"); } elseif ($class::count() > 0) { $this->ok('search', "{$model}: index {$index} answers (none of the rows may be indexed now, e.g. all expired)"); } else { $this->ok('search', "{$model}: index {$index} answers (no rows yet)"); } } } /** * Whether any row belongs in the index. Calls and posts index only some rows (shouldBeSearchable(): e.g. not * expired), so an empty index is right when none qualifies; on 2026-10-19 every call on the dev site had expired. */ protected function hasSearchableRows(string $class): bool { if (!method_exists($class, 'shouldBeSearchable') || (new \ReflectionMethod($class, 'shouldBeSearchable'))->getDeclaringClass()->getName() !== $class) { return $class::count() > 0; } foreach ($class::query()->lazy() as $row) { if ($row->shouldBeSearchable()) { return true; } } return false; } protected function checkBackground(): void { foreach (self::QUEUES as $queue) { $size = Queue::size($queue); $size > 500 ? $this->warn_('background', "queue {$queue}: {$size} jobs waiting: are the workers running with --queue=" . implode(',', self::QUEUES) . '?') : $this->ok('background', "queue {$queue}: {$size} jobs waiting"); } if (config('broadcasting.default') !== 'reverb') { $this->ok('background', 'broadcasting is not Reverb: not checked'); return; } $host = (string) config('broadcasting.connections.reverb.options.host', '127.0.0.1'); $port = (int) config('broadcasting.connections.reverb.options.port', 8080); $socket = @fsockopen($host, $port, $errno, $error, 2); if ($socket) { fclose($socket); $this->ok('background', "Reverb answers on {$host}:{$port}"); } else { $this->fail_('background', "Reverb does not answer on {$host}:{$port} ({$error})"); } } }