Laravel AT Protocol Client (alpha & unstable)

Add scope documentation and update extensions docs

Changed files
+438 -38
docs
+38 -38
docs/extensions.md
··· 14 14 | `AtpClient::hasDomainExtension($domain, $name)` | Check if a request client extension is registered | 15 15 | `AtpClient::flushExtensions()` | Clear all extensions (useful for testing) | 16 16 17 - The same methods are available on `AtpPublicClient` for unauthenticated extensions. 18 - 19 17 ### Extension Types 20 18 21 19 | Type | Access Pattern | Use Case | ··· 30 28 ```bash 31 29 # Create a domain client extension 32 30 php artisan make:atp-client AnalyticsClient 33 - 34 - # Create a public domain client extension 35 - php artisan make:atp-client DiscoverClient --public 36 31 37 32 # Create a request client extension for an existing domain 38 33 php artisan make:atp-request MetricsClient --domain=bsky 39 - 40 - # Create a public request client extension 41 - php artisan make:atp-request TrendingClient --domain=bsky --public 42 34 ``` 43 35 44 36 The generated files are placed in configurable directories. You can customize these paths in `config/client.php`: ··· 46 38 ```php 47 39 'generators' => [ 48 40 'client_path' => 'app/Services/Clients', 49 - 'client_public_path' => 'app/Services/Clients/Public', 50 41 'request_path' => 'app/Services/Clients/Requests', 51 - 'request_public_path' => 'app/Services/Clients/Public/Requests', 52 42 ], 53 43 ``` 54 44 ··· 225 215 $authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social'); 226 216 ``` 227 217 228 - ## Public Client Extensions 218 + ## Public vs Authenticated Mode 229 219 230 - The `AtpPublicClient` supports the same extension system for unauthenticated API access: 220 + The `AtpClient` class works in both public and authenticated modes. Both `Atp::public()` and `Atp::as()` return the same `AtpClient` class: 231 221 232 222 ```php 233 - use SocialDept\AtpClient\Client\Public\AtpPublicClient; 234 - 235 - // Domain client extension 236 - AtpPublicClient::extend('discover', fn($atp) => new DiscoverClient($atp)); 223 + // Public mode - no authentication 224 + $publicClient = Atp::public('https://public.api.bsky.app'); 225 + $publicClient->bsky->actor->getProfile('someone.bsky.social'); 237 226 238 - // Request client extension on existing domain 239 - AtpPublicClient::extendDomain('bsky', 'trending', fn($bsky) => new TrendingClient($bsky)); 227 + // Authenticated mode - with session 228 + $authClient = Atp::as('did:plc:xxx'); 229 + $authClient->bsky->actor->getProfile('someone.bsky.social'); 240 230 ``` 241 231 242 - For public request clients, extend `PublicRequest` instead of `Request`: 243 - 244 - ```php 245 - <?php 246 - 247 - namespace App\Atp; 248 - 249 - use SocialDept\AtpClient\Client\Public\Requests\PublicRequest; 250 - 251 - class TrendingPublicClient extends PublicRequest 252 - { 253 - public function getPopularFeeds(int $limit = 10): array 254 - { 255 - return $this->atp->bsky->feed->getPopularFeedGenerators($limit)->feeds; 256 - } 257 - } 258 - ``` 232 + Extensions registered on `AtpClient` work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present. 259 233 260 234 ## Registering Multiple Extensions 261 235 ··· 272 246 AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky)); 273 247 AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky)); 274 248 AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto)); 275 - 276 - // Public client extensions 277 - AtpPublicClient::extend('discover', fn($atp) => new DiscoverClient($atp)); 278 249 } 279 250 ``` 280 251 ··· 451 422 } 452 423 } 453 424 ``` 425 + 426 + ### Documenting Scope Requirements 427 + 428 + Use the `#[RequiresScope]` attribute to document which OAuth scopes your extension methods require. This helps with documentation and enables scope checking in authenticated mode: 429 + 430 + ```php 431 + use SocialDept\AtpClient\Attributes\RequiresScope; 432 + use SocialDept\AtpClient\Client\Requests\Request; 433 + use SocialDept\AtpClient\Enums\Scope; 434 + 435 + class BskyMetricsClient extends Request 436 + { 437 + #[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')] 438 + public function getTimelineMetrics(): array 439 + { 440 + $timeline = $this->atp->bsky->feed->getTimeline(); 441 + // Process and return metrics... 442 + } 443 + 444 + // Methods without #[RequiresScope] work in both public and authenticated modes 445 + public function getPublicPostMetrics(string $uri): array 446 + { 447 + $thread = $this->atp->bsky->feed->getPostThread($uri); 448 + // Process and return metrics... 449 + } 450 + } 451 + ``` 452 + 453 + Methods with `#[RequiresScope]` indicate they require authentication, while methods without it can work in public mode. See [scopes.md](scopes.md) for full documentation on scope handling. 454 454 455 455 ## Available Domains 456 456
+400
docs/scopes.md
··· 1 + # OAuth Scopes 2 + 3 + The AT Protocol uses OAuth scopes to control what actions an application can perform on behalf of a user. AtpClient provides tools for documenting, checking, and enforcing scope requirements. 4 + 5 + ## Quick Reference 6 + 7 + ### Scope Enum 8 + 9 + ```php 10 + use SocialDept\AtpClient\Enums\Scope; 11 + 12 + // Transition scopes (current AT Protocol scopes) 13 + Scope::Atproto // 'atproto' - Full access 14 + Scope::TransitionGeneric // 'transition:generic' - General API access 15 + Scope::TransitionEmail // 'transition:email' - Email access 16 + Scope::TransitionChat // 'transition:chat.bsky' - Chat access 17 + 18 + // Granular scope builders (future AT Protocol scopes) 19 + Scope::repo('app.bsky.feed.post', ['create', 'delete']) // Record operations 20 + Scope::rpc('app.bsky.feed.getTimeline') // RPC endpoint access 21 + Scope::blob('image/*') // Blob upload access 22 + Scope::account('email') // Account attribute access 23 + Scope::identity('handle') // Identity attribute access 24 + ``` 25 + 26 + ### RequiresScope Attribute 27 + 28 + ```php 29 + use SocialDept\AtpClient\Attributes\RequiresScope; 30 + use SocialDept\AtpClient\Enums\Scope; 31 + 32 + #[RequiresScope(Scope::TransitionGeneric)] 33 + public function getTimeline(): GetTimelineResponse 34 + { 35 + // Method implementation 36 + } 37 + ``` 38 + 39 + ## Understanding AT Protocol Scopes 40 + 41 + ### Current Transition Scopes 42 + 43 + The AT Protocol is currently in a transition period where broad "transition scopes" are used: 44 + 45 + | Scope | Description | 46 + |-------|-------------| 47 + | `atproto` | Full access to the AT Protocol | 48 + | `transition:generic` | General API access for most operations | 49 + | `transition:email` | Access to email-related operations | 50 + | `transition:chat.bsky` | Access to Bluesky chat features | 51 + 52 + ### Future Granular Scopes 53 + 54 + The AT Protocol is moving toward granular scopes that provide fine-grained access control: 55 + 56 + ```php 57 + // Record operations 58 + 'repo:app.bsky.feed.post' // All operations on posts 59 + 'repo:app.bsky.feed.post?action=create' // Only create posts 60 + 'repo:app.bsky.feed.like?action=create&action=delete' // Create or delete likes 61 + 'repo:*' // All collections, all actions 62 + 63 + // RPC endpoint access 64 + 'rpc:app.bsky.feed.getTimeline' // Access to timeline endpoint 65 + 'rpc:app.bsky.feed.*' // All feed endpoints 66 + 67 + // Blob operations 68 + 'blob:image/*' // Upload images 69 + 'blob:*/*' // Upload any blob type 70 + 71 + // Account and identity 72 + 'account:email' // Access email 73 + 'identity:handle' // Manage handle 74 + ``` 75 + 76 + ## The RequiresScope Attribute 77 + 78 + The `#[RequiresScope]` attribute documents and optionally enforces scope requirements on methods. 79 + 80 + ### Basic Usage 81 + 82 + ```php 83 + <?php 84 + 85 + namespace App\Atp; 86 + 87 + use SocialDept\AtpClient\Attributes\RequiresScope; 88 + use SocialDept\AtpClient\Client\Requests\Request; 89 + use SocialDept\AtpClient\Enums\Scope; 90 + 91 + class CustomClient extends Request 92 + { 93 + #[RequiresScope(Scope::TransitionGeneric)] 94 + public function getTimeline(): array 95 + { 96 + return $this->atp->client->get('app.bsky.feed.getTimeline')->json(); 97 + } 98 + } 99 + ``` 100 + 101 + ### With Granular Scope 102 + 103 + Document the future granular scope that will replace the transition scope: 104 + 105 + ```php 106 + #[RequiresScope( 107 + Scope::TransitionGeneric, 108 + granular: 'rpc:app.bsky.feed.getTimeline' 109 + )] 110 + public function getTimeline(): GetTimelineResponse 111 + { 112 + // ... 113 + } 114 + ``` 115 + 116 + ### With Description 117 + 118 + Add a human-readable description for documentation: 119 + 120 + ```php 121 + #[RequiresScope( 122 + Scope::TransitionGeneric, 123 + granular: 'rpc:app.bsky.feed.getTimeline', 124 + description: 'Access to the user\'s home timeline' 125 + )] 126 + public function getTimeline(): GetTimelineResponse 127 + { 128 + // ... 129 + } 130 + ``` 131 + 132 + ### Multiple Scopes (AND Logic) 133 + 134 + When a method requires multiple scopes, all must be present: 135 + 136 + ```php 137 + #[RequiresScope([Scope::TransitionGeneric, Scope::TransitionEmail])] 138 + public function getEmailPreferences(): array 139 + { 140 + // Requires BOTH scopes 141 + } 142 + ``` 143 + 144 + ### Multiple Attributes (OR Logic) 145 + 146 + Use multiple attributes for alternative scope requirements: 147 + 148 + ```php 149 + #[RequiresScope(Scope::Atproto)] 150 + #[RequiresScope(Scope::TransitionGeneric)] 151 + public function getProfile(string $actor): ProfileViewDetailed 152 + { 153 + // Either scope satisfies the requirement 154 + } 155 + ``` 156 + 157 + ## Scope Enforcement 158 + 159 + ### Configuration 160 + 161 + Configure scope enforcement in `config/client.php` or via environment variables: 162 + 163 + ```php 164 + 'scope_enforcement' => ScopeEnforcementLevel::Permissive, 165 + ``` 166 + 167 + | Level | Behavior | 168 + |-------|----------| 169 + | `Strict` | Throws `MissingScopeException` if required scopes are missing | 170 + | `Permissive` | Logs a warning but attempts the request anyway | 171 + 172 + Set via environment variable: 173 + 174 + ```env 175 + ATP_SCOPE_ENFORCEMENT=strict 176 + ``` 177 + 178 + ### Programmatic Scope Checking 179 + 180 + Check scopes programmatically using the `ScopeChecker`: 181 + 182 + ```php 183 + use SocialDept\AtpClient\Auth\ScopeChecker; 184 + use SocialDept\AtpClient\Facades\Atp; 185 + 186 + $checker = app(ScopeChecker::class); 187 + $session = Atp::as($did)->client->session(); 188 + 189 + // Check if session has a scope 190 + if ($checker->hasScope($session, Scope::TransitionGeneric)) { 191 + // Session has the scope 192 + } 193 + 194 + // Check multiple scopes 195 + if ($checker->check($session, [Scope::TransitionGeneric, Scope::TransitionEmail])) { 196 + // Session has ALL required scopes 197 + } 198 + 199 + // Check and fail if missing (respects enforcement level) 200 + $checker->checkOrFail($session, [Scope::TransitionGeneric]); 201 + 202 + // Check repo scope for specific action 203 + if ($checker->checkRepoScope($session, 'app.bsky.feed.post', 'create')) { 204 + // Can create posts 205 + } 206 + ``` 207 + 208 + ### Granular Pattern Matching 209 + 210 + The scope checker supports wildcard patterns: 211 + 212 + ```php 213 + // Check if session can access any feed endpoint 214 + $checker->matchesGranular($session, 'rpc:app.bsky.feed.*'); 215 + 216 + // Check if session can upload images 217 + $checker->matchesGranular($session, 'blob:image/*'); 218 + 219 + // Check if session has any repo access 220 + $checker->matchesGranular($session, 'repo:*'); 221 + ``` 222 + 223 + ## Route Middleware 224 + 225 + Protect Laravel routes based on ATP session scopes: 226 + 227 + ```php 228 + use Illuminate\Support\Facades\Route; 229 + 230 + // Single scope 231 + Route::get('/timeline', TimelineController::class) 232 + ->middleware('atp.scope:transition:generic'); 233 + 234 + // Multiple scopes (AND logic) 235 + Route::get('/email-settings', EmailSettingsController::class) 236 + ->middleware('atp.scope:transition:generic,transition:email'); 237 + ``` 238 + 239 + ### Middleware Configuration 240 + 241 + Configure middleware behavior in `config/client.php`: 242 + 243 + ```php 244 + 'scope_authorization' => [ 245 + // What to do when scope check fails 246 + 'failure_action' => ScopeAuthorizationFailure::Abort, // abort, redirect, or exception 247 + 248 + // Where to redirect (when failure_action is 'redirect') 249 + 'redirect_to' => '/login', 250 + ], 251 + ``` 252 + 253 + | Failure Action | Behavior | 254 + |----------------|----------| 255 + | `Abort` | Returns 403 Forbidden response | 256 + | `Redirect` | Redirects to configured URL | 257 + | `Exception` | Throws `ScopeAuthorizationException` | 258 + 259 + Set via environment variables: 260 + 261 + ```env 262 + ATP_SCOPE_FAILURE_ACTION=redirect 263 + ATP_SCOPE_REDIRECT=/auth/login 264 + ``` 265 + 266 + ### User Model Integration 267 + 268 + For the middleware to work, your User model must implement `HasAtpSession`: 269 + 270 + ```php 271 + <?php 272 + 273 + namespace App\Models; 274 + 275 + use Illuminate\Foundation\Auth\User as Authenticatable; 276 + use SocialDept\AtpClient\Contracts\HasAtpSession; 277 + 278 + class User extends Authenticatable implements HasAtpSession 279 + { 280 + public function getAtpDid(): ?string 281 + { 282 + return $this->atp_did; 283 + } 284 + } 285 + ``` 286 + 287 + ## Public Mode and Scopes 288 + 289 + When using `Atp::public()`, no scope checking occurs because there's no authenticated session: 290 + 291 + ```php 292 + // Public mode - no authentication, no scopes 293 + $client = Atp::public('https://public.api.bsky.app'); 294 + $client->bsky->actor->getProfile('someone.bsky.social'); // Works without scopes 295 + 296 + // Authenticated mode - scopes are checked 297 + $client = Atp::as($did); 298 + $client->bsky->feed->getTimeline(); // Requires transition:generic scope 299 + ``` 300 + 301 + Methods that work in public mode typically don't have `#[RequiresScope]` attributes, while authenticated-only methods do. 302 + 303 + ## Exception Handling 304 + 305 + ### MissingScopeException 306 + 307 + Thrown when required scopes are missing and enforcement is strict: 308 + 309 + ```php 310 + use SocialDept\AtpClient\Exceptions\MissingScopeException; 311 + 312 + try { 313 + $timeline = $client->bsky->feed->getTimeline(); 314 + } catch (MissingScopeException $e) { 315 + $missing = $e->getMissingScopes(); // Scopes that are missing 316 + $granted = $e->getGrantedScopes(); // Scopes the session has 317 + 318 + // Handle missing scope 319 + } 320 + ``` 321 + 322 + ### ScopeAuthorizationException 323 + 324 + Thrown by middleware when route access is denied: 325 + 326 + ```php 327 + use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException; 328 + 329 + try { 330 + // Route protected by atp.scope middleware 331 + } catch (ScopeAuthorizationException $e) { 332 + $required = $e->getRequiredScopes(); 333 + $granted = $e->getGrantedScopes(); 334 + $message = $e->getMessage(); 335 + } 336 + ``` 337 + 338 + ## Best Practices 339 + 340 + ### 1. Document All Scope Requirements 341 + 342 + Always add `#[RequiresScope]` to methods that require authentication: 343 + 344 + ```php 345 + #[RequiresScope( 346 + Scope::TransitionGeneric, 347 + granular: 'rpc:app.bsky.feed.getTimeline', 348 + description: 'Fetches the authenticated user\'s home timeline' 349 + )] 350 + public function getTimeline(): GetTimelineResponse 351 + ``` 352 + 353 + ### 2. Use the Scope Enum 354 + 355 + Prefer the `Scope` enum over string literals for type safety: 356 + 357 + ```php 358 + // Good 359 + #[RequiresScope(Scope::TransitionGeneric)] 360 + 361 + // Avoid 362 + #[RequiresScope('transition:generic')] 363 + ``` 364 + 365 + ### 3. Request Minimal Scopes 366 + 367 + When implementing OAuth, request only the scopes your application needs: 368 + 369 + ```php 370 + $authUrl = Atp::oauth()->getAuthorizationUrl([ 371 + 'scope' => 'atproto transition:generic', 372 + ]); 373 + ``` 374 + 375 + ### 4. Handle Missing Scopes Gracefully 376 + 377 + Check for scope availability before attempting operations: 378 + 379 + ```php 380 + $checker = app(ScopeChecker::class); 381 + $session = $client->client->session(); 382 + 383 + if ($checker->hasScope($session, Scope::TransitionChat)) { 384 + $conversations = $client->chat->getConversations(); 385 + } else { 386 + // Inform user they need to re-authorize with chat scope 387 + } 388 + ``` 389 + 390 + ### 5. Use Permissive Mode in Development 391 + 392 + Start with permissive enforcement during development, then switch to strict for production: 393 + 394 + ```env 395 + # .env.local 396 + ATP_SCOPE_ENFORCEMENT=permissive 397 + 398 + # .env.production 399 + ATP_SCOPE_ENFORCEMENT=strict 400 + ```