diff --git a/.env.example b/.env.example index 492233af7..34f57d91b 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,12 @@ ANYSTACK_TRIAL_POLICY_ID= FILAMENT_USERS= +# Passport OAuth keys for the admin MCP server (/mcp/oauth/admin, scope mcp:admin). +# Generate with: php artisan passport:keys +# Optionally override with multiline PEM values (use \n in env): +# PASSPORT_PRIVATE_KEY= +# PASSPORT_PUBLIC_KEY= + # Public subscribe endpoint for the newsletter list, and the honeypot field name # configured on it. Both default to the live NativePHP list in config/services.php, # so only set these to point at a different Mailcoach list. diff --git a/app/Enums/TokenAbility.php b/app/Enums/TokenAbility.php new file mode 100644 index 000000000..90a2b4835 --- /dev/null +++ b/app/Enums/TokenAbility.php @@ -0,0 +1,8 @@ +json([ + 'resource' => McpAccessToken::adminResource(), + 'authorization_servers' => [rtrim(config('app.url'), '/')], + 'scopes_supported' => [TokenAbility::AdminMcpServer->value], + 'bearer_methods_supported' => ['header'], + ]); + } + + public function authorizationServer(): JsonResponse + { + $issuer = rtrim(config('app.url'), '/'); + + return response()->json([ + 'issuer' => $issuer, + 'authorization_endpoint' => $issuer.'/oauth/authorize', + 'token_endpoint' => $issuer.'/oauth/token', + 'registration_endpoint' => $issuer.'/oauth/register', + 'response_types_supported' => ['code'], + 'code_challenge_methods_supported' => ['S256'], + 'scopes_supported' => [ + TokenAbility::AdminMcpServer->value, + ], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'token_endpoint_auth_methods_supported' => ['none'], + ]); + } + + public function revoke(Request $request, string $token): Response + { + $tokens = Passport::token()->newQuery()->where('user_id', $request->user()->getAuthIdentifier()); + $connection = (clone $tokens)->findOrFail($token); + + Passport::authCode()->newQuery() + ->where('user_id', $request->user()->getAuthIdentifier()) + ->where('client_id', $connection->client_id) + ->update(['revoked' => true]); + + $tokens->where('client_id', $connection->client_id)->each(function ($accessToken): void { + $accessToken->refreshToken()->update(['revoked' => true]); + $accessToken->revoke(); + }); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/OAuth/RegisterClientController.php b/app/Http/Controllers/OAuth/RegisterClientController.php new file mode 100644 index 000000000..3768e6573 --- /dev/null +++ b/app/Http/Controllers/OAuth/RegisterClientController.php @@ -0,0 +1,40 @@ +getStatusCode() === 201) { + $data = $response->getData(true); + $data['scope'] = TokenAbility::AdminMcpServer->value; + $response->setData($data); + } + + return $response; + } + + protected function isValidRedirectUri(string $value): bool + { + $parts = parse_url($value); + + if ($parts === false || isset($parts['fragment']) || isset($parts['user']) || isset($parts['pass'])) { + return false; + } + + if (($parts['scheme'] ?? null) === 'http' + && ! in_array($parts['host'] ?? null, ['127.0.0.1', '[::1]', 'localhost'], true)) { + return false; + } + + return parent::isValidRedirectUri($value); + } +} diff --git a/app/Http/Middleware/AddMcpOAuthChallenge.php b/app/Http/Middleware/AddMcpOAuthChallenge.php new file mode 100644 index 000000000..18f858405 --- /dev/null +++ b/app/Http/Middleware/AddMcpOAuthChallenge.php @@ -0,0 +1,29 @@ +getStatusCode() === 401) { + $metadataPath = '/.well-known/oauth-protected-resource/mcp/oauth/admin'; + $scope = TokenAbility::AdminMcpServer->value; + + $response->headers->set('WWW-Authenticate', 'Bearer realm="mcp", resource_metadata="' + .rtrim(config('app.url'), '/').$metadataPath.'", scope="'.$scope.'"'); + } + + return $response; + } +} diff --git a/app/Http/Middleware/EnsureAdminMcpOAuthAccess.php b/app/Http/Middleware/EnsureAdminMcpOAuthAccess.php new file mode 100644 index 000000000..76c631c70 --- /dev/null +++ b/app/Http/Middleware/EnsureAdminMcpOAuthAccess.php @@ -0,0 +1,32 @@ +user(); + $token = $user?->currentAccessToken(); + + abort_unless($token instanceof AccessToken, 401); + abort_unless( + $token->can(TokenAbility::AdminMcpServer->value) && $user->isAdmin(), + 403, + 'This connection cannot be used with the NativePHP admin MCP server. Only site admins with the mcp:admin scope may connect.' + ); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureMcpOAuthRequest.php b/app/Http/Middleware/EnsureMcpOAuthRequest.php new file mode 100644 index 000000000..9b2ceb388 --- /dev/null +++ b/app/Http/Middleware/EnsureMcpOAuthRequest.php @@ -0,0 +1,52 @@ +input('resource'); + $expectedScope = is_string($resource) ? McpAccessToken::scopeForResource($resource) : null; + + if ($expectedScope === null) { + return response()->json([ + 'error' => 'invalid_target', + 'error_description' => 'Specify the exact NativePHP admin OAuth MCP resource URL.', + ], 400); + } + + if ($request->isMethod('get') + && ($request->input('code_challenge_method') !== 'S256' + || ! is_string($request->input('code_challenge')) + || ! preg_match('/^[A-Za-z0-9_-]{43}$/D', $request->input('code_challenge')))) { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => 'An S256 PKCE code challenge is required.', + ], 400); + } + + if ($request->isMethod('post') + && ! in_array($request->input('grant_type'), ['authorization_code', 'refresh_token'], true)) { + return response()->json(['error' => 'unsupported_grant_type'], 400); + } + + if ($request->isMethod('get') && ! $request->has('scope')) { + $request->merge(['scope' => $expectedScope]); + } + + if ($request->has('scope') && $request->input('scope') !== $expectedScope) { + return response()->json(['error' => 'invalid_scope'], 400); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/RejectNonAdminMcpOAuthScope.php b/app/Http/Middleware/RejectNonAdminMcpOAuthScope.php new file mode 100644 index 000000000..c7c1d54c1 --- /dev/null +++ b/app/Http/Middleware/RejectNonAdminMcpOAuthScope.php @@ -0,0 +1,59 @@ +user(); + + if ($user && $this->requestsAdminAccess($request) && ! $user->isAdmin()) { + abort(403, 'Only NativePHP site admins may connect the admin MCP server.'); + } + + return $next($request); + } + + private function requestsAdminAccess(Request $request): bool + { + if ($request->input('resource') === McpAccessToken::adminResource()) { + return true; + } + + $scope = $request->input('scope'); + + if (is_string($scope) && $scope === TokenAbility::AdminMcpServer->value) { + return true; + } + + if (! $request->hasSession() || ! $request->session()->has('authRequest')) { + return false; + } + + $authRequest = unserialize($request->session()->get('authRequest')); + + if (! $authRequest instanceof AuthorizationRequestInterface) { + return false; + } + + foreach ($authRequest->getScopes() as $requestedScope) { + if ($requestedScope->getIdentifier() === TokenAbility::AdminMcpServer->value) { + return true; + } + } + + return false; + } +} diff --git a/app/Mcp/Servers/AdminNativePhpServer.php b/app/Mcp/Servers/AdminNativePhpServer.php new file mode 100644 index 000000000..0aa0a7f28 --- /dev/null +++ b/app/Mcp/Servers/AdminNativePhpServer.php @@ -0,0 +1,60 @@ +> + */ + protected array $tools = [ + AdminCreateBlogPost::class, + AdminGetBlogPost::class, + AdminListBlogPosts::class, + AdminListSignups::class, + AdminSearchUsers::class, + AdminGetUser::class, + AdminListCompanies::class, + AdminGetCompany::class, + AdminSearchPlugins::class, + AdminSalesSummary::class, + AdminSearchSupportTickets::class, + AdminGetSupportTicket::class, + ]; +} diff --git a/app/Mcp/Tools/Admin/AdminCreateBlogPost.php b/app/Mcp/Tools/Admin/AdminCreateBlogPost.php new file mode 100644 index 000000000..f1d2af6d9 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminCreateBlogPost.php @@ -0,0 +1,102 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'content' => ['required', 'string'], + 'excerpt' => ['nullable', 'string', 'max:5000'], + 'slug' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + ]); + + $user = $this->user($request); + $slug = $this->uniqueSlug($validated['slug'] ?? Str::slug($validated['title'])); + + $excerpt = $validated['excerpt'] ?? null; + if (! filled($excerpt)) { + $excerpt = str($validated['content'])->stripTags()->squish()->limit(160)->toString(); + } + + $article = new Article([ + 'title' => $validated['title'], + 'slug' => $slug, + 'excerpt' => $excerpt, + 'content' => $validated['content'], + 'published_at' => null, + ]); + $article->author_id = $user->id; + $article->save(); + + $editUrl = null; + + try { + $editUrl = ArticleResource::getUrl('edit', ['record' => $article]); + } catch (\Throwable) { + $editUrl = url('/admin/articles/'.$article->id.'/edit'); + } + + return Response::text($this->toJson([ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'excerpt' => $article->excerpt, + 'published' => false, + 'published_at' => null, + 'author_id' => $article->author_id, + 'admin_edit_url' => $editUrl, + 'preview_url' => route('article', $article), + 'preview_note' => 'Drafts are only visible to signed-in site admins on the public blog route.', + ])); + } + + protected function uniqueSlug(string $slug): string + { + $slug = Str::slug($slug) ?: 'article'; + $base = $slug; + $i = 1; + + while (Article::query()->where('slug', $slug)->exists()) { + $slug = $base.'-'.$i; + $i++; + } + + return $slug; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'title' => $schema->string()->description('Article title.')->required(), + 'content' => $schema->string()->description('Markdown body.')->required(), + 'excerpt' => $schema->string()->description('Optional short excerpt.'), + 'slug' => $schema->string()->description('Optional URL slug. Auto-generated from title when omitted; duplicates get a numeric suffix.'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminGetBlogPost.php b/app/Mcp/Tools/Admin/AdminGetBlogPost.php new file mode 100644 index 000000000..9a5906e02 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminGetBlogPost.php @@ -0,0 +1,85 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['nullable', 'integer', 'min:1'], + 'slug' => ['nullable', 'string', 'max:255'], + ]); + + if (empty($validated['id']) && empty($validated['slug'])) { + return Response::error('Provide id or slug.'); + } + + $article = Article::query() + ->with('author:id,name,email') + ->when(! empty($validated['id']), fn ($q) => $q->where('id', $validated['id'])) + ->when(! empty($validated['slug']), fn ($q) => $q->where('slug', $validated['slug'])) + ->first(); + + if (! $article) { + return Response::error('Article not found.'); + } + + $editUrl = null; + + try { + $editUrl = ArticleResource::getUrl('edit', ['record' => $article]); + } catch (\Throwable) { + $editUrl = url('/admin/articles/'.$article->id.'/edit'); + } + + return Response::text($this->toJson([ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'excerpt' => $article->excerpt, + 'content' => $article->content, + 'published' => $article->isPublished(), + 'published_at' => optional($article->published_at)?->toIso8601String(), + 'author' => [ + 'id' => $article->author?->id, + 'name' => $article->author?->name, + 'email' => $article->author?->email, + ], + 'admin_edit_url' => $editUrl, + 'preview_url' => route('article', $article), + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('Article id.'), + 'slug' => $schema->string()->description('Article slug.'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminGetCompany.php b/app/Mcp/Tools/Admin/AdminGetCompany.php new file mode 100644 index 000000000..b25b524f9 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminGetCompany.php @@ -0,0 +1,70 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255'], + ]); + + $domain = strtolower(trim($validated['domain'])); + + if (! ConsumerEmailDomains::isCompanyDomain($domain)) { + return Response::error("Domain [{$domain}] is treated as a consumer mailbox, not a company."); + } + + $aggregator = app(CompanyAggregator::class); + $users = $aggregator->usersForDomain($domain); + + if ($users->isEmpty()) { + return Response::error("No users found for domain [{$domain}]."); + } + + return Response::text($this->toJson([ + 'domain' => $domain, + 'users_count' => $users->count(), + 'earliest_signup' => optional($users->min('created_at'))?->toIso8601String(), + 'latest_signup' => optional($users->max('created_at'))?->toIso8601String(), + 'users' => $users->map(fn ($user): array => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => optional($user->created_at)?->toIso8601String(), + ])->values()->all(), + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'domain' => $schema->string()->description('Company email domain, e.g. acme.com.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminGetSupportTicket.php b/app/Mcp/Tools/Admin/AdminGetSupportTicket.php new file mode 100644 index 000000000..9d6e094fd --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminGetSupportTicket.php @@ -0,0 +1,84 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['nullable', 'integer', 'min:1'], + 'mask' => ['nullable', 'string', 'max:100'], + ]); + + if (empty($validated['id']) && empty($validated['mask'])) { + return Response::error('Provide id or mask.'); + } + + $ticket = SupportTicket::query() + ->with(['user:id,name,email', 'replies' => fn ($q) => $q->latest('id')->limit(10)]) + ->when(! empty($validated['id']), fn ($q) => $q->where('id', $validated['id'])) + ->when(! empty($validated['mask']), fn ($q) => $q->where('mask', $validated['mask'])) + ->first(); + + if (! $ticket) { + return Response::error('Support ticket not found.'); + } + + return Response::text($this->toJson([ + 'id' => $ticket->id, + 'mask' => $ticket->mask, + 'subject' => $ticket->subject, + 'message' => $ticket->message, + 'status' => $ticket->status?->value ?? $ticket->status, + 'product' => $ticket->product, + 'issue_type' => $ticket->issue_type, + 'user' => [ + 'id' => $ticket->user?->id, + 'name' => $ticket->user?->name, + 'email' => $ticket->user?->email, + ], + 'created_at' => optional($ticket->created_at)?->toIso8601String(), + 'updated_at' => optional($ticket->updated_at)?->toIso8601String(), + 'recent_replies' => $ticket->replies->map(fn ($reply): array => [ + 'id' => $reply->id, + 'note' => (bool) ($reply->note ?? false), + 'pinned' => (bool) ($reply->pinned ?? false), + 'user_id' => $reply->user_id ?? null, + 'created_at' => optional($reply->created_at)?->toIso8601String(), + 'body_excerpt' => str($reply->message ?? '')->limit(500)->toString(), + ])->values()->all(), + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('Ticket id.'), + 'mask' => $schema->string()->description('Public ticket mask/reference.'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminGetUser.php b/app/Mcp/Tools/Admin/AdminGetUser.php new file mode 100644 index 000000000..4fb505ca5 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminGetUser.php @@ -0,0 +1,122 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['nullable', 'integer', 'min:1'], + 'email' => ['nullable', 'email'], + ]); + + if (empty($validated['id']) && empty($validated['email'])) { + return Response::error('Provide id or email.'); + } + + $user = User::query() + ->when(! empty($validated['id']), fn ($q) => $q->where('id', $validated['id'])) + ->when(! empty($validated['email']), fn ($q) => $q->where('email', $validated['email'])) + ->first(); + + if (! $user) { + return Response::error('User not found.'); + } + + $licenses = License::query() + ->where('user_id', $user->id) + ->get(['id', 'policy_name', 'expires_at', 'created_at', 'is_suspended', 'source', 'name']) + ->map(fn (License $license): array => [ + 'id' => $license->id, + 'policy_name' => $license->policy_name, + 'name' => $license->name, + 'source' => $license->source?->value ?? $license->source, + 'is_suspended' => (bool) ($license->is_suspended ?? false), + 'expires_at' => optional($license->expires_at)?->toIso8601String(), + 'created_at' => optional($license->created_at)?->toIso8601String(), + ]) + ->all(); + + $pluginLicenses = PluginLicense::query() + ->with('plugin:id,name,status') + ->where('user_id', $user->id) + ->latest('purchased_at') + ->limit(50) + ->get() + ->map(fn (PluginLicense $license): array => [ + 'id' => $license->id, + 'plugin' => $license->plugin?->name, + 'plugin_status' => $license->plugin?->status?->value, + 'price_paid' => $license->price_paid, + 'currency' => $license->currency, + 'purchased_at' => optional($license->purchased_at)?->toIso8601String(), + 'expires_at' => optional($license->expires_at)?->toIso8601String(), + ]) + ->all(); + + $tickets = SupportTicket::query() + ->where('user_id', $user->id) + ->latest('id') + ->limit(20) + ->get(['id', 'mask', 'subject', 'status', 'product', 'created_at']) + ->map(fn (SupportTicket $ticket): array => [ + 'id' => $ticket->id, + 'mask' => $ticket->mask, + 'subject' => $ticket->subject, + 'status' => $ticket->status?->value ?? $ticket->status, + 'product' => $ticket->product, + 'created_at' => optional($ticket->created_at)?->toIso8601String(), + ]) + ->all(); + + return Response::text($this->toJson([ + 'id' => $user->id, + 'name' => $user->name, + 'display_name' => $user->display_name, + 'email' => $user->email, + 'email_verified_at' => optional($user->email_verified_at)?->toIso8601String(), + 'created_at' => optional($user->created_at)?->toIso8601String(), + 'is_admin' => $user->isAdmin(), + 'github_username' => $user->github_username ?? null, + 'discord_username' => $user->discord_username ?? null, + 'licenses' => $licenses, + 'plugin_licenses' => $pluginLicenses, + 'support_tickets' => $tickets, + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('User id.'), + 'email' => $schema->string()->description('Exact email address.'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminListBlogPosts.php b/app/Mcp/Tools/Admin/AdminListBlogPosts.php new file mode 100644 index 000000000..94afa2d13 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminListBlogPosts.php @@ -0,0 +1,71 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'status' => ['nullable', 'string', 'in:all,published,draft'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $status = $validated['status'] ?? 'all'; + $limit = (int) ($validated['limit'] ?? 25); + + $query = Article::query()->with('author:id,name,email')->latest('id'); + + if ($status === 'published') { + $query->published(); + } elseif ($status === 'draft') { + $query->whereNull('published_at'); + } + + $articles = $query->limit($limit)->get()->map(fn (Article $article): array => [ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'published' => $article->isPublished(), + 'published_at' => optional($article->published_at)?->toIso8601String(), + 'author_email' => $article->author?->email, + ])->all(); + + return Response::text($this->toJson([ + 'status' => $status, + 'count' => count($articles), + 'articles' => $articles, + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'status' => $schema->string()->description('all | published | draft (default all).'), + 'limit' => $schema->integer()->description('Max results (default 25, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminListCompanies.php b/app/Mcp/Tools/Admin/AdminListCompanies.php new file mode 100644 index 000000000..dec6736db --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminListCompanies.php @@ -0,0 +1,60 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'limit' => ['nullable', 'integer', 'min:1', 'max:200'], + 'min_users' => ['nullable', 'integer', 'min:1', 'max:1000'], + ]); + + $limit = (int) ($validated['limit'] ?? 50); + $minUsers = (int) ($validated['min_users'] ?? 1); + + $companies = app(CompanyAggregator::class)->aggregate() + ->filter(fn (array $row): bool => $row['users_count'] >= $minUsers) + ->sortByDesc('users_count') + ->take($limit) + ->values() + ->all(); + + return Response::text($this->toJson([ + 'count' => count($companies), + 'companies' => $companies, + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'limit' => $schema->integer()->description('Max companies (default 50, max 200).'), + 'min_users' => $schema->integer()->description('Minimum users_count (default 1).'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminListSignups.php b/app/Mcp/Tools/Admin/AdminListSignups.php new file mode 100644 index 000000000..0ef969277 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminListSignups.php @@ -0,0 +1,97 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'date' => ['nullable', 'date'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:200'], + ]); + + $tz = 'America/New_York'; + $limit = (int) ($validated['limit'] ?? 100); + + if (! empty($validated['from']) || ! empty($validated['to'])) { + $from = isset($validated['from']) + ? Carbon::parse($validated['from'], $tz)->startOfDay()->utc() + : Carbon::now($tz)->startOfDay()->utc(); + $to = isset($validated['to']) + ? Carbon::parse($validated['to'], $tz)->endOfDay()->utc() + : Carbon::now($tz)->endOfDay()->utc(); + } else { + $day = Carbon::parse($validated['date'] ?? 'today', $tz); + $from = $day->copy()->startOfDay()->utc(); + $to = $day->copy()->endOfDay()->utc(); + } + + $users = User::query() + ->whereBetween('created_at', [$from, $to]) + ->orderBy('created_at') + ->limit($limit) + ->get(['id', 'name', 'email', 'created_at']) + ->map(fn (User $user): array => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'company_domain' => $this->domainFromEmail($user->email), + 'created_at' => $user->created_at?->timezone($tz)->toIso8601String(), + ]) + ->all(); + + return Response::text($this->toJson([ + 'timezone' => $tz, + 'from' => $from->timezone($tz)->toIso8601String(), + 'to' => $to->timezone($tz)->toIso8601String(), + 'count' => count($users), + 'users' => $users, + ])); + } + + protected function domainFromEmail(?string $email): ?string + { + if (! is_string($email) || ! str_contains($email, '@')) { + return null; + } + + return strtolower(substr($email, strrpos($email, '@') + 1)); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'date' => $schema->string()->description('Single calendar day in America/New_York (YYYY-MM-DD). Defaults to today.'), + 'from' => $schema->string()->description('Optional range start day (America/New_York).'), + 'to' => $schema->string()->description('Optional range end day (America/New_York).'), + 'limit' => $schema->integer()->description('Max users (default 100, max 200).'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminSalesSummary.php b/app/Mcp/Tools/Admin/AdminSalesSummary.php new file mode 100644 index 000000000..f40526b64 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminSalesSummary.php @@ -0,0 +1,108 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'days' => ['nullable', 'integer', 'min:1', 'max:365'], + ]); + + $days = (int) ($validated['days'] ?? 30); + $since = now()->subDays($days); + + $pluginRevenue = PluginLicense::query() + ->where('purchased_at', '>=', $since) + ->selectRaw('currency, COUNT(*) as licenses_count, COALESCE(SUM(price_paid), 0) as revenue_cents') + ->groupBy('currency') + ->get() + ->map(fn ($row): array => [ + 'currency' => $row->currency, + 'licenses_count' => (int) $row->licenses_count, + 'revenue_cents' => (int) $row->revenue_cents, + ]) + ->all(); + + $topPluginRows = PluginLicense::query() + ->where('purchased_at', '>=', $since) + ->select('plugin_id', DB::raw('COUNT(*) as licenses_count'), DB::raw('COALESCE(SUM(price_paid), 0) as revenue_cents')) + ->groupBy('plugin_id') + ->orderByDesc('licenses_count') + ->limit(10) + ->get(); + + $plugins = Plugin::query() + ->whereIn('id', $topPluginRows->pluck('plugin_id')) + ->get(['id', 'name', 'status']) + ->keyBy('id'); + + $topPlugins = $topPluginRows->map(function ($row) use ($plugins): array { + $plugin = $plugins->get($row->plugin_id); + + return [ + 'plugin_id' => $row->plugin_id, + 'plugin' => $plugin?->name, + 'status' => $plugin?->status?->value, + 'licenses_count' => (int) $row->licenses_count, + 'revenue_cents' => (int) $row->revenue_cents, + ]; + })->all(); + + $nativeLicenses = License::query() + ->where('created_at', '>=', $since) + ->selectRaw('policy_name, COUNT(*) as count') + ->groupBy('policy_name') + ->orderByDesc('count') + ->get() + ->map(fn ($row): array => [ + 'policy_name' => $row->policy_name, + 'count' => (int) $row->count, + ]) + ->all(); + + return Response::text($this->toJson([ + 'days' => $days, + 'since' => $since->toIso8601String(), + 'plugin_license_revenue' => $pluginRevenue, + 'top_plugins' => $topPlugins, + 'nativephp_licenses_created' => $nativeLicenses, + 'active_nativephp_licenses' => License::query()->whereActive()->count(), + 'note' => 'Amounts are in cents. License keys and Stripe secrets are intentionally omitted.', + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'days' => $schema->integer()->description('Lookback window in days (default 30, max 365).'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminSearchPlugins.php b/app/Mcp/Tools/Admin/AdminSearchPlugins.php new file mode 100644 index 000000000..301d9d1a1 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminSearchPlugins.php @@ -0,0 +1,85 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'query' => ['nullable', 'string', 'max:100'], + 'status' => ['nullable', 'string', 'in:draft,pending,approved,rejected,all'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $limit = (int) ($validated['limit'] ?? 25); + $status = $validated['status'] ?? 'all'; + + $query = Plugin::query() + ->with('user:id,name,email') + ->latest('id'); + + if ($status !== 'all') { + $query->where('status', PluginStatus::from($status)); + } + + if (! empty($validated['query'])) { + $like = '%'.$validated['query'].'%'; + $query->where(function ($builder) use ($like): void { + $builder->where('name', 'like', $like) + ->orWhere('description', 'like', $like); + }); + } + + $plugins = $query->limit($limit)->get()->map(fn (Plugin $plugin): array => [ + 'id' => $plugin->id, + 'name' => $plugin->name, + 'status' => $plugin->status?->value, + 'is_official' => (bool) $plugin->is_official, + 'is_featured' => (bool) ($plugin->featured ?? false), + 'developer_email' => $plugin->user?->email, + 'created_at' => optional($plugin->created_at)?->toIso8601String(), + 'updated_at' => optional($plugin->updated_at)?->toIso8601String(), + ])->all(); + + return Response::text($this->toJson([ + 'status' => $status, + 'query' => $validated['query'] ?? null, + 'count' => count($plugins), + 'plugins' => $plugins, + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string()->description('Optional name/description search.'), + 'status' => $schema->string()->description('draft|pending|approved|rejected|all (default all).'), + 'limit' => $schema->integer()->description('Max results (default 25, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminSearchSupportTickets.php b/app/Mcp/Tools/Admin/AdminSearchSupportTickets.php new file mode 100644 index 000000000..baa60cc23 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminSearchSupportTickets.php @@ -0,0 +1,93 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'query' => ['nullable', 'string', 'max:100'], + 'status' => ['nullable', 'string', 'in:open,in_progress,on_hold,responded,closed'], + 'product' => ['nullable', 'string', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $limit = (int) ($validated['limit'] ?? 25); + + $query = SupportTicket::query() + ->with('user:id,name,email') + ->latest('id'); + + if (! empty($validated['status'])) { + $query->where('status', Status::from($validated['status'])); + } + + if (! empty($validated['product'])) { + $query->where('product', $validated['product']); + } + + if (! empty($validated['query'])) { + $like = '%'.$validated['query'].'%'; + $query->where(function ($builder) use ($like): void { + $builder->where('subject', 'like', $like) + ->orWhere('mask', 'like', $like) + ->orWhere('message', 'like', $like) + ->orWhereHas('user', function ($user) use ($like): void { + $user->where('email', 'like', $like) + ->orWhere('name', 'like', $like); + }); + }); + } + + $tickets = $query->limit($limit)->get()->map(fn (SupportTicket $ticket): array => [ + 'id' => $ticket->id, + 'mask' => $ticket->mask, + 'subject' => $ticket->subject, + 'status' => $ticket->status?->value ?? $ticket->status, + 'product' => $ticket->product, + 'issue_type' => $ticket->issue_type, + 'user_email' => $ticket->user?->email, + 'created_at' => optional($ticket->created_at)?->toIso8601String(), + ])->all(); + + return Response::text($this->toJson([ + 'count' => count($tickets), + 'tickets' => $tickets, + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string()->description('Search subject, mask, message, or user email/name.'), + 'status' => $schema->string()->description('open|in_progress|on_hold|responded|closed'), + 'product' => $schema->string()->description('Product filter, e.g. mobile, desktop, bifrost.'), + 'limit' => $schema->integer()->description('Max results (default 25, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/Admin/AdminSearchUsers.php b/app/Mcp/Tools/Admin/AdminSearchUsers.php new file mode 100644 index 000000000..ae25e9bf2 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminSearchUsers.php @@ -0,0 +1,71 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'query' => ['required', 'string', 'min:2', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + + $like = '%'.$validated['query'].'%'; + $limit = (int) ($validated['limit'] ?? 20); + + $users = User::query() + ->where(function ($builder) use ($like): void { + $builder->where('email', 'like', $like) + ->orWhere('name', 'like', $like) + ->orWhere('display_name', 'like', $like); + }) + ->limit($limit) + ->get(['id', 'name', 'display_name', 'email', 'created_at']) + ->map(fn (User $user): array => [ + 'id' => $user->id, + 'name' => $user->name, + 'display_name' => $user->display_name, + 'email' => $user->email, + 'created_at' => $user->created_at?->toIso8601String(), + ]) + ->all(); + + return Response::text($this->toJson([ + 'query' => $validated['query'], + 'count' => count($users), + 'users' => $users, + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string()->description('Email or name substring.')->required(), + 'limit' => $schema->integer()->description('Max results (default 20, max 50).'), + ]; + } +} diff --git a/app/Mcp/Tools/Concerns/RequiresAdmin.php b/app/Mcp/Tools/Concerns/RequiresAdmin.php new file mode 100644 index 000000000..78c75f3c5 --- /dev/null +++ b/app/Mcp/Tools/Concerns/RequiresAdmin.php @@ -0,0 +1,75 @@ +user(); + } + + protected function ensureAdmin(Request $request): ?Response + { + if (! $this->user($request)?->isAdmin()) { + return Response::error('This tool is only available to NativePHP site admins.'); + } + + return null; + } + + /** + * @param array $payload + */ + protected function toJson(array $payload): string + { + return (string) json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * Strip secret-looking keys from nested arrays before returning metadata. + * + * @param array $data + * @return array + */ + protected function metadataOnly(array $data): array + { + $denied = [ + 'password', + 'remember_token', + 'github_token', + 'key', + 'license_key', + 'plugin_license_key', + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'secret', + 'api_key', + 'token', + ]; + + $clean = []; + + foreach ($data as $key => $value) { + $normalized = strtolower((string) $key); + + if (in_array($normalized, $denied, true) + || str_contains($normalized, 'secret') + || str_contains($normalized, 'password') + || (str_contains($normalized, 'token') && $normalized !== 'token_type') + || str_ends_with($normalized, '_key') && $normalized !== 'key_id') { + continue; + } + + $clean[$key] = is_array($value) ? $this->metadataOnly($value) : $value; + } + + return $clean; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0e401fde6..7ad76c055 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,12 +17,33 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; use Laravel\Cashier\Billable; +use Laravel\Passport\Contracts\ScopeAuthorizable; +use Laravel\Sanctum\Contracts\HasAbilities; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail { use Billable, HasApiTokens, HasFactory, Notifiable; + /** @var HasAbilities|ScopeAuthorizable|null */ + protected $accessToken; + + /** + * Both the Sanctum and Passport guards attach a request token; token + * creation and the tokens() relationship continue to belong to Sanctum. + */ + public function currentAccessToken(): HasAbilities|ScopeAuthorizable|null + { + return $this->accessToken; + } + + public function withAccessToken(HasAbilities|ScopeAuthorizable|null $accessToken): static + { + $this->accessToken = $accessToken; + + return $this; + } + protected $guarded = []; protected $hidden = [ diff --git a/app/Providers/McpOAuthServiceProvider.php b/app/Providers/McpOAuthServiceProvider.php new file mode 100644 index 000000000..a0a309e08 --- /dev/null +++ b/app/Providers/McpOAuthServiceProvider.php @@ -0,0 +1,45 @@ +app->singleton(ResourceServer::class, function ($app): ResourceServer { + $repository = $app->make(AccessTokenRepository::class); + $key = str_replace('\\n', "\n", config('passport.public_key') ?? '') + ?: 'file://'.Passport::keyPath('oauth-public.key'); + + return new ResourceServer( + $repository, + new CryptKey($key, null, Passport::$validateKeyPermissions), + new McpBearerTokenValidator($repository), + ); + }); + } + + public function boot(): void + { + Passport::tokensCan([ + TokenAbility::AdminMcpServer->value => 'NativePHP site-admin MCP access (blog drafts, signups, support, plugins — no secrets)', + ]); + Passport::setDefaultScope([TokenAbility::AdminMcpServer->value]); + Passport::tokensExpireIn(CarbonInterval::hour()); + Passport::refreshTokensExpireIn(CarbonInterval::days(30)); + Passport::useAccessTokenEntity(McpAccessToken::class); + Passport::authorizationView('mcp.authorize'); + } +} diff --git a/app/Support/OAuth/McpAccessToken.php b/app/Support/OAuth/McpAccessToken.php new file mode 100644 index 000000000..c825faa58 --- /dev/null +++ b/app/Support/OAuth/McpAccessToken.php @@ -0,0 +1,86 @@ + + */ + public static function resources(): array + { + return [self::adminResource()]; + } + + public static function resource(): string + { + return self::adminResource(); + } + + public static function resourceForScope(string $scope): ?string + { + return match ($scope) { + TokenAbility::AdminMcpServer->value => self::adminResource(), + default => null, + }; + } + + public static function scopeForResource(string $resource): ?string + { + return match ($resource) { + self::adminResource() => TokenAbility::AdminMcpServer->value, + default => null, + }; + } + + /** + * @param array $scopes + */ + public static function resourceForScopes(array $scopes): string + { + return self::adminResource(); + } + + public function setPrivateKey(#[\SensitiveParameter] CryptKeyInterface $privateKey): void + { + parent::setPrivateKey($privateKey); + $this->signingKey = $privateKey; + } + + public function toString(): string + { + $jwt = Configuration::forAsymmetricSigner( + new Sha256, + InMemory::plainText($this->signingKey->getKeyContents(), $this->signingKey->getPassPhrase() ?? ''), + InMemory::plainText('unused'), + ); + + return $jwt->builder() + ->permittedFor($this->getClient()->getIdentifier(), self::resourceForScopes($this->getScopes())) + ->issuedBy(rtrim(config('app.url'), '/')) + ->identifiedBy($this->getIdentifier()) + ->issuedAt(new DateTimeImmutable) + ->canOnlyBeUsedAfter(new DateTimeImmutable) + ->expiresAt($this->getExpiryDateTime()) + ->relatedTo($this->getUserIdentifier() ?? $this->getClient()->getIdentifier()) + ->withClaim('scopes', $this->getScopes()) + ->getToken($jwt->signer(), $jwt->signingKey()) + ->toString(); + } +} diff --git a/app/Support/OAuth/McpBearerTokenValidator.php b/app/Support/OAuth/McpBearerTokenValidator.php new file mode 100644 index 000000000..3d7144c42 --- /dev/null +++ b/app/Support/OAuth/McpBearerTokenValidator.php @@ -0,0 +1,35 @@ +getHeaderLine('authorization'))); + $token = (new Parser(new JoseEncoder))->parse($jwt); + + if (! $token instanceof UnencryptedToken) { + throw OAuthServerException::accessDenied('An unencrypted access token is required.'); + } + + $claims = $token->claims(); + $audiences = $claims->get('aud', []); + $audiences = is_array($audiences) ? $audiences : [$audiences]; + + if (array_intersect(McpAccessToken::resources(), $audiences) === [] + || $claims->get('iss', null) !== rtrim(config('app.url'), '/')) { + throw OAuthServerException::accessDenied('This access token was not issued for the NativePHP admin OAuth MCP resource.'); + } + + return $validated; + } +} diff --git a/composer.json b/composer.json index 58d6a2e9a..f68d17fd4 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,9 @@ "laravel/cashier": "^15.6", "laravel/framework": "^12.0", "laravel/horizon": "^5.44", + "laravel/mcp": "^0.9.1", "laravel/nightwatch": "^1.21", + "laravel/passport": "^13.0", "laravel/pennant": "^1.18", "laravel/sanctum": "^4.0", "laravel/socialite": "^5.24", @@ -43,7 +45,7 @@ "require-dev": { "driftingly/rector-laravel": "^2.1", "fakerphp/faker": "^1.9.1", - "laravel/boost": "^1.0", + "laravel/boost": "^2.0", "laravel/pint": "^1.0", "laravel/sail": "^1.18", "mockery/mockery": "^1.4.4", diff --git a/composer.lock b/composer.lock index cfda03e45..cb9750019 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8a50c12fcab5d1c61218e30bb3cdd5b2", + "content-hash": "d6c7c20dea72d1f8eb5abc1a59b519c8", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -505,16 +505,16 @@ }, { "name": "carbonphp/carbon-doctrine-types", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", "shasum": "" }, "require": { @@ -529,6 +529,11 @@ "phpunit/phpunit": "^10.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" @@ -554,7 +559,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" }, "funding": [ { @@ -570,7 +575,7 @@ "type": "tidelift" } ], - "time": "2024-02-09T16:56:22+00:00" + "time": "2026-09-06T14:11:38+00:00" }, { "name": "chillerlan/php-qrcode", @@ -982,6 +987,73 @@ ], "time": "2026-03-16T11:29:23+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2072,24 +2144,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -2118,7 +2190,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -2130,26 +2202,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.14.2", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "fa88c57803501ad0770f5cddb1e60525d49da9a1" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/fa88c57803501ad0770f5cddb1e60525d49da9a1", - "reference": "fa88c57803501ad0770f5cddb1e60525d49da9a1", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", - "guzzlehttp/psr7": "^2.12.5", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -2162,7 +2234,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.3", - "guzzlehttp/test-server": "^0.6", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -2242,7 +2314,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.14.2" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -2258,20 +2330,20 @@ "type": "tidelift" } ], - "time": "2026-07-14T18:15:01+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -2326,7 +2398,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -2342,20 +2414,20 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.5", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/9365d578a9fd1552ad6ca9c3cb530708526feb09", - "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { @@ -2445,7 +2517,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.5" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -2461,20 +2533,20 @@ "type": "tidelift" } ], - "time": "2026-07-13T01:27:20+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.9", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d7580af6d3f8384325d9cd3e99b21c3ed1848176", - "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { @@ -2531,7 +2603,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.9" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -2547,7 +2619,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T16:19:22+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "intervention/gif", @@ -2971,16 +3043,16 @@ }, { "name": "laravel/framework", - "version": "v12.64.0", + "version": "v12.69.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0" + "reference": "17d034ef1e209b63a1d3a89c90ce5e89a4eb3fc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0", - "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0", + "url": "https://api.github.com/repos/laravel/framework/zipball/17d034ef1e209b63a1d3a89c90ce5e89a4eb3fc5", + "reference": "17d034ef1e209b63a1d3a89c90ce5e89a4eb3fc5", "shasum": "" }, "require": { @@ -3189,7 +3261,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-07-14T14:25:37+00:00" + "time": "2026-09-08T14:29:09+00:00" }, { "name": "laravel/horizon", @@ -3271,6 +3343,80 @@ }, "time": "2026-06-03T15:11:37+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.9.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "923d8d8cd9ed46766d1a8b5269f8b4afc4c91c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/923d8d8cd9ed46766d1a8b5269f8b4afc4c91c53", + "reference": "923d8d8cd9ed46766d1a8b5269f8b4afc4c91c53", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-09-09T12:56:57+00:00" + }, { "name": "laravel/nightwatch", "version": "v1.28.4", @@ -3365,6 +3511,81 @@ }, "time": "2026-06-30T06:54:16+00:00" }, + { + "name": "laravel/passport", + "version": "v13.7.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "980ed27da1da67408754b7e0c13989fe05327d9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/980ed27da1da67408754b7e0c13989fe05327d9e", + "reference": "980ed27da1da67408754b7e0c13989fe05327d9e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^3.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-07-22T09:24:32+00:00" + }, { "name": "laravel/pennant", "version": "v1.24.0", @@ -3444,16 +3665,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.21", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7753c65c281c2550c7c183f14e18062073b7d821" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", - "reference": "7753c65c281c2550c7c183f14e18062073b7d821", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { @@ -3497,9 +3718,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.21" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2026-06-26T00:11:25+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/sanctum", @@ -3622,16 +3843,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.13", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { @@ -3679,7 +3900,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-16T14:03:50+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "laravel/socialite", @@ -3819,18 +4040,91 @@ }, "time": "2026-02-06T14:12:35+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", - "version": "2.8.3", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", - "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", "shasum": "" }, "require": { @@ -3867,7 +4161,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -3924,7 +4218,7 @@ "type": "tidelift" } ], - "time": "2026-07-12T15:29:16+00:00" + "time": "2026-09-07T13:44:26+00:00" }, { "name": "league/config", @@ -4099,18 +4393,77 @@ ], "time": "2025-12-27T15:18:42+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.36.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "shasum": "" }, "require": { @@ -4178,9 +4531,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-09-02T08:00:27+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -4239,16 +4592,16 @@ }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -4282,9 +4635,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/flysystem-path-prefixing", @@ -4464,6 +4817,103 @@ }, "time": "2024-12-10T19:59:05+00:00" }, + { + "name": "league/oauth2-server", + "version": "9.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/jwt": "^5.6", + "league/event": "^3.0", + "league/uri": "^7.8", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.8", + "paragonie/random_compat": "^9.99.100", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.50", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.27.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2026-06-25T15:24:07+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -5199,16 +5649,16 @@ }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.12.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "72c534fc0ab181ef52d92a68382318631e301608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/72c534fc0ab181ef52d92a68382318631e301608", + "reference": "72c534fc0ab181ef52d92a68382318631e301608", "shasum": "" }, "require": { @@ -5234,6 +5684,7 @@ "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", "rollbar/rollbar": "^4.0", "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", @@ -5252,6 +5703,7 @@ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -5286,7 +5738,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.12.0" }, "funding": [ { @@ -5298,7 +5750,7 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-09-09T08:34:20+00:00" }, { "name": "mtdowling/jmespath.php", @@ -5368,16 +5820,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.1", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { @@ -5469,7 +5921,7 @@ "type": "tidelift" } ], - "time": "2026-07-09T18:23:49+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/php-generator", @@ -5547,16 +5999,16 @@ }, { "name": "nette/schema", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { @@ -5608,22 +6060,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.5" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2026-02-23T03:47:12+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", - "version": "v4.1.4", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { @@ -5643,7 +6095,7 @@ }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", @@ -5699,9 +6151,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.4" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2026-05-11T20:49:54+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nikic/php-parser", @@ -6184,24 +6636,103 @@ "random" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" }, - "time": "2020-10-15T08:29:30+00:00" + "time": "2024-10-02T11:20:13+00:00" }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -6209,7 +6740,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -6251,7 +6782,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -6263,20 +6794,20 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.55", + "version": "3.0.57", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", "shasum": "" }, "require": { @@ -6357,7 +6888,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57" }, "funding": [ { @@ -6373,7 +6904,7 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-08-26T12:13:21+00:00" }, { "name": "pragmarx/google2fa", @@ -6885,6 +7416,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -8342,16 +8986,16 @@ }, { "name": "symfony/console", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "shasum": "" }, "require": { @@ -8416,7 +9060,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.14" + "source": "https://github.com/symfony/console/tree/v7.4.18" }, "funding": [ { @@ -8436,20 +9080,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T11:50:14+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -8485,7 +9129,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -8505,7 +9149,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8580,16 +9224,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "4e1a093b481f323e6e326451f9760c3868430673" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", - "reference": "4e1a093b481f323e6e326451f9760c3868430673", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { @@ -8638,7 +9282,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.14" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -8658,20 +9302,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:22:21+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.1", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { @@ -8724,7 +9368,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -8744,7 +9388,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T12:28:30+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -8899,16 +9543,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { @@ -8943,7 +9587,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -8963,7 +9607,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/html-sanitizer", @@ -9140,16 +9784,16 @@ }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -9198,7 +9842,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -9218,20 +9862,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", - "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", "shasum": "" }, "require": { @@ -9280,7 +9924,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" }, "funding": [ { @@ -9300,20 +9944,20 @@ "type": "tidelift" } ], - "time": "2026-06-11T07:31:44+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", - "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", "shasum": "" }, "require": { @@ -9371,7 +10015,7 @@ "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -9399,7 +10043,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" }, "funding": [ { @@ -9419,20 +10063,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:14:35+00:00" + "time": "2026-08-30T21:24:29+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { @@ -9483,7 +10127,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.14" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -9503,7 +10147,7 @@ "type": "tidelift" } ], - "time": "2026-06-13T08:51:35+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mailgun-mailer", @@ -9580,16 +10224,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.13", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { @@ -9613,7 +10257,7 @@ "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -9645,7 +10289,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.13" + "source": "https://github.com/symfony/mime/tree/v7.4.18" }, "funding": [ { @@ -9665,7 +10309,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:22:37+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/options-resolver", @@ -9823,16 +10467,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -9881,7 +10525,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -9901,7 +10545,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-icu", @@ -9993,16 +10637,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -10056,7 +10700,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -10076,20 +10720,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -10141,7 +10785,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -10161,7 +10805,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -10334,16 +10978,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -10390,7 +11034,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -10410,7 +11054,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php84", @@ -10494,16 +11138,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -10550,7 +11194,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -10570,7 +11214,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", @@ -10657,16 +11301,16 @@ }, { "name": "symfony/process", - "version": "v7.4.13", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", "shasum": "" }, "require": { @@ -10698,7 +11342,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/process/tree/v7.4.18" }, "funding": [ { @@ -10718,7 +11362,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -10809,16 +11453,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.13", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { @@ -10870,7 +11514,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.13" + "source": "https://github.com/symfony/routing/tree/v7.4.18" }, "funding": [ { @@ -10890,20 +11534,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -10957,7 +11601,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -10977,20 +11621,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -11047,7 +11691,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -11067,20 +11711,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/translation", - "version": "v8.1.1", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "342b4218630dc2cf284cedcb2080c80b13404014" + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", - "reference": "342b4218630dc2cf284cedcb2080c80b13404014", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", "shasum": "" }, "require": { @@ -11140,7 +11784,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.1" + "source": "https://github.com/symfony/translation/tree/v8.1.5" }, "funding": [ { @@ -11160,7 +11804,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:11:44+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/translation-contracts", @@ -11246,16 +11890,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { @@ -11300,7 +11944,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -11320,20 +11964,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { @@ -11349,7 +11993,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -11387,7 +12031,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -11407,32 +12051,32 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.14", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -11463,7 +12107,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.14" + "source": "https://github.com/symfony/yaml/tree/v8.1.6" }, "funding": [ { @@ -11483,7 +12127,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11731,23 +12375,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -11791,7 +12435,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -11799,7 +12443,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -11811,7 +12455,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", @@ -11889,6 +12533,83 @@ } ], "packages-dev": [ + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "driftingly/rector-laravel", "version": "2.5.0", @@ -12112,33 +12833,33 @@ }, { "name": "laravel/boost", - "version": "v1.8.13", + "version": "v2.8.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "cdcb12114315491f72a2cecb5130d8b9dffa0103" + "reference": "820bc93b7826c456ca591cfecca463b4dc794a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/cdcb12114315491f72a2cecb5130d8b9dffa0103", - "reference": "cdcb12114315491f72a2cecb5130d8b9dffa0103", + "url": "https://api.github.com/repos/laravel/boost/zipball/820bc93b7826c456ca591cfecca463b4dc794a72", + "reference": "820bc93b7826c456ca591cfecca463b4dc794a72", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", - "laravel/mcp": "^0.5.1", - "laravel/prompts": "0.1.25|^0.3.6", - "laravel/roster": "^0.2.9", - "php": "^8.1" + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.20.0", + "laravel/pint": "^1.27.0", "mockery/mockery": "^1.6.12", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" @@ -12174,80 +12895,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-27T17:24:01+00:00" - }, - { - "name": "laravel/mcp", - "version": "v0.5.9", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129", - "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2" - }, - "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-02-17T19:05:53+00:00" + "time": "2026-09-10T15:25:39+00:00" }, { "name": "laravel/pint", @@ -12319,32 +12967,33 @@ }, { "name": "laravel/roster", - "version": "v0.2.9", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2", - "symfony/yaml": "^6.4|^7.2" + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { - "laravel/pint": "^1.14", + "laravel/pint": "^1.29", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" }, "type": "library", "extra": { @@ -12376,7 +13025,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-10-20T09:56:46+00:00" + "time": "2026-07-18T17:53:15+00:00" }, { "name": "laravel/sail", diff --git a/config/app.php b/config/app.php index 575c4f067..36f8a1d36 100644 --- a/config/app.php +++ b/config/app.php @@ -5,6 +5,7 @@ use App\Providers\EventServiceProvider; use App\Providers\Filament\AdminPanelProvider; use App\Providers\HorizonServiceProvider; +use App\Providers\McpOAuthServiceProvider; use App\Providers\RouteServiceProvider; use Illuminate\Support\Facades\Facade; use Illuminate\Support\ServiceProvider; @@ -170,6 +171,7 @@ * Application Service Providers... */ AppServiceProvider::class, + McpOAuthServiceProvider::class, AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, EventServiceProvider::class, diff --git a/config/auth.php b/config/auth.php index 5888aea4d..6526e753a 100644 --- a/config/auth.php +++ b/config/auth.php @@ -42,6 +42,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'oauth' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* diff --git a/config/mcp.php b/config/mcp.php new file mode 100644 index 000000000..6292c5230 --- /dev/null +++ b/config/mcp.php @@ -0,0 +1,70 @@ + [ + '*', + // 'https://example.com', + // 'http://localhost', + ], + + /* + |-------------------------------------------------------------------------- + | Allowed Custom Schemes + |-------------------------------------------------------------------------- + | + | Native desktop OAuth clients like Cursor and VS Code use private-use URI + | schemes (RFC 8252) for redirect callbacks instead of standard schemes + | like HTTPS. Here, you may list which custom schemes you will allow. + | + */ + + 'custom_schemes' => [ + 'cursor', + 'vscode', + 'claude', + ], + + /* + |-------------------------------------------------------------------------- + | Authorization Server + |-------------------------------------------------------------------------- + | + | Here you may configure the OAuth authorization server issuer identifier + | per RFC 8414. This value appears in your protected resource and auth + | server metadata endpoints. When null, this defaults to `url('/')`. + | + */ + + 'authorization_server' => null, + + /* + |-------------------------------------------------------------------------- + | Tool Search + |-------------------------------------------------------------------------- + | + | Here you may configure the limits enforced during tool search. The maximum + | number of tool calls limits how many tools each search request can run + | while the maximum output bytes value caps the size of every result. + | + */ + + 'tool_search' => [ + 'max_tool_calls' => 10, + 'max_output_bytes' => 65_536, + ], + +]; diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 000000000..aed435890 --- /dev/null +++ b/config/passport.php @@ -0,0 +1,48 @@ + 'web', + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + +]; diff --git a/database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php b/database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php new file mode 100644 index 000000000..c700b50e8 --- /dev/null +++ b/database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php b/database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php new file mode 100644 index 000000000..3e50f7f76 --- /dev/null +++ b/database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php b/database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php new file mode 100644 index 000000000..afb3c55c9 --- /dev/null +++ b/database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php @@ -0,0 +1,37 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2016_06_01_000004_create_oauth_clients_table.php b/database/migrations/2016_06_01_000004_create_oauth_clients_table.php new file mode 100644 index 000000000..9794dc860 --- /dev/null +++ b/database/migrations/2016_06_01_000004_create_oauth_clients_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->nullableMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php b/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php new file mode 100644 index 000000000..ea078319c --- /dev/null +++ b/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/docs/internal/admin-mcp-oauth.md b/docs/internal/admin-mcp-oauth.md new file mode 100644 index 000000000..5ba537c69 --- /dev/null +++ b/docs/internal/admin-mcp-oauth.md @@ -0,0 +1,33 @@ +# NativePHP Admin MCP (OAuth) + +Internal note — this is **not** the public docs MCP. + +## Endpoints + +| Purpose | URL | +| --- | --- | +| Admin MCP (SSE/HTTP) | `/mcp/oauth/admin` | +| Protected resource metadata | `/.well-known/oauth-protected-resource/mcp/oauth/admin` | +| Authorization server metadata | `/.well-known/oauth-authorization-server` | +| Dynamic client registration | `POST /oauth/register` | + +**Scope:** `mcp:admin` (site admins only — `User::isAdmin()` / `FILAMENT_USERS`). + +The public docs MCP at `/api/mcp/message` stays unauthenticated and unchanged. + +## Connect (Cursor etc.) + +1. Ensure Passport keys exist: `php artisan passport:keys` (or set `PASSPORT_PRIVATE_KEY` / `PASSPORT_PUBLIC_KEY`). +2. Point the MCP client at `https://nativephp.com/mcp/oauth/admin` (or your local Herd URL). +3. Complete the OAuth PKCE flow when prompted; approve only while signed in as a Filament admin. +4. Clients discover auth via the well-known protected-resource document (`scope=mcp:admin`). + +## Tools (v1) + +- Blog: `admin-create-blog-post` (unpublished only), `admin-get-blog-post`, `admin-list-blog-posts` +- Users: `admin-list-signups`, `admin-search-users`, `admin-get-user` +- Companies: `admin-list-companies`, `admin-get-company` +- Plugins/sales: `admin-search-plugins`, `admin-sales-summary` +- Support: `admin-search-support-tickets`, `admin-get-support-ticket` + +Never expect license keys, Stripe secrets, passwords, or GitHub tokens from these tools. diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php new file mode 100644 index 000000000..a5fc0ccf3 --- /dev/null +++ b/resources/views/mcp/authorize.blade.php @@ -0,0 +1,33 @@ + +
+ Connect {{ $client->name }}? + + + Signed in as {{ $user->email }}. This connection grants NativePHP admin MCP access + for site support (create unpublished blog posts, look up signups/users/companies, review plugins and + support tickets). It never returns license keys, Stripe secrets, or passwords. + Only site admins may approve this connection (mcp:admin). + + +
+
+ @csrf + + + + + Connect admin assistant + +
+ +
+ @csrf + @method('DELETE') + + + + Cancel +
+
+
+
diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 000000000..042791b88 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,45 @@ +withoutMiddleware(AddWwwAuthenticateHeader::class) + ->middleware([AddMcpOAuthChallenge::class, 'auth:oauth', EnsureAdminMcpOAuthAccess::class]) + ->name('mcp.oauth.admin'); + +Route::get('/.well-known/oauth-protected-resource/mcp/oauth/admin', [McpOAuthController::class, 'adminProtectedResource']) + ->name('mcp.oauth.admin.protected-resource'); +Route::get('/.well-known/oauth-authorization-server', [McpOAuthController::class, 'authorizationServer']); + +Route::prefix('oauth')->group(function (): void { + Route::post('/register', RegisterClientController::class)->middleware('throttle:20,1'); + Route::post('/token', [AccessTokenController::class, 'issueToken']) + ->middleware(['throttle:60,1', EnsureMcpOAuthRequest::class])->name('passport.token'); + + Route::middleware('web')->group(function (): void { + Route::get('/authorize', [AuthorizationController::class, 'authorize']) + ->middleware([EnsureMcpOAuthRequest::class, RejectNonAdminMcpOAuthScope::class]) + ->name('passport.authorizations.authorize'); + + Route::middleware('auth:web')->group(function (): void { + Route::post('/authorize', [ApproveAuthorizationController::class, 'approve']) + ->middleware(RejectNonAdminMcpOAuthScope::class) + ->name('passport.authorizations.approve'); + Route::delete('/authorize', [DenyAuthorizationController::class, 'deny'])->name('passport.authorizations.deny'); + Route::delete('/tokens/{token}', [McpOAuthController::class, 'revoke'])->name('mcp.oauth.revoke'); + }); + }); +}); diff --git a/tests/Concerns/InteractsWithMcpOAuth.php b/tests/Concerns/InteractsWithMcpOAuth.php new file mode 100644 index 000000000..813740772 --- /dev/null +++ b/tests/Concerns/InteractsWithMcpOAuth.php @@ -0,0 +1,166 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $privateKey); + $keys = [$privateKey, openssl_pkey_get_details($key)['key']]; + } + + config([ + 'passport.private_key' => $keys[0], + 'passport.public_key' => $keys[1], + ]); + + $this->withoutVite(); + } + + protected function mcpAdminOAuthResource(): string + { + return McpAccessToken::adminResource(); + } + + protected function createMcpOAuthClient(): Client + { + return app(ClientRepository::class)->createAuthorizationCodeGrantClient( + name: 'Example Assistant', + redirectUris: ['https://assistant.example/callback'], + confidential: false, + ); + } + + /** + * @return array + */ + protected function mcpOAuthAuthorizationParameters( + Client $client, + string $verifier, + string $scope = 'mcp:admin', + ?string $resource = null, + ): array { + return [ + 'client_id' => (string) $client->id, + 'redirect_uri' => $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => $scope, + 'state' => Str::random(40), + 'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='), + 'code_challenge_method' => 'S256', + 'resource' => $resource ?? $this->mcpAdminOAuthResource(), + 'prompt' => 'consent', + ]; + } + + /** + * @return array + */ + protected function approveMcpOAuthAuthorization( + User $user, + ?Client $client = null, + string $scope = 'mcp:admin', + ?string $resource = null, + ): array { + $client ??= $this->createMcpOAuthClient(); + $verifier = Str::random(64); + $resource ??= $this->mcpAdminOAuthResource(); + $parameters = $this->mcpOAuthAuthorizationParameters($client, $verifier, $scope, $resource); + + $this->flushHeaders(); + Auth::forgetGuards(); + $this->actingAs($user, 'web') + ->get('/oauth/authorize?'.http_build_query($parameters)) + ->assertOk() + ->assertSee('Example Assistant'); + + $response = $this->post('/oauth/authorize', [ + 'auth_token' => session('authToken'), + 'client_id' => (string) $client->id, + ])->assertRedirect(); + + $this->assertStringStartsWith($client->redirect_uris[0].'?', $response->headers->get('Location')); + parse_str(parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); + $this->assertArrayHasKey('code', $query); + $this->assertSame($parameters['state'], $query['state']); + + return [ + 'grant_type' => 'authorization_code', + 'client_id' => (string) $client->id, + 'redirect_uri' => $client->redirect_uris[0], + 'code' => $query['code'], + 'code_verifier' => $verifier, + 'resource' => $resource, + ]; + } + + /** + * @return array{access_token: string, refresh_token: string, expires_in: int, token_type: string} + */ + protected function issueMcpOAuthTokens( + User $user, + ?Client $client = null, + string $scope = 'mcp:admin', + ?string $resource = null, + ): array { + return $this->post('/oauth/token', $this->approveMcpOAuthAuthorization($user, $client, $scope, $resource), ['Accept' => 'application/json']) + ->assertOk() + ->assertJsonStructure(['access_token', 'refresh_token', 'expires_in', 'token_type']) + ->json(); + } + + protected function resetMcpAuthentication(): void + { + $this->flushHeaders(); + $this->flushSession(); + Auth::forgetGuards(); + Auth::shouldUse('web'); + } + + /** + * @param array $params + */ + protected function callMcpWithBearer(string $token, string $method = 'initialize', array $params = [], string $endpoint = '/mcp/oauth/admin'): TestResponse + { + $this->resetMcpAuthentication(); + + if ($method === 'initialize') { + $params = [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => (object) [], + 'clientInfo' => ['name' => 'phpunit-oauth', 'version' => '1.0.0'], + ]; + } + + return $this->withToken($token)->postJson($endpoint, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => (object) $params, + ], ['Accept' => 'application/json, text/event-stream']); + } + + /** + * @param array $arguments + */ + protected function callMcpTool(string $token, string $name, array $arguments = []): TestResponse + { + return $this->callMcpWithBearer($token, 'tools/call', [ + 'name' => $name, + 'arguments' => (object) $arguments, + ]); + } +} diff --git a/tests/Feature/Mcp/AdminMcpOAuthTest.php b/tests/Feature/Mcp/AdminMcpOAuthTest.php new file mode 100644 index 000000000..09f63578b --- /dev/null +++ b/tests/Feature/Mcp/AdminMcpOAuthTest.php @@ -0,0 +1,246 @@ +configureMcpOAuthKeys(); + $this->admin = User::factory()->create(['email' => 'admin-oauth@nativephp.com']); + config(['filament.users' => [$this->admin->email]]); + } + + public function test_advertises_admin_oauth_protected_resource_with_mcp_admin_scope(): void + { + $this->getJson('/.well-known/oauth-protected-resource/mcp/oauth/admin') + ->assertOk() + ->assertJsonPath('resource', $this->mcpAdminOAuthResource()) + ->assertJsonPath('authorization_servers.0', rtrim(config('app.url'), '/')) + ->assertJsonPath('scopes_supported', ['mcp:admin']); + + $this->getJson('/.well-known/oauth-authorization-server') + ->assertOk() + ->assertJsonPath('scopes_supported', ['mcp:admin']); + + $response = $this->callMcpWithBearer('invalid') + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate'); + + $this->assertStringStartsWith('Bearer ', $response->headers->get('WWW-Authenticate')); + $this->assertStringContainsString( + 'resource_metadata="'.url('/.well-known/oauth-protected-resource/mcp/oauth/admin').'"', + $response->headers->get('WWW-Authenticate') + ); + $this->assertStringContainsString('scope="mcp:admin"', $response->headers->get('WWW-Authenticate')); + } + + public function test_unauthenticated_admin_mcp_requests_are_unauthorized(): void + { + $this->postJson('/mcp/oauth/admin', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => (object) [], + ], ['Accept' => 'application/json, text/event-stream'])->assertUnauthorized(); + } + + public function test_admin_can_connect_via_oauth_and_use_admin_mcp_server(): void + { + $client = $this->createMcpOAuthClient(); + $tokens = $this->issueMcpOAuthTokens($this->admin, $client); + + $jwt = (new Parser(new JoseEncoder))->parse($tokens['access_token']); + $this->assertSame( + [(string) $client->id, $this->mcpAdminOAuthResource()], + $jwt->claims()->get('aud') + ); + + $this->callMcpWithBearer($tokens['access_token']) + ->assertOk() + ->assertJsonPath('result.serverInfo.name', 'NativePHP Admin'); + + $tools = $this->callMcpWithBearer($tokens['access_token'], 'tools/list') + ->assertOk(); + + $names = collect($tools->json('result.tools'))->pluck('name')->all(); + + $this->assertContains('admin-create-blog-post', $names); + $this->assertContains('admin-list-signups', $names); + $this->assertContains('admin-list-companies', $names); + $this->assertContains('admin-search-plugins', $names); + $this->assertContains('admin-search-support-tickets', $names); + } + + public function test_non_admins_cannot_approve_admin_mcp_scope(): void + { + $user = User::factory()->create(); + $client = $this->createMcpOAuthClient(); + $parameters = $this->mcpOAuthAuthorizationParameters( + $client, + str_repeat('a', 64), + 'mcp:admin', + $this->mcpAdminOAuthResource(), + ); + + $this->actingAs($user, 'web') + ->get('/oauth/authorize?'.http_build_query($parameters)) + ->assertForbidden(); + } + + public function test_public_docs_mcp_message_endpoint_still_works_without_auth(): void + { + $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ])->assertOk() + ->assertJsonPath('jsonrpc', '2.0'); + } + + public function test_create_blog_post_creates_unpublished_article_for_admin_author(): void + { + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-create-blog-post', [ + 'title' => 'Hello from Admin MCP', + 'content' => "# Hello\n\nDraft body.", + 'excerpt' => 'A draft excerpt', + ])->assertOk(); + + $text = data_get($response->json(), 'result.content.0.text') + ?? data_get($response->json(), 'result.content.0') + ?? $response->json('result'); + + if (is_array($text)) { + $payload = $text; + } else { + $payload = json_decode((string) $text, true); + } + + $this->assertIsArray($payload); + $this->assertSame('Hello from Admin MCP', $payload['title']); + $this->assertFalse($payload['published']); + $this->assertNull($payload['published_at']); + $this->assertSame($this->admin->id, $payload['author_id']); + + $article = Article::query()->findOrFail($payload['id']); + $this->assertNull($article->published_at); + $this->assertSame($this->admin->id, $article->author_id); + $this->assertSame('hello-from-admin-mcp', $article->slug); + } + + public function test_create_blog_post_handles_duplicate_slugs(): void + { + Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'duplicate-slug', + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-create-blog-post', [ + 'title' => 'Anything', + 'content' => 'Body', + 'slug' => 'duplicate-slug', + ])->assertOk(); + + $text = data_get($response->json(), 'result.content.0.text'); + $payload = json_decode((string) $text, true); + + $this->assertSame('duplicate-slug-1', $payload['slug']); + $this->assertDatabaseHas('articles', [ + 'slug' => 'duplicate-slug-1', + 'author_id' => $this->admin->id, + 'published_at' => null, + ]); + } + + public function test_list_signups_returns_users_created_today_in_new_york(): void + { + $today = User::factory()->create([ + 'email' => 'new@acme.com', + 'created_at' => now('America/New_York'), + ]); + User::factory()->create([ + 'email' => 'old@acme.com', + 'created_at' => now('America/New_York')->subDay(), + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-list-signups')->assertOk(); + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $emails = collect($payload['users'])->pluck('email'); + $this->assertTrue($emails->contains($today->email)); + $this->assertFalse($emails->contains('old@acme.com')); + $this->assertSame('acme.com', collect($payload['users'])->firstWhere('email', $today->email)['company_domain']); + } + + public function test_list_companies_rolls_up_email_domains(): void + { + User::factory()->create(['email' => 'a@widgets.io']); + User::factory()->create(['email' => 'b@widgets.io']); + User::factory()->create(['email' => 'c@gmail.com']); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-list-companies')->assertOk(); + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + $domains = collect($payload['companies'])->pluck('domain'); + + $this->assertTrue($domains->contains('widgets.io')); + $this->assertFalse($domains->contains('gmail.com')); + } + + public function test_search_plugins_includes_pending(): void + { + $pending = Plugin::factory()->pending()->create(); + Plugin::factory()->approved()->create(); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-search-plugins', [ + 'status' => 'pending', + ])->assertOk(); + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertSame(1, $payload['count']); + $this->assertSame($pending->name, $payload['plugins'][0]['name']); + $this->assertSame('pending', $payload['plugins'][0]['status']); + } + + public function test_search_support_tickets_returns_summaries(): void + { + $ticket = SupportTicket::factory()->create([ + 'subject' => 'MCP cannot connect', + 'user_id' => User::factory(), + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-search-support-tickets', [ + 'query' => 'MCP cannot', + ])->assertOk(); + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertGreaterThanOrEqual(1, $payload['count']); + $this->assertSame($ticket->mask, $payload['tickets'][0]['mask']); + } +}