diff --git a/.env.example b/.env.example index a5ea69c82..00aa5a931 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,7 @@ GITHUB_API_TOKEN= # Dev.to — slug da organização cujos artigos o contents:sync-articles busca. # A chave de API de cada pessoa é conectada pelo painel (/app/profile → Conexões), não aqui. DEVTO_ORG_SLUG=he4rt + +# API mobile (he4rt-app) — autenticação JWT, gerar com `php artisan jwt:secret`. +JWT_SECRET= +HE4RT_APP_DEEPLINK_SCHEME=he4rtapp diff --git a/.env.testing.example b/.env.testing.example index 88c9040eb..f58bf2083 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -81,3 +81,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" DISCORD_TOKEN= + +# API mobile (he4rt-app) — valor fixo, não é segredo de produção. +JWT_SECRET=testing-jwt-secret-do-not-use-in-production diff --git a/app-modules/bot-discord/phpstan.ignore.neon b/app-modules/bot-discord/phpstan.ignore.neon index bb33f1d5e..1e376664b 100644 --- a/app-modules/bot-discord/phpstan.ignore.neon +++ b/app-modules/bot-discord/phpstan.ignore.neon @@ -21,12 +21,12 @@ parameters: count: 1 path: src/Tasks/VoiceExperienceTask.php - - message: '#^Parameter \#1 \$roles of method Discord\\Parts\\User\\Member::setRoles\(\) expects#' + message: '#^Parameter \#1 \$roles of method Discord\\Parts\\Guild\\Member\\Member::setRoles\(\) expects#' identifier: argument.type count: 1 path: src/SlashCommands/CargoDelasCommand.php - - message: '#^Parameter \#1 \$roles of method Discord\\Parts\\User\\Member::setRoles\(\) expects#' + message: '#^Parameter \#1 \$roles of method Discord\\Parts\\Guild\\Member\\Member::setRoles\(\) expects#' identifier: argument.type count: 1 path: src/SlashCommands/IntroductionCommand.php diff --git a/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php b/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php index c8ff4e50a..0b5e44930 100644 --- a/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php +++ b/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php @@ -4,7 +4,7 @@ namespace He4rt\BotDiscord\Actions\Welcome; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; use He4rt\BotDiscord\DTO\WelcomeContextDTO; use He4rt\BotDiscord\Enums\DiscordErrorCode; use He4rt\BotDiscord\Welcome\WelcomeEmbedBuilder; diff --git a/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php b/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php index 2b71c33bb..071c48216 100644 --- a/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php +++ b/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php @@ -4,7 +4,7 @@ namespace He4rt\BotDiscord\Concerns; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; trait ResolvesGuildIcon { diff --git a/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php b/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php index ed137a1bb..32db984b5 100644 --- a/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php +++ b/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php @@ -4,7 +4,7 @@ namespace He4rt\BotDiscord\DTO; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; final readonly class WelcomeContextDTO { diff --git a/app-modules/bot-discord/src/Events/WelcomeMember.php b/app-modules/bot-discord/src/Events/WelcomeMember.php index fb49d91de..d17a22e45 100644 --- a/app-modules/bot-discord/src/Events/WelcomeMember.php +++ b/app-modules/bot-discord/src/Events/WelcomeMember.php @@ -5,7 +5,7 @@ namespace He4rt\BotDiscord\Events; use Discord\Discord; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; use Discord\WebSockets\Event as Events; use He4rt\BotDiscord\Actions\Welcome\AnnounceNewMemberAction; use He4rt\BotDiscord\Actions\Welcome\SendWelcomeDmAction; diff --git a/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php b/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php index 5b49340a0..4f6110772 100644 --- a/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php @@ -4,10 +4,10 @@ namespace He4rt\BotDiscord\SlashCommands; -use Discord\Parts\Guild\Role; +use Discord\Parts\Guild\Member\Member; +use Discord\Parts\Guild\Role\Role; use Discord\Parts\Interactions\Command\Option; use Discord\Parts\Interactions\Interaction; -use Discord\Parts\User\Member; class CargoDelasCommand extends AbstractSlashCommand { diff --git a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php index 4f829231b..fa7ba6bcc 100644 --- a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php @@ -6,7 +6,7 @@ use Discord\Builders\Components\TextInput; use Discord\Helpers\Collection; -use Discord\Parts\Guild\Role; +use Discord\Parts\Guild\Role\Role; use Discord\Parts\Interactions\Interaction; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; diff --git a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php index 3f8e8c06d..1539d9061 100644 --- a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php @@ -5,7 +5,7 @@ namespace He4rt\BotDiscord\SlashCommands; use Discord\Parts\Channel\Channel; -use Discord\Parts\Guild\Role; +use Discord\Parts\Guild\Role\Role; use Discord\Parts\Interactions\Command\Option; use Discord\Parts\Interactions\Interaction; use Exception; diff --git a/app-modules/bot-discord/stubs/discord-php.stub b/app-modules/bot-discord/stubs/discord-php.stub index 4090adba0..fe79377b8 100644 --- a/app-modules/bot-discord/stubs/discord-php.stub +++ b/app-modules/bot-discord/stubs/discord-php.stub @@ -25,9 +25,6 @@ interface CollectionInterface namespace Discord\Parts\User; -use Discord\Helpers\CollectionInterface; -use Discord\Parts\Guild\Role; - /** * @property string $id * @property string $username @@ -42,6 +39,25 @@ class User { } +/** + * @property string $name + * @property int $type + * @property string|null $url + * @property string|null $application_id + * @property string|null $details + * @property string|null $state + */ +class Activity +{ +} + +namespace Discord\Parts\Guild\Member; + +use Discord\Helpers\CollectionInterface; +use Discord\Parts\Guild\Role\Role; +use Discord\Parts\User\Activity; +use Discord\Parts\User\User; + /** * @property string $id * @property User|null $user @@ -61,18 +77,6 @@ class Member { } -/** - * @property string $name - * @property int $type - * @property string|null $url - * @property string|null $application_id - * @property string|null $details - * @property string|null $state - */ -class Activity -{ -} - namespace Discord\Parts\Guild; /** @@ -86,13 +90,15 @@ namespace Discord\Parts\Guild; * @property int|null $member_count * @property mixed $members * @property mixed $channels - * @property \Discord\Helpers\CollectionInterface $roles + * @property \Discord\Helpers\CollectionInterface<\Discord\Parts\Guild\Role\Role> $roles * @property-read mixed $voice_states */ class Guild { } +namespace Discord\Parts\Guild\Role; + /** * @property string $id * @property string $name @@ -130,7 +136,7 @@ namespace Discord\Parts\WebSockets; * @property \Discord\Parts\Channel\Channel|null $channel * @property string $user_id * @property \Discord\Parts\User\User|null $user - * @property \Discord\Parts\User\Member|null $member + * @property \Discord\Parts\Guild\Member\Member|null $member * @property string $session_id * @property bool $deaf * @property bool $mute diff --git a/app-modules/docs/config/docs.php b/app-modules/docs/config/docs.php index a794db7b2..a513924b1 100644 --- a/app-modules/docs/config/docs.php +++ b/app-modules/docs/config/docs.php @@ -3,7 +3,7 @@ declare(strict_types=1); return [ - 'default_version' => '3.x', + 'default_version' => '4.x', 'cache' => [ 'enabled' => env('DOCS_CACHE_ENABLED', default: true), diff --git a/app-modules/docs/routes/docs-routes.php b/app-modules/docs/routes/docs-routes.php index 1a6fc7b7f..f5fa2f632 100644 --- a/app-modules/docs/routes/docs-routes.php +++ b/app-modules/docs/routes/docs-routes.php @@ -14,7 +14,7 @@ Route::get('docs', [DocsController::class, 'index'])->name('docs.index'); // The section is constrained to known document types so Scramble's -// `docs/3.x/api` (and any other prefix) falls through to its own route. +// `docs/4.x/api` (and any other prefix) falls through to its own route. Route::get('docs/{section}/{path?}', [DocsController::class, 'show']) ->where('section', $sections) ->where('path', '.*') diff --git a/app-modules/docs/src/DocsServiceProvider.php b/app-modules/docs/src/DocsServiceProvider.php index bc1a3c933..e4b15bbde 100644 --- a/app-modules/docs/src/DocsServiceProvider.php +++ b/app-modules/docs/src/DocsServiceProvider.php @@ -58,9 +58,9 @@ public function boot(): void { $this->commands([CacheDocsCommand::class]); - Scramble::registerApi('3.x'); + Scramble::registerApi('4.x'); - Scramble::registerUiRoute(path: 'docs/3.x/api', api: '3.x'); - Scramble::registerJsonSpecificationRoute(path: 'docs/3.x/swagger.json', api: '3.x'); + Scramble::registerUiRoute(path: 'docs/4.x/api', api: '4.x'); + Scramble::registerJsonSpecificationRoute(path: 'docs/4.x/swagger.json', api: '4.x'); } } diff --git a/app-modules/docs/src/Documentation.php b/app-modules/docs/src/Documentation.php index 37cfc6db5..02d435c3b 100644 --- a/app-modules/docs/src/Documentation.php +++ b/app-modules/docs/src/Documentation.php @@ -40,7 +40,7 @@ public static function replaceLinks($version, RenderedContentInterface|string $c public static function getDocVersions(): array { return [ - '3.x' => '3.x', + '4.x' => '4.x', ]; } diff --git a/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php b/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php index 902afd04d..99836932e 100644 --- a/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php +++ b/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php @@ -46,7 +46,7 @@ it('does not let the catch-all hijack the Scramble api route', function (): void { $route = resolve(Router::class)->getRoutes()->match( - Request::create('/docs/3.x/api', 'GET'), + Request::create('/docs/4.x/api', 'GET'), ); expect($route->getActionName())->not->toContain(DocsController::class); diff --git a/app-modules/identity/routes/api-mobile-routes.php b/app-modules/identity/routes/api-mobile-routes.php new file mode 100644 index 000000000..62f393cee --- /dev/null +++ b/app-modules/identity/routes/api-mobile-routes.php @@ -0,0 +1,35 @@ +middleware('api') + ->group(static function (): void { + Route::prefix('auth')->group(static function (): void { + // O callback do OAuth é único por provider e já está cadastrado + // apontando pra rota web (auth/oauth/{provider} → OAuthController:: + // getAuthenticate), que também finaliza o login mobile quando + // intent=MobileLogin. Ver He4rt\Identity\Auth\Support\MobileOAuthDeepLink. + Route::get('/{provider}/redirect', [MobileOAuthController::class, 'redirect']) + ->name('mobile.oauth.redirect'); + + Route::post('/exchange', [MobileAuthController::class, 'exchange']) + ->name('mobile.auth.exchange'); + + Route::post('/refresh', [MobileAuthController::class, 'refresh']) + ->name('mobile.auth.refresh'); + + Route::post('/logout', [MobileAuthController::class, 'logout']) + ->middleware('auth:api') + ->name('mobile.auth.logout'); + }); + + Route::get('/me', MobileMeController::class) + ->middleware('auth:api') + ->name('mobile.me'); + }); diff --git a/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php new file mode 100644 index 000000000..bed7c3ef2 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php @@ -0,0 +1,44 @@ +get()) { + throw MobileAuthException::invalidExchangeCode(); + } + + try { + /** @var string|null $userId */ + $userId = Cache::get($cacheKey); + + throw_if($userId === null, MobileAuthException::invalidExchangeCode()); + + Cache::forget($cacheKey); + + $user = User::query()->find($userId); + + throw_if($user === null, MobileAuthException::invalidExchangeCode()); + + return $user; + } finally { + $lock->release(); + } + } +} diff --git a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php index a073d4994..a60d3e12a 100644 --- a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -34,7 +34,7 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string $oauthUser = $client->getAuthenticatedUser($access); $user = match ($state->intent) { - OAuthIntent::Login => $this->findOrCreateUser->execute($oauthUser), + OAuthIntent::Login, OAuthIntent::MobileLogin => $this->findOrCreateUser->execute($oauthUser), OAuthIntent::Link => $this->resolveAuthenticatedUser(), }; diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php new file mode 100644 index 000000000..62e839f1c --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php @@ -0,0 +1,28 @@ +id, self::TTL_SECONDS); + + return $code; + } +} diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php new file mode 100644 index 000000000..d71635d51 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php @@ -0,0 +1,28 @@ +login($user); + + return new MobileTokenDTO( + accessToken: $token, + tokenType: 'bearer', + expiresIn: config()->integer('jwt.ttl') * 60, + ); + } +} diff --git a/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php new file mode 100644 index 000000000..ee58d3d37 --- /dev/null +++ b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php @@ -0,0 +1,26 @@ + $this->accessToken, + 'token_type' => $this->tokenType, + 'expires_in' => $this->expiresIn, + ]; + } +} diff --git a/app-modules/identity/src/Auth/Enums/OAuthIntent.php b/app-modules/identity/src/Auth/Enums/OAuthIntent.php index 3431d31c7..90161d2df 100644 --- a/app-modules/identity/src/Auth/Enums/OAuthIntent.php +++ b/app-modules/identity/src/Auth/Enums/OAuthIntent.php @@ -8,4 +8,5 @@ enum OAuthIntent: string { case Login = 'login'; case Link = 'link'; + case MobileLogin = 'mobile_login'; } diff --git a/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php b/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php new file mode 100644 index 000000000..7a5113e09 --- /dev/null +++ b/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php @@ -0,0 +1,15 @@ +validate([ + 'code' => ['required', 'string'], + ]); + + try { + $user = $exchangeCode->execute($request->string('code')->toString()); + } catch (MobileAuthException $mobileAuthException) { + return response()->json(['message' => $mobileAuthException->getMessage()], 401); + } + + return response()->json($issueToken->execute($user)->toArray()); + } + + /** + * Renovar token de acesso + * + * Emite um novo token a partir do token atual do header Authorization, + * mesmo que já tenha expirado — desde que dentro da janela de refresh + * (jwt.refresh_ttl) e não esteja na blacklist. + */ + public function refresh(): JsonResponse + { + /** @var JWTGuard $guard */ + $guard = Auth::guard('api'); + + try { + /** @var string $token */ + $token = $guard->refresh(); + } catch (JWTException $jwtException) { + return response()->json(['message' => $jwtException->getMessage()], 401); + } + + $refreshed = new MobileTokenDTO( + accessToken: $token, + tokenType: 'bearer', + expiresIn: config()->integer('jwt.ttl') * 60, + ); + + return response()->json($refreshed->toArray()); + } + + /** + * Encerrar sessão + * + * Invalida o token de acesso atual (blacklist) — o mesmo token não + * autentica nem renova depois disso. + */ + public function logout(): JsonResponse + { + Auth::guard('api')->logout(); + + return response()->json(status: 204); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php new file mode 100644 index 000000000..a02119cab --- /dev/null +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php @@ -0,0 +1,30 @@ +user(); + + return response()->json([ + 'id' => $user->id, + 'username' => $user->username, + 'avatar_url' => $user->getFilamentAvatarUrl(), + ]); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php new file mode 100644 index 000000000..0dfebdd58 --- /dev/null +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php @@ -0,0 +1,69 @@ + */ + private const array SUPPORTED_PROVIDERS = [ + IdentityProvider::Discord, + IdentityProvider::GitHub, + IdentityProvider::Twitch, + ]; + + /** + * Iniciar login OAuth + * + * Redireciona pro provider (discord, github ou twitch). O provider + * devolve o usuário pro callback web fixo, que redireciona de volta + * pro app via deep link com um código de troca de uso único — ver + * POST /api/mobile/auth/exchange. + */ + public function redirect(string $provider): RedirectResponse + { + $identityProvider = $this->resolveSupportedProvider($provider); + + try { + $client = $identityProvider->getClient(); + } catch (RuntimeException $runtimeException) { + Log::warning('Mobile OAuth client not configured', ['provider' => $provider, 'error' => $runtimeException->getMessage()]); + + return redirect()->to(MobileOAuthDeepLink::build('error', 'client_not_configured')); + } + + throw_unless($client instanceof OAuthClientContract, NotFoundHttpException::class); + + $state = new OAuthStateDTO( + intent: OAuthIntent::MobileLogin, + provider: $identityProvider, + panel: 'mobile', + ); + + return redirect()->to($client->redirectUrl($state)); + } + + private function resolveSupportedProvider(string $provider): IdentityProvider + { + $identityProvider = IdentityProvider::tryFrom($provider); + + throw_unless( + $identityProvider !== null && in_array($identityProvider, self::SUPPORTED_PROVIDERS, strict: true), + NotFoundHttpException::class, + ); + + return $identityProvider; + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index a55cd9f8d..e59cacb21 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -7,9 +7,11 @@ use App\Contracts\OAuthClientContract; use App\Http\Controllers\Controller; use He4rt\Identity\Auth\Actions\HandleOAuthCallbackAction; +use He4rt\Identity\Auth\Actions\IssueMobileExchangeCodeAction; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\Auth\Exceptions\OAuthFlowException; +use He4rt\Identity\Auth\Support\MobileOAuthDeepLink; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; @@ -52,14 +54,15 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act throw_if($identityProvider === null, NotFoundHttpException::class); $state = OAuthStateDTO::fromEncryptedString(request()->input('state')); + $isMobile = $state->intent === OAuthIntent::MobileLogin; $code = request()->input('code'); $oauthDenied = $code === null || request()->has('error'); if ($oauthDenied) { - $fallbackUrl = $state->returnUrl ?? '/'; - - return redirect()->to($fallbackUrl); + return $isMobile + ? redirect()->to(MobileOAuthDeepLink::build('error', 'access_denied')) + : redirect()->to($state->returnUrl ?? '/'); } try { @@ -67,7 +70,15 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act } catch (OAuthFlowException $oAuthFlowException) { Log::warning('OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); - return redirect()->to($state->returnUrl ?? '/'); + return $isMobile + ? redirect()->to(MobileOAuthDeepLink::build('error', 'oauth_flow_failed')) + : redirect()->to($state->returnUrl ?? '/'); + } + + if ($isMobile) { + $exchangeCode = resolve(IssueMobileExchangeCodeAction::class)->execute($result->user); + + return redirect()->to(MobileOAuthDeepLink::build('callback', code: $exchangeCode)); } if ($result->hasMergeConflict()) { diff --git a/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php b/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php new file mode 100644 index 000000000..b0990cf34 --- /dev/null +++ b/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php @@ -0,0 +1,16 @@ + $error, 'code' => $code]); + + return sprintf('%s://oauth/%s%s', $scheme, $path, $query === [] ? '' : '?'.http_build_query($query)); + } +} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index da61c76ec..024614682 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -30,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Models\Role; @@ -54,7 +55,7 @@ #[UseFactory(factoryClass: UserFactory::class)] #[Table(name: 'users')] #[Hidden('password', 'remember_token', 'email_verified_at')] -final class User extends Authenticatable implements FilamentUser, HasMedia, HasName +final class User extends Authenticatable implements FilamentUser, HasMedia, HasName, JWTSubject { use HasAddress; /** @use HasFactory */ @@ -71,6 +72,19 @@ public function isSuperAdmin(): bool return $this->hasRole(UserRole::SuperAdmin); } + public function getJWTIdentifier(): string + { + return $this->getKey(); + } + + /** + * @return array + */ + public function getJWTCustomClaims(): array + { + return []; + } + /** * @return MorphMany */ diff --git a/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php new file mode 100644 index 000000000..9e280e143 --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php @@ -0,0 +1,84 @@ +create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code]) + ->assertOk() + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']) + ->assertJson(['token_type' => 'bearer']); +}); + +test('exchange consumes the code, so it cannot be reused', function (): void { + $user = User::factory()->create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertOk(); + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); +}); + +test('exchange rejects a concurrent redemption of the same code', function (): void { + $user = User::factory()->create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + // Simula um segundo request concorrente já segurando o lock antes do + // primeiro conseguir ler+apagar o código — prova que a troca é atômica. + $lock = Cache::lock('identity:mobile-oauth-exchange-lock:'.$code, 10); + $lock->get(); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); + + $lock->release(); +}); + +test('exchange rejects an unknown code', function (): void { + $this->postJson('/api/mobile/auth/exchange', ['code' => 'does-not-exist']) + ->assertUnauthorized(); +}); + +test('me returns the authenticated user', function (): void { + $user = User::factory()->create(['username' => 'he4rtdev']); + $token = Auth::guard('api')->login($user); + + $this->getJson('/api/mobile/me', ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJson(['id' => $user->id, 'username' => 'he4rtdev']); +}); + +test('me rejects a request without a token', function (): void { + $this->getJson('/api/mobile/me')->assertUnauthorized(); +}); + +test('refresh issues a new token', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $response = $this->postJson('/api/mobile/auth/refresh', [], ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']); + + expect($response->json('access_token'))->not->toBe($token); +}); + +test('refresh rejects a missing token', function (): void { + $this->postJson('/api/mobile/auth/refresh')->assertUnauthorized(); +}); + +test('logout invalidates the token', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $this->postJson('/api/mobile/auth/logout', [], ['Authorization' => "Bearer {$token}"]) + ->assertNoContent(); + + $this->getJson('/api/mobile/me', ['Authorization' => "Bearer {$token}"]) + ->assertUnauthorized(); +}); diff --git a/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php new file mode 100644 index 000000000..1eadc41d6 --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php @@ -0,0 +1,125 @@ +instance(GitHubOAuthClient::class, new readonly class($access, $user) implements OAuthClientContract + { + public function __construct( + private OAuthAccessDTO $access, + private OAuthUserDTO $user, + ) {} + + public function redirectUrl(?OAuthStateDTO $state = null): string + { + return 'https://github.test/oauth'; + } + + public function auth(string $code): OAuthAccessDTO + { + return $this->access; + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO + { + return $this->user; + } + }); +} + +test('redirect sends an unsupported provider to a 404', function (): void { + $this->get('/api/mobile/auth/devto/redirect')->assertNotFound(); +}); + +test('redirect forwards to the provider authorize URL', function (): void { + bindMobileGithubClient(); + + $this->get('/api/mobile/auth/github/redirect') + ->assertRedirect('https://github.test/oauth'); +}); + +test('a denied mobile authorization on the shared web callback redirects to the app deep link with an error', function (): void { + // Discord/GitHub/Twitch só conhecem UMA redirect_uri por app: a rota web + // /auth/oauth/{provider} (OAuthController::getAuthenticate). O login mobile + // é distinguido pelo intent codificado no state, não por uma rota própria. + $state = new OAuthStateDTO( + intent: OAuthIntent::MobileLogin, + provider: IdentityProvider::GitHub, + panel: 'mobile', + returnUrl: 'mobile', + ); + + $response = $this->get('/auth/oauth/github?'.http_build_query([ + 'state' => (string) $state, + 'error' => 'access_denied', + ])); + + $response->assertRedirect(); + expect($response->headers->get('Location')) + ->toStartWith('he4rtapp://oauth/error') + ->toContain('error=access_denied'); +}); + +test('a successful mobile login on the shared web callback redirects to the app deep link with an exchange code, without starting a web session', function (): void { + bindMobileGithubClient(); + + $state = new OAuthStateDTO( + intent: OAuthIntent::MobileLogin, + provider: IdentityProvider::GitHub, + panel: 'mobile', + returnUrl: 'mobile', + ); + + $response = $this->get('/auth/oauth/github?'.http_build_query([ + 'state' => (string) $state, + 'code' => 'auth-code', + ])); + + $response->assertRedirect(); + + $location = (string) $response->headers->get('Location'); + + expect($location)->toStartWith('he4rtapp://oauth/callback?code=') + ->and(User::query()->where('username', 'mobile-user')->exists())->toBeTrue() + ->and(Auth::check())->toBeFalse(); +}); diff --git a/bootstrap/app.php b/bootstrap/app.php index 86c27f279..afd8a9ef4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,20 @@ $middleware->web(append: [ SetApplicationLocale::class, ]); + + // O app não tem rota "login" — auth é só via OAuth (Filament cuida do + // próprio redirect nos painéis). Sem isso, o middleware `auth`/`auth:api` + // tenta redirect(route('login')) pra qualquer client sem "Accept: + // application/json" e quebra com 500 (RouteNotFoundException). + $middleware->redirectGuestsTo(redirect: null); + }) + ->withExceptions(static function (Exceptions $exceptions): void { + // Sem isso, um cliente de API que não manda "Accept: application/json" + // (curl puro, a maioria dos clientes HTTP mobile) recebe um 500 em vez + // de 401/erro em JSON: o handler padrão tenta redirect(route('login')), + // que não existe neste app — login é só via OAuth. + $exceptions->shouldRenderJsonWhen( + fn ($request, $throwable): bool => $request->is('api/*') || $request->expectsJson(), + ); }) - ->withExceptions(static function (Exceptions $exceptions): void {}) ->create(); diff --git a/composer.json b/composer.json index b25fb0d38..c82fe24fa 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,8 @@ "bacon/bacon-qr-code": "^2.0.8", "calebporzio/sushi": "^2.5.4", "dedoc/scramble": "^0.13.45", - "filament/filament": "^5.8.4", - "filament/spatie-laravel-media-library-plugin": "^5.8.4", + "filament/filament": "^5.9.0", + "filament/spatie-laravel-media-library-plugin": "^5.9.0", "guzzlehttp/guzzle": "^7.15.5", "he4rt/activity": "^1.0.0", "he4rt/bot-discord": "^1.0.0", @@ -53,6 +53,7 @@ "monicahq/laravel-cloudflare": "^4.1", "owenvoke/blade-fontawesome": "^3.3.1", "phiki/phiki": "^2.2.1", + "php-open-source-saver/jwt-auth": "^2.9.3", "ryangjchandler/commonmark-blade-block": "^1.1.1", "saloonphp/saloon": "^4.3.0", "spatie/laravel-backup": "^10.3.3", @@ -64,9 +65,9 @@ "require-dev": { "driftingly/rector-laravel": "^2.6.2", "fakerphp/faker": "^1.24.1", - "fruitcake/laravel-debugbar": "^4.4.3", + "fruitcake/laravel-debugbar": "^4.4.4", "larastan/larastan": "^3.12.2", - "laravel/boost": "^2.9.1", + "laravel/boost": "^2.10.0", "laravel/pail": "^1.2.7", "laravel/pao": "^1.1.5", "laravel/pint": "^1.32.1", diff --git a/composer.lock b/composer.lock index 99f1bd5bc..8413ca136 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": "a3ce57b2ea4c2e6fec8821902b388ac9", + "content-hash": "f14868f78884bf288f9080ebc634a2f2", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -699,16 +699,16 @@ }, { "name": "composer/class-map-generator", - "version": "1.7.3", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", - "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", "shasum": "" }, "require": { @@ -752,7 +752,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + "source": "https://github.com/composer/class-map-generator/tree/1.8.0" }, "funding": [ { @@ -764,7 +764,7 @@ "type": "github" } ], - "time": "2026-05-05T09:17:07+00:00" + "time": "2026-09-25T15:24:19+00:00" }, { "name": "composer/composer", @@ -1024,16 +1024,16 @@ }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", "shasum": "" }, "require": { @@ -1075,7 +1075,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -1085,7 +1085,7 @@ "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" + "source": "https://github.com/composer/semver/tree/3.5.0" }, "funding": [ { @@ -1097,7 +1097,7 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-09-24T14:38:51+00:00" }, { "name": "composer/spdx-licenses", @@ -1601,16 +1601,16 @@ }, { "name": "discord-php-helpers/voice", - "version": "v8.1.1", + "version": "v8.3.0", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP-Voice.git", - "reference": "951c987fc89c8dd636b0a1ac49bf8186efe9d0b7" + "reference": "a4128d2ac85d370d2d30b62f0aff0f91138a500c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP-Voice/zipball/951c987fc89c8dd636b0a1ac49bf8186efe9d0b7", - "reference": "951c987fc89c8dd636b0a1ac49bf8186efe9d0b7", + "url": "https://api.github.com/repos/discord-php/DiscordPHP-Voice/zipball/a4128d2ac85d370d2d30b62f0aff0f91138a500c", + "reference": "a4128d2ac85d370d2d30b62f0aff0f91138a500c", "shasum": "" }, "require": { @@ -1652,10 +1652,18 @@ "chat": "https://discord.gg/dphp", "docs": "https://discord-php.github.io/DiscordPHP/", "issues": "https://github.com/discord-php/DiscordPHP-Voice/issues", - "source": "https://github.com/discord-php/DiscordPHP-Voice/tree/v8.1.1", + "source": "https://github.com/discord-php/DiscordPHP-Voice/tree/v8.3.0", "wiki": "https://github.com/discord-php/DiscordPHP/wiki" }, "funding": [ + { + "url": "https://valgorithms.com", + "type": "custom" + }, + { + "url": "https://www.paypal.me/valithor", + "type": "custom" + }, { "url": "https://github.com/Log1x", "type": "github" @@ -1663,26 +1671,22 @@ { "url": "https://github.com/valzargaming", "type": "github" - }, - { - "url": "https://www.patreon.com/DiscordPHP", - "type": "patreon" } ], - "time": "2026-06-18T21:04:43+00:00" + "time": "2026-09-28T17:12:32+00:00" }, { "name": "discord-php/http", - "version": "v10.9.6", + "version": "v10.9.8", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP-Http.git", - "reference": "45ec0a137c609595a03c299711e95b1be1ef7185" + "reference": "c4430613366b86937b985791baa000ec30e812d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP-Http/zipball/45ec0a137c609595a03c299711e95b1be1ef7185", - "reference": "45ec0a137c609595a03c299711e95b1be1ef7185", + "url": "https://api.github.com/repos/discord-php/DiscordPHP-Http/zipball/c4430613366b86937b985791baa000ec30e812d8", + "reference": "c4430613366b86937b985791baa000ec30e812d8", "shasum": "" }, "require": { @@ -1723,7 +1727,7 @@ "description": "Handles HTTP requests to Discord servers", "support": { "issues": "https://github.com/discord-php/DiscordPHP-Http/issues", - "source": "https://github.com/discord-php/DiscordPHP-Http/tree/v10.9.6" + "source": "https://github.com/discord-php/DiscordPHP-Http/tree/v10.9.8" }, "funding": [ { @@ -1739,7 +1743,7 @@ "type": "github" } ], - "time": "2026-09-22T13:34:09+00:00" + "time": "2026-09-24T11:48:20+00:00" }, { "name": "discord/interactions", @@ -2191,16 +2195,16 @@ }, { "name": "filament/actions", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "d52683c6624d7adbae5979dc5474632b3299f47f" + "reference": "0f7d87ded56d066cf1be8fada4655fee3acc8f16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/d52683c6624d7adbae5979dc5474632b3299f47f", - "reference": "d52683c6624d7adbae5979dc5474632b3299f47f", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/0f7d87ded56d066cf1be8fada4655fee3acc8f16", + "reference": "0f7d87ded56d066cf1be8fada4655fee3acc8f16", "shasum": "" }, "require": { @@ -2236,20 +2240,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:56:37+00:00" + "time": "2026-09-26T19:45:10+00:00" }, { "name": "filament/filament", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "b5aad5862fe932c8013aa7c6cba2fd360711de0d" + "reference": "92fcbd4d5dba2b0b2fdc44d789f2b72e20e20996" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/b5aad5862fe932c8013aa7c6cba2fd360711de0d", - "reference": "b5aad5862fe932c8013aa7c6cba2fd360711de0d", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/92fcbd4d5dba2b0b2fdc44d789f2b72e20e20996", + "reference": "92fcbd4d5dba2b0b2fdc44d789f2b72e20e20996", "shasum": "" }, "require": { @@ -2294,20 +2298,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-20T19:20:59+00:00" + "time": "2026-09-26T19:40:50+00:00" }, { "name": "filament/forms", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "d7567d2dd57222ac15700324bbaae38405196d46" + "reference": "37a4c18517cb95b2fa333c83a8adf02c1942dc44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/d7567d2dd57222ac15700324bbaae38405196d46", - "reference": "d7567d2dd57222ac15700324bbaae38405196d46", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/37a4c18517cb95b2fa333c83a8adf02c1942dc44", + "reference": "37a4c18517cb95b2fa333c83a8adf02c1942dc44", "shasum": "" }, "require": { @@ -2318,6 +2322,9 @@ "php": "^8.2", "ueberdosis/tiptap-php": "^2.0" }, + "suggest": { + "fakerphp/faker": "Required to generate fake rich content and Markdown." + }, "type": "library", "extra": { "laravel": { @@ -2344,11 +2351,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-20T18:46:41+00:00" + "time": "2026-09-26T19:42:07+00:00" }, { "name": "filament/infolists", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", @@ -2393,7 +2400,7 @@ }, { "name": "filament/notifications", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -2440,7 +2447,7 @@ }, { "name": "filament/query-builder", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/query-builder.git", @@ -2486,16 +2493,16 @@ }, { "name": "filament/schemas", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", - "reference": "59406389183fbdaccdae65a29f752fbfea4567bd" + "reference": "17c0265b0bb7070d5926e79d6ec2f3811b5aecde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/schemas/zipball/59406389183fbdaccdae65a29f752fbfea4567bd", - "reference": "59406389183fbdaccdae65a29f752fbfea4567bd", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/17c0265b0bb7070d5926e79d6ec2f3811b5aecde", + "reference": "17c0265b0bb7070d5926e79d6ec2f3811b5aecde", "shasum": "" }, "require": { @@ -2527,11 +2534,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-20T18:46:28+00:00" + "time": "2026-09-26T19:40:43+00:00" }, { "name": "filament/spatie-laravel-media-library-plugin", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", @@ -2568,27 +2575,28 @@ }, { "name": "filament/support", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "c39b26bbcdad3086b1445590c5da955c7d1177d6" + "reference": "20855638e079b89c95b74f65266a28ca9653052c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/c39b26bbcdad3086b1445590c5da955c7d1177d6", - "reference": "c39b26bbcdad3086b1445590c5da955c7d1177d6", + "url": "https://api.github.com/repos/filamentphp/support/zipball/20855638e079b89c95b74f65266a28ca9653052c", + "reference": "20855638e079b89c95b74f65266a28ca9653052c", "shasum": "" }, "require": { "blade-ui-kit/blade-heroicons": "^2.5", "composer-runtime-api": "^2.1", + "composer/class-map-generator": "^1.6", "danharrin/livewire-rate-limiting": "^2.0", "ext-intl": "*", "illuminate/contracts": "^11.28|^12.0|^13.0", "kirschbaum-development/eloquent-power-joins": "^4.0", "league/uri-components": "^7.0", - "livewire/livewire": "^4.1", + "livewire/livewire": "^4.4.2", "nette/php-generator": "^4.0", "php": "^8.2", "ryangjchandler/blade-capture-directive": "^1.0", @@ -2623,20 +2631,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-20T19:22:34+00:00" + "time": "2026-09-26T19:44:15+00:00" }, { "name": "filament/tables", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "e07361e182118d85e02bce35653c19429f7a8349" + "reference": "bb6b68a02f28c3fa9b90c21f03bea565238ff41d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/e07361e182118d85e02bce35653c19429f7a8349", - "reference": "e07361e182118d85e02bce35653c19429f7a8349", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/bb6b68a02f28c3fa9b90c21f03bea565238ff41d", + "reference": "bb6b68a02f28c3fa9b90c21f03bea565238ff41d", "shasum": "" }, "require": { @@ -2669,11 +2677,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-20T18:52:31+00:00" + "time": "2026-09-26T19:44:11+00:00" }, { "name": "filament/widgets", - "version": "v5.8.4", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", @@ -4286,16 +4294,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.12.0", + "version": "6.13.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "8ef236a1a37df364b518b7411a4b5bb95b040d79" + "reference": "30de5e9b3fa04253af272a7d932a38cd026c311b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8ef236a1a37df364b518b7411a4b5bb95b040d79", - "reference": "8ef236a1a37df364b518b7411a4b5bb95b040d79", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/30de5e9b3fa04253af272a7d932a38cd026c311b", + "reference": "30de5e9b3fa04253af272a7d932a38cd026c311b", "shasum": "" }, "require": { @@ -4345,22 +4353,22 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.12.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.13.0" }, - "time": "2026-09-04T12:54:20+00:00" + "time": "2026-09-23T19:46:42+00:00" }, { "name": "kirschbaum-development/eloquent-power-joins", - "version": "4.3.3", + "version": "4.3.4", "source": { "type": "git", "url": "https://github.com/kirschbaum-development/eloquent-power-joins.git", - "reference": "c609dbbe4ad2051b667e937f1ab554067519d64b" + "reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/c609dbbe4ad2051b667e937f1ab554067519d64b", - "reference": "c609dbbe4ad2051b667e937f1ab554067519d64b", + "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/a0e6c0420c381861e1830693c4b74bdac7f7fb5f", + "reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f", "shasum": "" }, "require": { @@ -4408,9 +4416,9 @@ ], "support": { "issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues", - "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.3" + "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.4" }, - "time": "2026-07-23T11:41:37+00:00" + "time": "2026-09-25T11:10:42+00:00" }, { "name": "laracord/framework", @@ -5267,6 +5275,79 @@ }, "time": "2026-03-17T14:54:13+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.10.3", @@ -6118,16 +6199,16 @@ }, { "name": "livewire/livewire", - "version": "v4.4.6", + "version": "v4.4.7", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "c6b7db7a92103ac2738e8fdc926cecc93eb67853" + "reference": "5976d18b1c808287017a17337d9e68dc1ca64ca2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/c6b7db7a92103ac2738e8fdc926cecc93eb67853", - "reference": "c6b7db7a92103ac2738e8fdc926cecc93eb67853", + "url": "https://api.github.com/repos/livewire/livewire/zipball/5976d18b1c808287017a17337d9e68dc1ca64ca2", + "reference": "5976d18b1c808287017a17337d9e68dc1ca64ca2", "shasum": "" }, "require": { @@ -6182,7 +6263,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.4.6" + "source": "https://github.com/livewire/livewire/tree/v4.4.7" }, "funding": [ { @@ -6190,7 +6271,7 @@ "type": "github" } ], - "time": "2026-09-21T18:51:23+00:00" + "time": "2026-09-28T18:12:51+00:00" }, { "name": "maennchen/zipstream-php", @@ -6788,18 +6869,85 @@ ], "time": "2026-08-11T10:17:44+00:00" }, + { + "name": "namshi/jose", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/namshi/jose.git", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": ">=5.5", + "symfony/polyfill-php56": "^1.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^2.0", + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "suggest": { + "ext-openssl": "Allows to use OpenSSL as crypto engine.", + "phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0." + }, + "type": "library", + "autoload": { + "psr-4": { + "Namshi\\JOSE\\": "src/Namshi/JOSE/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Nadalin", + "email": "alessandro.nadalin@gmail.com" + }, + { + "name": "Alessandro Cinelli (cirpo)", + "email": "alessandro.cinelli@gmail.com" + } + ], + "description": "JSON Object Signing and Encryption library for PHP.", + "keywords": [ + "JSON Web Signature", + "JSON Web Token", + "JWS", + "json", + "jwt", + "token" + ], + "support": { + "issues": "https://github.com/namshi/jose/issues", + "source": "https://github.com/namshi/jose/tree/master" + }, + "time": "2016-12-05T07:27:31+00:00" + }, { "name": "nesbot/carbon", - "version": "3.14.0", + "version": "3.14.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" + "reference": "34e9109535165bf1d7ff17a610492372ae73a896" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", - "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896", + "reference": "34e9109535165bf1d7ff17a610492372ae73a896", "shasum": "" }, "require": { @@ -6891,7 +7039,7 @@ "type": "tidelift" } ], - "time": "2026-09-12T20:45:19+00:00" + "time": "2026-09-27T12:07:26+00:00" }, { "name": "nette/php-generator", @@ -7615,6 +7763,99 @@ ], "time": "2026-07-22T19:51:40+00:00" }, + { + "name": "php-open-source-saver/jwt-auth", + "version": "v2.9.3", + "source": { + "type": "git", + "url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git", + "reference": "1bdb72de5e60fcb02264d7aba4bd4608939e3798" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/1bdb72de5e60fcb02264d7aba4bd4608939e3798", + "reference": "1bdb72de5e60fcb02264d7aba4bd4608939e3798", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/auth": "^12|^13", + "illuminate/contracts": "^12|^13", + "illuminate/http": "^12|^13", + "illuminate/support": "^12|^13", + "lcobucci/jwt": "^5.4", + "namshi/jose": "^7.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "illuminate/console": "^12|^13", + "illuminate/routing": "^12|^13", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10|^11", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5|^11" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth", + "JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory" + }, + "providers": [ + "PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "PHPOpenSourceSaver\\JWTAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Forked package creator | Developer" + }, + { + "name": "Eric Schricker", + "email": "eric.schricker@adiutabyte.de", + "role": "Developer" + }, + { + "name": "Fabio William Conceição", + "email": "messhias@gmail.com", + "role": "Developer" + }, + { + "name": "Max Snow", + "email": "contact@maxsnow.me", + "role": "Developer" + } + ], + "description": "JSON Web Token Authentication for Laravel and Lumen", + "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", + "keywords": [ + "Authentication", + "JSON Web Token", + "auth", + "jwt", + "laravel" + ], + "support": { + "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", + "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + }, + "time": "2026-08-21T05:00:05+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -7802,16 +8043,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.5", + "version": "2.3.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", - "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", "shasum": "" }, "require": { @@ -7843,9 +8084,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" }, - "time": "2026-08-31T16:05:28+00:00" + "time": "2026-09-27T20:07:45+00:00" }, { "name": "pragmarx/google2fa", @@ -10727,16 +10968,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.93.2", + "version": "1.93.3", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "e927d5b5b05e9fecae098e559e51918aa608ad02" + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/e927d5b5b05e9fecae098e559e51918aa608ad02", - "reference": "e927d5b5b05e9fecae098e559e51918aa608ad02", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/7aeef26bd8d4909bec744dbf47aee7d1395588c1", + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1", "shasum": "" }, "require": { @@ -10776,7 +11017,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.2" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.3" }, "funding": [ { @@ -10784,7 +11025,7 @@ "type": "github" } ], - "time": "2026-08-26T09:13:25+00:00" + "time": "2026-09-28T07:16:50+00:00" }, { "name": "spatie/laravel-permission", @@ -12770,6 +13011,74 @@ ], "time": "2026-05-27T06:59:30+00:00" }, + { + "name": "symfony/polyfill-php56", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + }, + "branch-alias": { + "dev-main": "1.20-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, { "name": "symfony/polyfill-php73", "version": "v1.37.0", @@ -14165,22 +14474,22 @@ }, { "name": "team-reflex/discord-php", - "version": "v10.58.0", + "version": "v10.66.1", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP.git", - "reference": "204a825a014e202488ebd9448358e877105620fb" + "reference": "7530d50da2193cbd4a71ed2d81ffda00b1aef009" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/204a825a014e202488ebd9448358e877105620fb", - "reference": "204a825a014e202488ebd9448358e877105620fb", + "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/7530d50da2193cbd4a71ed2d81ffda00b1aef009", + "reference": "7530d50da2193cbd4a71ed2d81ffda00b1aef009", "shasum": "" }, "require": { "discord-php-helpers/collection": "dev-main || ^8.4 || ^8.0", - "discord-php-helpers/voice": "^8.0 || dev-main", - "discord-php/http": "^10.1.7", + "discord-php-helpers/voice": "^8.3 || dev-main", + "discord-php/http": "^10.9.8", "discord/interactions": "^2.2", "ext-zlib": "*", "monolog/monolog": "^2.1.1 || ^3.0", @@ -14193,7 +14502,9 @@ "react/child-process": "^0.6.3", "react/datagram": "^1.8", "react/event-loop": "^1.2", + "react/http": "^1.9", "react/promise": "^3.0.0", + "react/socket": "^1.9", "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0 || ^8.0", "trafficcophp/bytebuffer": "^0.3" }, @@ -14215,7 +14526,9 @@ "ext-fileinfo": "For function mime_content_type().", "ext-gmp": "For 64 bit calculations on x86 (32 bit) PHP.", "ext-mbstring": "For accurate calculations of string length when handling non-english characters.", + "ext-sodium": "For checking the signatures on webhook events. Bundled with PHP; enable it in php.ini.", "ext-uv": "For a faster, and more performant loop. Preferred.", + "ext-zstd": "For Zstandard compression support.", "laracord/laracord": "Provides Laracord integration for DiscordPHP." }, "type": "library", @@ -14246,7 +14559,7 @@ "chat": "https://discord.gg/dphp", "docs": "https://discord-php.github.io/DiscordPHP/", "issues": "https://github.com/discord-php/DiscordPHP/issues", - "source": "https://github.com/discord-php/DiscordPHP/tree/v10.58.0", + "source": "https://github.com/discord-php/DiscordPHP/tree/v10.66.1", "wiki": "https://github.com/discord-php/DiscordPHP/wiki" }, "funding": [ @@ -14263,7 +14576,7 @@ "type": "github" } ], - "time": "2026-09-22T13:56:38+00:00" + "time": "2026-09-28T21:31:05+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -14599,16 +14912,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.24.1", + "version": "v7.25.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "c29bc43a5cdd0124b7eff959064ae83ea902b49a" + "reference": "5aa46dcc0db88156b46114253ce596055f9be563" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/c29bc43a5cdd0124b7eff959064ae83ea902b49a", - "reference": "c29bc43a5cdd0124b7eff959064ae83ea902b49a", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/5aa46dcc0db88156b46114253ce596055f9be563", + "reference": "5aa46dcc0db88156b46114253ce596055f9be563", "shasum": "" }, "require": { @@ -14618,25 +14931,25 @@ "ext-simplexml": "*", "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^14.3.1", - "phpunit/php-file-iterator": "^7", + "php": "~8.4.0 || ~8.5.0 || ~8.6.0", + "phpunit/php-code-coverage": "^14.3.3", + "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-timer": "^9", - "phpunit/phpunit": "^13.3.1", + "phpunit/phpunit": "^13.3.4", "sebastian/environment": "^9.3.2", - "symfony/console": "^7.4.8 || ^8.1.2", - "symfony/process": "^7.4.8 || ^8.1.0" + "symfony/console": "^7.4.8 || ^8.1.7", + "symfony/process": "^7.4.8 || ^8.1.7" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.2.8", + "phpstan/phpstan": "^2.2.15", "phpstan/phpstan-deprecation-rules": "^2.0.5", "phpstan/phpstan-phpunit": "^2.0.18", "phpstan/phpstan-strict-rules": "^2.0.12", - "symfony/filesystem": "^7.4.8 || ^8.1.2" + "symfony/filesystem": "^7.4.8 || ^8.1.6" }, "bin": [ "bin/paratest", @@ -14676,7 +14989,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.24.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.25.0" }, "funding": [ { @@ -14688,7 +15001,7 @@ "type": "paypal" } ], - "time": "2026-08-17T06:31:26+00:00" + "time": "2026-09-24T08:47:36+00:00" }, { "name": "doctrine/deprecations", @@ -14861,23 +15174,23 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d", + "reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "fidry/makefile": "^0.2.0", + "fidry/makefile": "^0.2.0 || ^1.1.2", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", "phpstan/phpstan": "^2.0", @@ -14885,6 +15198,7 @@ "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", "webmozarts/strict-phpunit": "^7.5" }, "type": "library", @@ -14910,7 +15224,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.0" }, "funding": [ { @@ -14918,7 +15232,7 @@ "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2026-09-27T17:40:20+00:00" }, { "name": "filp/whoops", @@ -14993,16 +15307,16 @@ }, { "name": "fruitcake/laravel-debugbar", - "version": "v4.4.3", + "version": "v4.4.4", "source": { "type": "git", "url": "https://github.com/fruitcake/laravel-debugbar.git", - "reference": "caa08515a2eef6d8137653d3a1f3c3387cadf184" + "reference": "02274537e13944ad1990c521bd06d0fb3578ba17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/caa08515a2eef6d8137653d3a1f3c3387cadf184", - "reference": "caa08515a2eef6d8137653d3a1f3c3387cadf184", + "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/02274537e13944ad1990c521bd06d0fb3578ba17", + "reference": "02274537e13944ad1990c521bd06d0fb3578ba17", "shasum": "" }, "require": { @@ -15018,7 +15332,7 @@ }, "require-dev": { "larastan/larastan": "^3", - "laravel/ai": "^0.8", + "laravel/ai": "^0.8|^0.9|^0.10|^0.11|^1.0", "laravel/octane": "^2", "laravel/pennant": "^1", "laravel/pint": "^1", @@ -15080,7 +15394,7 @@ ], "support": { "issues": "https://github.com/fruitcake/laravel-debugbar/issues", - "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.3" + "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.4" }, "funding": [ { @@ -15092,7 +15406,7 @@ "type": "github" } ], - "time": "2026-09-01T13:46:43+00:00" + "time": "2026-09-24T10:05:29+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -15403,16 +15717,16 @@ }, { "name": "laravel/boost", - "version": "v2.9.1", + "version": "v2.10.0", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0" + "reference": "76c236d4eb7d51698f882d149bc367c1ebc0d078" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0", - "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0", + "url": "https://api.github.com/repos/laravel/boost/zipball/76c236d4eb7d51698f882d149bc367c1ebc0d078", + "reference": "76c236d4eb7d51698f882d149bc367c1ebc0d078", "shasum": "" }, "require": { @@ -15465,20 +15779,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-09-17T02:40:42+00:00" + "time": "2026-09-23T09:33:31+00:00" }, { "name": "laravel/mcp", - "version": "v1.0.0", + "version": "v1.0.1", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "cfa4f38f82873eeb6848527883545f98f871e229" + "reference": "92987a9d03847801299ff4abe909ba7686147dde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/cfa4f38f82873eeb6848527883545f98f871e229", - "reference": "cfa4f38f82873eeb6848527883545f98f871e229", + "url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde", + "reference": "92987a9d03847801299ff4abe909ba7686147dde", "shasum": "" }, "require": { @@ -15539,7 +15853,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-09-14T14:35:19+00:00" + "time": "2026-09-24T10:45:09+00:00" }, { "name": "laravel/pail", @@ -17004,20 +17318,25 @@ }, { "name": "phpdocumentor/reflection-common", - "version": "2.2.0", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b", + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.25" }, "type": "library", "extra": { @@ -17041,7 +17360,7 @@ } ], "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", + "homepage": "https://www.phpdoc.org", "keywords": [ "FQSEN", "phpDocumentor", @@ -17051,9 +17370,9 @@ ], "support": { "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1" }, - "time": "2020-06-27T09:03:43+00:00" + "time": "2025-11-10T21:21:55+00:00" }, { "name": "phpdocumentor/reflection-docblock", @@ -17122,16 +17441,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/dbe2133bb23e2e33a6834f4ed417f24a1b167769", + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769", "shasum": "" }, "require": { @@ -17174,9 +17493,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.1.0" }, - "time": "2026-01-06T21:53:42+00:00" + "time": "2026-09-27T19:32:54+00:00" }, { "name": "phpstan/extension-installer", @@ -17228,11 +17547,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.14", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", - "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -17288,20 +17607,20 @@ "type": "github" } ], - "time": "2026-09-12T21:39:33+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.3.3", + "version": "14.3.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f8640c238e930b914d0098a1edbad6652e61a64a" + "reference": "96af7aaa1e15561a67b2fa5b98906b063ebec9c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f8640c238e930b914d0098a1edbad6652e61a64a", - "reference": "f8640c238e930b914d0098a1edbad6652e61a64a", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/96af7aaa1e15561a67b2fa5b98906b063ebec9c2", + "reference": "96af7aaa1e15561a67b2fa5b98906b063ebec9c2", "shasum": "" }, "require": { @@ -17309,7 +17628,7 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.8.0", + "nikic/php-parser": "^5.9.0", "php": ">=8.4", "phpunit/php-text-template": "^6.0", "sebastian/complexity": "^6.0", @@ -17320,7 +17639,7 @@ "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.3.3" + "phpunit/phpunit": "^13.3.4" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -17358,7 +17677,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.5" }, "funding": [ { @@ -17378,7 +17697,7 @@ "type": "tidelift" } ], - "time": "2026-09-10T13:08:32+00:00" + "time": "2026-09-25T08:28:49+00:00" }, { "name": "phpunit/php-file-iterator", diff --git a/config/auth.php b/config/auth.php index 18a46a52f..9c49b178b 100644 --- a/config/auth.php +++ b/config/auth.php @@ -44,6 +44,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + ], ], /* diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 000000000..49ad96366 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,326 @@ + env('JWT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | JWT Authentication Keys + |-------------------------------------------------------------------------- + | + | The algorithm you are using, will determine whether your tokens are + | signed with a random string (defined in `JWT_SECRET`) or using the + | following public & private keys. + | + | Symmetric Algorithms: + | HS256, HS384 & HS512 will use `JWT_SECRET`. + | + | Asymmetric Algorithms: + | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below. + | + */ + + 'keys' => [ + /* + |-------------------------------------------------------------------------- + | Public Key + |-------------------------------------------------------------------------- + | + | A path or resource to your public key. + | + | E.g. 'file://path/to/public/key' + | + */ + + 'public' => env('JWT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Private Key + |-------------------------------------------------------------------------- + | + | A path or resource to your private key. + | + | E.g. 'file://path/to/private/key' + | + */ + + 'private' => env('JWT_PRIVATE_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passphrase + |-------------------------------------------------------------------------- + | + | The passphrase for your private key. Can be null if none set. + | + */ + + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + /* + |-------------------------------------------------------------------------- + | JWT time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token will be valid for. + | Defaults to 1 hour. + | + | You can also set this to null, to yield a never expiring token. + | Some people may want this behaviour for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. + | + */ + + 'ttl' => (int) env('JWT_TTL', 60), + + /* + |-------------------------------------------------------------------------- + | Refresh time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token can be refreshed within. + | This defines the refresh window, during which the user can refresh their token + | before re-authentication is required. + | + | By default, a refresh will NOT issue a new "iat" (issued at) timestamp. If changed + | to true, each refresh will issue a new "iat" timestamp, extending the refresh + | period from the most recent refresh. This results in a rolling refresh + | + | To retain a fluid refresh window from the last refresh action (i.e., the behavior between + | version 2.5.0 and 2.8.2), set "refresh_iat" to true. With this setting, the refresh + | window will renew with each subsequent refresh. + | + | The refresh ttl defaults to 2 weeks. + | + | You can also set this to null, to yield an infinite refresh time. + | Some may want this instead of never expiring tokens for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | + */ + + 'refresh_iat' => env('JWT_REFRESH_IAT', default: false), + 'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20_160), + + /* + |-------------------------------------------------------------------------- + | JWT hashing algorithm + |-------------------------------------------------------------------------- + | + | Specify the hashing algorithm that will be used to sign the token. + | + | See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL + | for possible values. + | + */ + + 'algo' => env('JWT_ALGO', 'HS256'), + + /* + |-------------------------------------------------------------------------- + | Required Claims + |-------------------------------------------------------------------------- + | + | Specify the required claims that must exist in any token. + | A TokenInvalidException will be thrown if any of these claims are not + | present in the payload. + | + */ + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + /* + |-------------------------------------------------------------------------- + | Persistent Claims + |-------------------------------------------------------------------------- + | + | Specify the claim keys to be persisted when refreshing a token. + | `sub` and `iat` will automatically be persisted, in + | addition to the these claims. + | + | Note: If a claim does not exist then it will be ignored. + | + */ + + 'persistent_claims' => [ + // 'foo', + // 'bar', + ], + + /* + |-------------------------------------------------------------------------- + | Lock Subject + |-------------------------------------------------------------------------- + | + | This will determine whether a `prv` claim is automatically added to + | the token. The purpose of this is to ensure that if you have multiple + | authentication models e.g. `App\User` & `App\OtherPerson`, then we + | should prevent one authentication request from impersonating another, + | if 2 tokens happen to have the same id across the 2 different models. + | + | Under specific circumstances, you may want to disable this behaviour + | e.g. if you only have one authentication model, then you would save + | a little on token size. + | + */ + + 'lock_subject' => true, + + /* + |-------------------------------------------------------------------------- + | Leeway + |-------------------------------------------------------------------------- + | + | This property gives the jwt timestamp claims some "leeway". + | Meaning that if you have any unavoidable slight clock skew on + | any of your servers then this will afford you some level of cushioning. + | + | This applies to the claims `iat`, `nbf` and `exp`. + | + | Specify in seconds - only if you know you need it. + | + */ + + 'leeway' => (int) env('JWT_LEEWAY', 0), + + /* + |-------------------------------------------------------------------------- + | Blacklist Enabled + |-------------------------------------------------------------------------- + | + | In order to invalidate tokens, you must have the blacklist enabled. + | If you do not want or need this functionality, then set this to false. + | + */ + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', default: true), + + /* + | ------------------------------------------------------------------------- + | Blacklist Grace Period + | ------------------------------------------------------------------------- + | + | When multiple concurrent requests are made with the same JWT, + | it is possible that some of them fail, due to token regeneration + | on every request. + | + | Set grace period in seconds to prevent parallel request failure. + | + */ + + 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + /* + |-------------------------------------------------------------------------- + | Show blacklisted token option + |-------------------------------------------------------------------------- + | + | Specify if you want to show black listed token exception on the laravel logs. + | + */ + + 'show_black_list_exception' => env('JWT_SHOW_BLACKLIST_EXCEPTION', default: true), + + /* + |-------------------------------------------------------------------------- + | Cookies encryption + |-------------------------------------------------------------------------- + | + | By default Laravel encrypt cookies for security reason. + | If you decide to not decrypt cookies, you will have to configure Laravel + | to not encrypt your cookie token by adding its name into the $except + | array available in the middleware "EncryptCookies" provided by Laravel. + | see https://laravel.com/docs/master/responses#cookies-and-encryption + | for details. + | + | Set it to true if you want to decrypt cookies. + | + */ + + 'decrypt_cookies' => false, + + /* + |-------------------------------------------------------------------------- + | Cookie key name + |-------------------------------------------------------------------------- + | + | Specify the cookie key name that you would like to use for the cookie token. + | + */ + + 'cookie_key_name' => 'token', + + /* + |-------------------------------------------------------------------------- + | Providers + |-------------------------------------------------------------------------- + | + | Specify the various providers used throughout the package. + | + */ + + 'providers' => [ + /* + |-------------------------------------------------------------------------- + | JWT Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to create and decode the tokens. + | + */ + + 'jwt' => Lcobucci::class, + + /* + |-------------------------------------------------------------------------- + | Authentication Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to authenticate users. + | + */ + + 'auth' => Illuminate::class, + + /* + |-------------------------------------------------------------------------- + | Storage Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to store tokens in the blacklist. + | + */ + + 'storage' => PHPOpenSourceSaver\JWTAuth\Providers\Storage\Illuminate::class, + ], +]; diff --git a/config/scramble.php b/config/scramble.php index 750b8ecc0..019f9664d 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess; +use Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy; return [ /* @@ -26,7 +27,7 @@ /* * API version. */ - 'version' => '3.x', + 'version' => '4.x', /* * Description rendered on the home page of the API documentation (`/docs/api`). @@ -134,5 +135,12 @@ RestrictedDocsAccess::class, ], + /* + * Marca como "bearer" as rotas protegidas por qualquer guard "auth:*" + * (ex.: auth:api, o guard JWT da API mobile) — sem isso, os endpoints + * protegidos aparecem na doc como se não precisassem de autenticação. + */ + 'security_strategy' => MiddlewareAuthSecurityStrategy::class, + 'extensions' => [], ]; diff --git a/config/services.php b/config/services.php index b56da8e11..dc425d3ca 100644 --- a/config/services.php +++ b/config/services.php @@ -72,4 +72,8 @@ 'openai' => [ 'api_key' => env('OPENAI_API_KEY'), ], + + 'he4rt_app' => [ + 'deeplink_scheme' => env('HE4RT_APP_DEEPLINK_SCHEME', 'he4rtapp'), + ], ]; diff --git a/docs/plans/2026-09-22-api-mobile-jwt.md b/docs/plans/2026-09-22-api-mobile-jwt.md new file mode 100644 index 000000000..75641e487 --- /dev/null +++ b/docs/plans/2026-09-22-api-mobile-jwt.md @@ -0,0 +1,178 @@ +--- +type: plan +title: 'API mobile do He4rt App — autenticação JWT e endpoints por feature' +module: identity, events, activity, profile +status: proposed +date: 2026-09-22 +author: tecrodrigocastro +related: + prd: he4rt/heartdevs.com#531 +--- + +# Plano — API mobile pro He4rt App (JWT) + +**Goal:** nascer a API que o [he4rt/he4rt-app](https://github.com/he4rt/he4rt-app) (NativePHP Mobile, repo separado) vai consumir por HTTP, cobrindo as três áreas do `panel-app` que o PRD pede: Timeline, Eventos (com check-in) e Perfil — autenticada por JWT, não Sanctum. + +**Por que JWT em vez de Sanctum:** o PoC anterior (documentado no PS da issue #531) já validou Sanctum funcionando (endpoint `/api/mobile/me` autenticado). A troca não é por limitação técnica do Sanctum — é para abrir a porta a **claims customizadas no próprio token** (role, tenant, capabilities) sem precisar de uma query extra por request, o que o Sanctum não oferece nativamente (seus tokens são opacos; abilities existem, mas não claims arbitrárias no payload). v1 usa claims mínimas (`sub`, `iat`, `exp`); o espaço pra crescer fica reservado via `JWTSubject::getJWTCustomClaims()`. + +**Pacote:** [`php-open-source-saver/jwt-auth`](https://github.com/PHP-Open-Source-Saver/jwt-auth) `^2.9` — fork ativo do `tymon/jwt-auth` original (parado), com suporte confirmado a Laravel `^12|^13` e PHP `^8.3`. Compatível com o stack atual (Laravel 13.25, PHP 8.4). + +**O que herdamos do PoC anterior e precisa ser desfeito:** a branch `poc/nativephp-mobile` (PR aberto em `tecrodrigocastro/heartdevs.com#1`) tem `HasApiTokens` no `User`, guard `sanctum` em `config/auth.php`, a migration de `personal_access_tokens` pra UUID, e `MobileMeController`/`api-mobile-routes.php` em `identity`. Esse trabalho vira **ponto de partida** da Feature 0 abaixo, mas o guard e o mecanismo de token trocam de Sanctum pra JWT — não dá pra só mergear como está. + +--- + +## Onde a API vive + +O PRD deixa isso como pergunta em aberto. Recomendação, com precedente já criado pelo próprio PoC: + +**Cada módulo de domínio dono do recurso expõe sua própria fatia da API mobile — sem módulo novo.** + +``` +app-modules/{modulo}/ +├── routes/api-mobile-routes.php <- prefixo /api/mobile/{recurso}, middleware auth:api (JWT) +└── src/Http/Controllers/Mobile/ <- controllers finos, só serializam o retorno de Actions existentes +``` + +Por quê: nenhuma das quatro features abaixo precisa compor domínio de MAIS de um módulo dentro do mesmo endpoint — Timeline só usa `activity`, Eventos só usa `events`, Perfil só usa `profile`, Auth só usa `identity`. `auth()->user()`/`auth()->id()` (guard JWT) resolve a identidade em qualquer um deles sem import cruzado. Isso respeita a regra do `CONTEXT-MAP.md` ("a API depende do domínio, nunca o contrário") sem inventar uma categoria de módulo nova (nem Domain, nem Integration, nem Presentation encaixam perfeitamente num módulo "api-mobile" dedicado). + +Se alguma feature futura precisar compor dois domínios num único payload (ex.: notificação cruzando Events + Activity), aí sim vale reabrir essa decisão. + +--- + +## Arquitetura de autenticação (Feature 0) + +### Guard novo + +`config/auth.php` ganha: + +```php +'guards' => [ + 'api' => ['driver' => 'jwt', 'provider' => 'users'], +], +``` + +`User` (`app-modules/identity/src/User/Models/User.php`) implementa `Tymon\JWTAuth\Contracts\JWTSubject` (nome do namespace do pacote pode variar — conferir na doc do `php-open-source-saver/jwt-auth` no momento de implementar): `getJWTIdentifier()` retorna `$this->getKey()` (UUID), `getJWTCustomClaims()` retorna `[]` na v1. + +### Fluxo OAuth → JWT + +O login web hoje (`OAuthController::getAuthenticate`) termina em `Auth::login()` (sessão) + redirect pro painel Filament. Isso não serve pro mobile — o app não tem sessão, e o controller depende de `filament()->setCurrentPanel()`. Em vez de reescrever esse fluxo, ele nasce **paralelo**, reaproveitando a resolução de usuário: + +1. App abre `Browser::auth()` (plugin do NativePHP) apontando pra `GET /api/mobile/auth/{provider}/redirect` (novo endpoint em `identity`, análogo ao `OAuthController::getRedirect` mas sem depender de painel Filament). +2. Provider (Discord/GitHub/Twitch — os três já suportados via `IdentityProvider::supportedProviders()`) redireciona pro callback do OAuth. +3. **Implementado diferente do rascunho inicial**: Discord/GitHub/Twitch só aceitam UMA `redirect_uri` fixa por app, cadastrada apontando pro callback web (`/auth/oauth/{provider}` → `OAuthController::getAuthenticate`) — não dava pra ter uma rota de callback mobile própria. O callback web compartilhado passou a distinguir por `OAuthIntent::MobileLogin` (lido do `state`) e, quando é esse o caso, gera um **código de troca de uso único** (curto, ~60s de TTL, guardado em cache) e redireciona pro deep link do app em vez de fazer `Auth::login()`. +4. App recebe o deep link (`he4rtapp://oauth/callback?code=...`), extrai o `code`, faz `POST /api/mobile/auth/exchange` com esse código. +5. Endpoint valida o código (uso único, expira, invalida-se após o uso — troca é atômica via `Cache::lock()`, não só `Cache::pull()`), emite `{ access_token, token_type, expires_in }` via `php-open-source-saver/jwt-auth`. + +**Por que um código de troca em vez do JWT direto no deep link:** deep links (e o histórico de URLs do SO) não são um lugar seguro pra um token de longa duração passar. O código de troca é de uso único e vive segundos — se vazar, não serve pra nada depois do primeiro uso. + +### Refresh + +**Implementado diferente do rascunho inicial**: não existe um `refresh_token` separado — `php-open-source-saver/jwt-auth` renova o próprio access token via `JWTGuard::refresh()`, aceitando um token já expirado desde que dentro da janela `jwt.refresh_ttl` e fora da blacklist. `POST /api/mobile/auth/refresh` manda o token atual no header `Authorization` (sem middleware `auth:api`, que rejeitaria um token expirado antes mesmo do controller rodar) e devolve um novo `{ access_token, token_type, expires_in }`. `POST /api/mobile/auth/logout` invalida o token atual via blacklist. + +### Endpoints da Feature 0 + +| Método | Rota | Descrição | +| ------ | -------------------------------------- | ------------------------------------------------------------------------------- | +| GET | `/api/mobile/auth/{provider}/redirect` | Inicia OAuth (Discord/GitHub/Twitch) | +| POST | `/api/mobile/auth/exchange` | Código de troca → token JWT | +| POST | `/api/mobile/auth/refresh` | Token atual (mesmo expirado, dentro da janela) → token novo | +| POST | `/api/mobile/auth/logout` | Invalida o token atual (blacklist) | +| GET | `/api/mobile/me` | Usuário autenticado (id, username, avatar) — já existe no PoC, só troca o guard | + +Não existe rota de callback mobile própria — o callback do provider bate direto em `/auth/oauth/{provider}` (rota web já existente), ver passo 3 acima. + +--- + +## Feature 1 — Timeline + +Domínio: `He4rt\Activity\Timeline\*` (já existe, reaproveitado sem alteração). + +| Método | Rota | Action reaproveitada | +| ------ | -------------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/mobile/timeline` | `TimelineFeed::builder()` (paginação simples, mesmo padrão do `Feed.php` do `panel-app`) | +| POST | `/api/mobile/timeline` | `CreatePost` + `CreatePostDTO` | +| POST | `/api/mobile/timeline/{post}/replies` | `CreateReply` + `CreateReplyDTO` | +| DELETE | `/api/mobile/timeline/replies/{reply}` | `DeleteReply` | + +Sem gap de domínio — é serialização pura em cima do que já existe. O corpo de resposta precisa de um API Resource novo (`TimelinePostResource`) já que a UI mobile não usa view Blade; padrão Eloquent API Resource, conforme `laravel/core` guideline deste repo. + +**Fora do v1, decisão explícita**: reações (`withCount('reactions')` aparece no `Feed.php`) — o PRD não menciona reagir como escopo v1 do app; incluir a contagem na resposta é grátis, mas o endpoint de reagir fica pra depois se a issue não abrir esse escopo. + +--- + +## Feature 2 — Eventos + +Domínio: `He4rt\Events\*`. + +| Método | Rota | Action reaproveitada | +| ------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/mobile/events` | `Event::query()->viewableByParticipant()` (scope já existe, usado no `EventDetail.php`) | +| GET | `/api/mobile/events/{event}` | idem + `enrollmentPolicy`, computa os mesmos booleans do `EventDetail.php` (`canApply`, `canConfirmPresence`, `isEventFull`) num Resource | +| POST | `/api/mobile/events/{event}/enroll` | `EnrollUserAction` + `EnrollUserDTO` (RSVP e Application, mesma Action cobre os dois) | +| GET | `/api/mobile/events/{event}/qr` | Serializa `$enrollment->qrToken->token` (dado cru — o app renderiza o QR nativamente, não precisa do SVG que o Livewire gera) | +| POST | `/api/mobile/events/{event}/check-in` | `NumericCodeCheckInAction` (ver gap abaixo pro método QR) | + +### Gap de domínio encontrado: não existe self-check-in por QR + +O PRD pede "check-in por QR code (via Scanner) com o código numérico como alternativa" — ou seja, os dois métodos alimentando a **mesma ação de self-check-in**, só mudando a forma de entrada (câmera vs teclado). Investigando o domínio (`app-modules/events/src/CheckIn/`): + +- `NumericCodeCheckInAction` — **self-service** (`TriggeredBy::User`, ator = o próprio dono da enrollment), valida contra `CheckInCode` (código compartilhado por evento/dia, com expiração e limite de usos). +- `QrCheckInAction` — **existe, mas é outra coisa**: é o staff escaneando o QR pessoal do participante (`TriggeredBy::Admin` fixo no código, valida contra `QrToken`, token 1:1 com a enrollment). É provavelmente o que alimenta um scanner no `panel-admin`, não o app do participante. + +Não existe hoje uma ação self-service equivalente à `NumericCodeCheckInAction` que aceite entrada por QR. Duas formas de fechar esse gap (decisão a bater com o time antes de codar a Feature 2): + +1. **QR só codifica o mesmo código compartilhado** (`CheckInCode.code`) — o venue projeta o código como texto E como QR na mesma tela; o Scanner do app só preenche o campo automaticamente. Nesse caso não precisa de Action nova: o endpoint aceita o valor decodificado como se fosse digitado, chama `NumericCodeCheckInAction` do mesmo jeito, e o `method` gravado no `CheckIn` continua sendo `NumericCode` (ou passa a receber o `method` como parâmetro pra registrar `QrCode` de verdade — mudança pequena na Action/DTO). +2. **QR é uma entidade própria** (novo token, não o `CheckInCode` compartilhado) — precisa de uma `QrCodeSelfCheckInAction` nova, espelhando a `NumericCodeCheckInAction` mas validando um token diferente. Mais trabalho, mais uma tabela ou reuso do `QrToken` com uma regra nova de quem pode consumir (hoje `QrToken` é pensado pra ser consumido por um `Admin`, não pelo próprio dono). + +Recomendação: opção 1 é bem mais barata e resolve o que o PRD pede ("QR como alternativa ao numérico", não "QR como terceiro sistema"). Fica registrado aqui pra não repetir a investigação — mas é uma decisão de produto, não só técnica, então deveria ser confirmada na issue antes da Feature 2 entrar em código. + +--- + +## Feature 3 — Perfil + +Domínio: `He4rt\Profile\*`. + +| Método | Rota | Action/Model reaproveitado | +| ------ | --------------------- | --------------------------------------------------------------- | +| GET | `/api/mobile/profile` | `Profile::ensureExists(auth()->id())` — mesmo padrão usado hoje | + +v1 é só leitura (o PRD explicitamente escopa "visualização do próprio perfil" — editar fica de fora). Um `ProfileResource` serializa os campos públicos do model (nickname, headline, seniority, social_links, etc.) — sem gap de domínio, `UpsertProfile`/`SyncProfileSkills` já existem se a edição entrar em escopo depois. + +--- + +## Fora de escopo (herdado do PRD, sem mudança) + +- Reaproveitar as classes Livewire do `panel-app` como UI — arquitetura do NativePHP Mobile não permite. +- Funcionalidade de domínio nova além do que já existe no `panel-app` (reações no feed, edição de perfil, etc.) — a não ser que a issue #531 seja atualizada pra abrir esse escopo. +- Notificações push — mesma pendência do PRD original, sem decisão ainda. +- Publicação nas lojas — fora do escopo desta API. + +--- + +## Riscos e perguntas em aberto + +1. **Gap do QR self-check-in (Feature 2)** — decisão de produto, não só técnica; ver seção acima. +2. **Deep link scheme** (`NATIVEPHP_DEEPLINK_SCHEME`) — precisa ser registrado no `he4rt-app` e o valor comunicado pra esta API configurar o redirect do passo 3 do fluxo OAuth. +3. **Rate limiting da API mobile** — os endpoints de escrita (postar, check-in) precisam de throttle próprio, análogo ao `RateLimiter` que `NumericCodeCheckIn.php` já usa no Livewire; replicar o mesmo limite na Action ou no middleware da rota. +4. **Blacklist do refresh token** — `php-open-source-saver/jwt-auth` precisa de um storage pra blacklist (cache/DB); confirmar qual driver de cache este projeto já usa em produção antes de habilitar `JWT_BLACKLIST_ENABLED`. +5. **Claims futuras** — o PRD não pede isso agora; só está sendo deixado como gancho arquitetural (`getJWTCustomClaims()`). Não implementar claims de role/tenant sem um caso de uso concreto puxando. + +--- + +## Ordem de implementação sugerida + +1. **Feature 0 (Auth)** — bloqueia todo o resto; sem token, nenhuma outra feature autentica. +2. **Feature 3 (Perfil)** — menor superfície, bom smoke test do guard `api` novo de ponta a ponta antes de features com escrita. +3. **Feature 1 (Timeline)** — leitura + escrita simples (post/reply), sem gap de domínio. +4. **Feature 2 (Eventos)** — a mais complexa (enrollment + dois métodos de check-in); decisão do gap de QR (seção acima) deveria estar fechada antes de começar. + +Cada feature vira sua própria branch/PR neste repo (`heartdevs.com`), seguindo a convenção `feature/` ou `story/531-` já documentada em `.ai/rules`. O client (`he4rt-app`) consome cada endpoint conforme ele fica pronto — não precisa esperar a API inteira pra começar a integrar a Feature 0/3. + +--- + +## PS: achados da revisão de segurança (CodeRabbit) + +A implementação da Feature 0 passou por revisão automática de segurança antes do merge, que achou dois pontos reais no fluxo de troca de código: + +- **Race condition na troca do código** (corrigido): `Cache::pull()` do Laravel é `get()` + `forget()` como duas chamadas separadas, não atômicas — duas requisições concorrentes com o mesmo código podiam ler o valor antes de qualquer uma apagar, mintando dois tokens da mesma autorização. `ExchangeMobileCodeAction` passou a usar `Cache::lock()` pra serializar leitura+remoção por código. +- **Deep link com custom scheme pode ser sequestrado** (débito técnico conhecido, não fechado nesta PR): `he4rtapp://oauth/callback?code=...` usa um esquema de URL customizado, que não é exclusivo do app — outro app instalado no mesmo aparelho pode registrar o mesmo scheme e interceptar o código antes do app legítimo (TTL curto e uso único não impedem isso, já que o atacante só precisa ser o primeiro a usar). A correção correta é **PKCE** (o app mobile gera um `code_verifier` local, manda só o hash `code_challenge` no redirect, e precisa do verifier original pra completar a troca depois) ou um HTTPS App Link verificado em vez do scheme customizado. Não implementado agora porque depende do `he4rt-app` (ainda só um scaffold) também mudar o lado dele — fica registrado aqui pra não ser esquecido antes do app mobile começar a consumir esse fluxo de verdade. diff --git a/resources/docs/3.x/convencoes-de-codigo.md b/resources/docs/4.x/convencoes-de-codigo.md similarity index 100% rename from resources/docs/3.x/convencoes-de-codigo.md rename to resources/docs/4.x/convencoes-de-codigo.md diff --git a/resources/docs/3.x/documentation.md b/resources/docs/4.x/documentation.md similarity index 100% rename from resources/docs/3.x/documentation.md rename to resources/docs/4.x/documentation.md diff --git a/resources/docs/3.x/installation.md b/resources/docs/4.x/installation.md similarity index 98% rename from resources/docs/3.x/installation.md rename to resources/docs/4.x/installation.md index b07f23426..3ee69b2ed 100644 --- a/resources/docs/3.x/installation.md +++ b/resources/docs/4.x/installation.md @@ -14,7 +14,7 @@ order: 1 ## Versioning Scheme -A documentação do portal é versionada por linha de release (por exemplo, `3.x`). +A documentação do portal é versionada por linha de release (por exemplo, `4.x`). Os arquivos vivem em `resources/docs/{version}/` e os links internos usam o placeholder `{{version}}` para apontar sempre para a versão corrente — assim a mesma página funciona em qualquer linha de release. diff --git a/resources/docs/3.x/primeiro-pull-request.md b/resources/docs/4.x/primeiro-pull-request.md similarity index 100% rename from resources/docs/3.x/primeiro-pull-request.md rename to resources/docs/4.x/primeiro-pull-request.md diff --git a/resources/docs/3.x/releases.md b/resources/docs/4.x/releases.md similarity index 94% rename from resources/docs/3.x/releases.md rename to resources/docs/4.x/releases.md index eece8c215..45028eb71 100644 --- a/resources/docs/3.x/releases.md +++ b/resources/docs/4.x/releases.md @@ -19,7 +19,7 @@ linha de release (`MAJOR.MINOR.PATCH`): - **MINOR** — funcionalidades novas retrocompatíveis. - **PATCH** — correções de bug retrocompatíveis. -A documentação acompanha a linha **MAJOR.x** (por exemplo, `3.x`). Os links entre +A documentação acompanha a linha **MAJOR.x** (por exemplo, `4.x`). Os links entre páginas usam o placeholder `{{version}}` para resolver sempre a versão corrente. diff --git a/resources/docs/3.x/rodando-o-projeto.md b/resources/docs/4.x/rodando-o-projeto.md similarity index 100% rename from resources/docs/3.x/rodando-o-projeto.md rename to resources/docs/4.x/rodando-o-projeto.md