···1414| `AtpClient::hasDomainExtension($domain, $name)` | Check if a request client extension is registered |
1515| `AtpClient::flushExtensions()` | Clear all extensions (useful for testing) |
16161717-The same methods are available on `AtpPublicClient` for unauthenticated extensions.
1818-1917### Extension Types
20182119| Type | Access Pattern | Use Case |
···3028```bash
3129# Create a domain client extension
3230php artisan make:atp-client AnalyticsClient
3333-3434-# Create a public domain client extension
3535-php artisan make:atp-client DiscoverClient --public
36313732# Create a request client extension for an existing domain
3833php artisan make:atp-request MetricsClient --domain=bsky
3939-4040-# Create a public request client extension
4141-php artisan make:atp-request TrendingClient --domain=bsky --public
4234```
43354436The generated files are placed in configurable directories. You can customize these paths in `config/client.php`:
···4638```php
4739'generators' => [
4840 'client_path' => 'app/Services/Clients',
4949- 'client_public_path' => 'app/Services/Clients/Public',
5041 'request_path' => 'app/Services/Clients/Requests',
5151- 'request_public_path' => 'app/Services/Clients/Public/Requests',
5242],
5343```
5444···225215$authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social');
226216```
227217228228-## Public Client Extensions
218218+## Public vs Authenticated Mode
229219230230-The `AtpPublicClient` supports the same extension system for unauthenticated API access:
220220+The `AtpClient` class works in both public and authenticated modes. Both `Atp::public()` and `Atp::as()` return the same `AtpClient` class:
231221232222```php
233233-use SocialDept\AtpClient\Client\Public\AtpPublicClient;
234234-235235-// Domain client extension
236236-AtpPublicClient::extend('discover', fn($atp) => new DiscoverClient($atp));
223223+// Public mode - no authentication
224224+$publicClient = Atp::public('https://public.api.bsky.app');
225225+$publicClient->bsky->actor->getProfile('someone.bsky.social');
237226238238-// Request client extension on existing domain
239239-AtpPublicClient::extendDomain('bsky', 'trending', fn($bsky) => new TrendingClient($bsky));
227227+// Authenticated mode - with session
228228+$authClient = Atp::as('did:plc:xxx');
229229+$authClient->bsky->actor->getProfile('someone.bsky.social');
240230```
241231242242-For public request clients, extend `PublicRequest` instead of `Request`:
243243-244244-```php
245245-<?php
246246-247247-namespace App\Atp;
248248-249249-use SocialDept\AtpClient\Client\Public\Requests\PublicRequest;
250250-251251-class TrendingPublicClient extends PublicRequest
252252-{
253253- public function getPopularFeeds(int $limit = 10): array
254254- {
255255- return $this->atp->bsky->feed->getPopularFeedGenerators($limit)->feeds;
256256- }
257257-}
258258-```
232232+Extensions registered on `AtpClient` work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present.
259233260234## Registering Multiple Extensions
261235···272246 AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
273247 AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky));
274248 AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto));
275275-276276- // Public client extensions
277277- AtpPublicClient::extend('discover', fn($atp) => new DiscoverClient($atp));
278249}
279250```
280251···451422 }
452423}
453424```
425425+426426+### Documenting Scope Requirements
427427+428428+Use the `#[RequiresScope]` attribute to document which OAuth scopes your extension methods require. This helps with documentation and enables scope checking in authenticated mode:
429429+430430+```php
431431+use SocialDept\AtpClient\Attributes\RequiresScope;
432432+use SocialDept\AtpClient\Client\Requests\Request;
433433+use SocialDept\AtpClient\Enums\Scope;
434434+435435+class BskyMetricsClient extends Request
436436+{
437437+ #[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
438438+ public function getTimelineMetrics(): array
439439+ {
440440+ $timeline = $this->atp->bsky->feed->getTimeline();
441441+ // Process and return metrics...
442442+ }
443443+444444+ // Methods without #[RequiresScope] work in both public and authenticated modes
445445+ public function getPublicPostMetrics(string $uri): array
446446+ {
447447+ $thread = $this->atp->bsky->feed->getPostThread($uri);
448448+ // Process and return metrics...
449449+ }
450450+}
451451+```
452452+453453+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.
454454455455## Available Domains
456456
+400
docs/scopes.md
···11+# OAuth Scopes
22+33+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.
44+55+## Quick Reference
66+77+### Scope Enum
88+99+```php
1010+use SocialDept\AtpClient\Enums\Scope;
1111+1212+// Transition scopes (current AT Protocol scopes)
1313+Scope::Atproto // 'atproto' - Full access
1414+Scope::TransitionGeneric // 'transition:generic' - General API access
1515+Scope::TransitionEmail // 'transition:email' - Email access
1616+Scope::TransitionChat // 'transition:chat.bsky' - Chat access
1717+1818+// Granular scope builders (future AT Protocol scopes)
1919+Scope::repo('app.bsky.feed.post', ['create', 'delete']) // Record operations
2020+Scope::rpc('app.bsky.feed.getTimeline') // RPC endpoint access
2121+Scope::blob('image/*') // Blob upload access
2222+Scope::account('email') // Account attribute access
2323+Scope::identity('handle') // Identity attribute access
2424+```
2525+2626+### RequiresScope Attribute
2727+2828+```php
2929+use SocialDept\AtpClient\Attributes\RequiresScope;
3030+use SocialDept\AtpClient\Enums\Scope;
3131+3232+#[RequiresScope(Scope::TransitionGeneric)]
3333+public function getTimeline(): GetTimelineResponse
3434+{
3535+ // Method implementation
3636+}
3737+```
3838+3939+## Understanding AT Protocol Scopes
4040+4141+### Current Transition Scopes
4242+4343+The AT Protocol is currently in a transition period where broad "transition scopes" are used:
4444+4545+| Scope | Description |
4646+|-------|-------------|
4747+| `atproto` | Full access to the AT Protocol |
4848+| `transition:generic` | General API access for most operations |
4949+| `transition:email` | Access to email-related operations |
5050+| `transition:chat.bsky` | Access to Bluesky chat features |
5151+5252+### Future Granular Scopes
5353+5454+The AT Protocol is moving toward granular scopes that provide fine-grained access control:
5555+5656+```php
5757+// Record operations
5858+'repo:app.bsky.feed.post' // All operations on posts
5959+'repo:app.bsky.feed.post?action=create' // Only create posts
6060+'repo:app.bsky.feed.like?action=create&action=delete' // Create or delete likes
6161+'repo:*' // All collections, all actions
6262+6363+// RPC endpoint access
6464+'rpc:app.bsky.feed.getTimeline' // Access to timeline endpoint
6565+'rpc:app.bsky.feed.*' // All feed endpoints
6666+6767+// Blob operations
6868+'blob:image/*' // Upload images
6969+'blob:*/*' // Upload any blob type
7070+7171+// Account and identity
7272+'account:email' // Access email
7373+'identity:handle' // Manage handle
7474+```
7575+7676+## The RequiresScope Attribute
7777+7878+The `#[RequiresScope]` attribute documents and optionally enforces scope requirements on methods.
7979+8080+### Basic Usage
8181+8282+```php
8383+<?php
8484+8585+namespace App\Atp;
8686+8787+use SocialDept\AtpClient\Attributes\RequiresScope;
8888+use SocialDept\AtpClient\Client\Requests\Request;
8989+use SocialDept\AtpClient\Enums\Scope;
9090+9191+class CustomClient extends Request
9292+{
9393+ #[RequiresScope(Scope::TransitionGeneric)]
9494+ public function getTimeline(): array
9595+ {
9696+ return $this->atp->client->get('app.bsky.feed.getTimeline')->json();
9797+ }
9898+}
9999+```
100100+101101+### With Granular Scope
102102+103103+Document the future granular scope that will replace the transition scope:
104104+105105+```php
106106+#[RequiresScope(
107107+ Scope::TransitionGeneric,
108108+ granular: 'rpc:app.bsky.feed.getTimeline'
109109+)]
110110+public function getTimeline(): GetTimelineResponse
111111+{
112112+ // ...
113113+}
114114+```
115115+116116+### With Description
117117+118118+Add a human-readable description for documentation:
119119+120120+```php
121121+#[RequiresScope(
122122+ Scope::TransitionGeneric,
123123+ granular: 'rpc:app.bsky.feed.getTimeline',
124124+ description: 'Access to the user\'s home timeline'
125125+)]
126126+public function getTimeline(): GetTimelineResponse
127127+{
128128+ // ...
129129+}
130130+```
131131+132132+### Multiple Scopes (AND Logic)
133133+134134+When a method requires multiple scopes, all must be present:
135135+136136+```php
137137+#[RequiresScope([Scope::TransitionGeneric, Scope::TransitionEmail])]
138138+public function getEmailPreferences(): array
139139+{
140140+ // Requires BOTH scopes
141141+}
142142+```
143143+144144+### Multiple Attributes (OR Logic)
145145+146146+Use multiple attributes for alternative scope requirements:
147147+148148+```php
149149+#[RequiresScope(Scope::Atproto)]
150150+#[RequiresScope(Scope::TransitionGeneric)]
151151+public function getProfile(string $actor): ProfileViewDetailed
152152+{
153153+ // Either scope satisfies the requirement
154154+}
155155+```
156156+157157+## Scope Enforcement
158158+159159+### Configuration
160160+161161+Configure scope enforcement in `config/client.php` or via environment variables:
162162+163163+```php
164164+'scope_enforcement' => ScopeEnforcementLevel::Permissive,
165165+```
166166+167167+| Level | Behavior |
168168+|-------|----------|
169169+| `Strict` | Throws `MissingScopeException` if required scopes are missing |
170170+| `Permissive` | Logs a warning but attempts the request anyway |
171171+172172+Set via environment variable:
173173+174174+```env
175175+ATP_SCOPE_ENFORCEMENT=strict
176176+```
177177+178178+### Programmatic Scope Checking
179179+180180+Check scopes programmatically using the `ScopeChecker`:
181181+182182+```php
183183+use SocialDept\AtpClient\Auth\ScopeChecker;
184184+use SocialDept\AtpClient\Facades\Atp;
185185+186186+$checker = app(ScopeChecker::class);
187187+$session = Atp::as($did)->client->session();
188188+189189+// Check if session has a scope
190190+if ($checker->hasScope($session, Scope::TransitionGeneric)) {
191191+ // Session has the scope
192192+}
193193+194194+// Check multiple scopes
195195+if ($checker->check($session, [Scope::TransitionGeneric, Scope::TransitionEmail])) {
196196+ // Session has ALL required scopes
197197+}
198198+199199+// Check and fail if missing (respects enforcement level)
200200+$checker->checkOrFail($session, [Scope::TransitionGeneric]);
201201+202202+// Check repo scope for specific action
203203+if ($checker->checkRepoScope($session, 'app.bsky.feed.post', 'create')) {
204204+ // Can create posts
205205+}
206206+```
207207+208208+### Granular Pattern Matching
209209+210210+The scope checker supports wildcard patterns:
211211+212212+```php
213213+// Check if session can access any feed endpoint
214214+$checker->matchesGranular($session, 'rpc:app.bsky.feed.*');
215215+216216+// Check if session can upload images
217217+$checker->matchesGranular($session, 'blob:image/*');
218218+219219+// Check if session has any repo access
220220+$checker->matchesGranular($session, 'repo:*');
221221+```
222222+223223+## Route Middleware
224224+225225+Protect Laravel routes based on ATP session scopes:
226226+227227+```php
228228+use Illuminate\Support\Facades\Route;
229229+230230+// Single scope
231231+Route::get('/timeline', TimelineController::class)
232232+ ->middleware('atp.scope:transition:generic');
233233+234234+// Multiple scopes (AND logic)
235235+Route::get('/email-settings', EmailSettingsController::class)
236236+ ->middleware('atp.scope:transition:generic,transition:email');
237237+```
238238+239239+### Middleware Configuration
240240+241241+Configure middleware behavior in `config/client.php`:
242242+243243+```php
244244+'scope_authorization' => [
245245+ // What to do when scope check fails
246246+ 'failure_action' => ScopeAuthorizationFailure::Abort, // abort, redirect, or exception
247247+248248+ // Where to redirect (when failure_action is 'redirect')
249249+ 'redirect_to' => '/login',
250250+],
251251+```
252252+253253+| Failure Action | Behavior |
254254+|----------------|----------|
255255+| `Abort` | Returns 403 Forbidden response |
256256+| `Redirect` | Redirects to configured URL |
257257+| `Exception` | Throws `ScopeAuthorizationException` |
258258+259259+Set via environment variables:
260260+261261+```env
262262+ATP_SCOPE_FAILURE_ACTION=redirect
263263+ATP_SCOPE_REDIRECT=/auth/login
264264+```
265265+266266+### User Model Integration
267267+268268+For the middleware to work, your User model must implement `HasAtpSession`:
269269+270270+```php
271271+<?php
272272+273273+namespace App\Models;
274274+275275+use Illuminate\Foundation\Auth\User as Authenticatable;
276276+use SocialDept\AtpClient\Contracts\HasAtpSession;
277277+278278+class User extends Authenticatable implements HasAtpSession
279279+{
280280+ public function getAtpDid(): ?string
281281+ {
282282+ return $this->atp_did;
283283+ }
284284+}
285285+```
286286+287287+## Public Mode and Scopes
288288+289289+When using `Atp::public()`, no scope checking occurs because there's no authenticated session:
290290+291291+```php
292292+// Public mode - no authentication, no scopes
293293+$client = Atp::public('https://public.api.bsky.app');
294294+$client->bsky->actor->getProfile('someone.bsky.social'); // Works without scopes
295295+296296+// Authenticated mode - scopes are checked
297297+$client = Atp::as($did);
298298+$client->bsky->feed->getTimeline(); // Requires transition:generic scope
299299+```
300300+301301+Methods that work in public mode typically don't have `#[RequiresScope]` attributes, while authenticated-only methods do.
302302+303303+## Exception Handling
304304+305305+### MissingScopeException
306306+307307+Thrown when required scopes are missing and enforcement is strict:
308308+309309+```php
310310+use SocialDept\AtpClient\Exceptions\MissingScopeException;
311311+312312+try {
313313+ $timeline = $client->bsky->feed->getTimeline();
314314+} catch (MissingScopeException $e) {
315315+ $missing = $e->getMissingScopes(); // Scopes that are missing
316316+ $granted = $e->getGrantedScopes(); // Scopes the session has
317317+318318+ // Handle missing scope
319319+}
320320+```
321321+322322+### ScopeAuthorizationException
323323+324324+Thrown by middleware when route access is denied:
325325+326326+```php
327327+use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
328328+329329+try {
330330+ // Route protected by atp.scope middleware
331331+} catch (ScopeAuthorizationException $e) {
332332+ $required = $e->getRequiredScopes();
333333+ $granted = $e->getGrantedScopes();
334334+ $message = $e->getMessage();
335335+}
336336+```
337337+338338+## Best Practices
339339+340340+### 1. Document All Scope Requirements
341341+342342+Always add `#[RequiresScope]` to methods that require authentication:
343343+344344+```php
345345+#[RequiresScope(
346346+ Scope::TransitionGeneric,
347347+ granular: 'rpc:app.bsky.feed.getTimeline',
348348+ description: 'Fetches the authenticated user\'s home timeline'
349349+)]
350350+public function getTimeline(): GetTimelineResponse
351351+```
352352+353353+### 2. Use the Scope Enum
354354+355355+Prefer the `Scope` enum over string literals for type safety:
356356+357357+```php
358358+// Good
359359+#[RequiresScope(Scope::TransitionGeneric)]
360360+361361+// Avoid
362362+#[RequiresScope('transition:generic')]
363363+```
364364+365365+### 3. Request Minimal Scopes
366366+367367+When implementing OAuth, request only the scopes your application needs:
368368+369369+```php
370370+$authUrl = Atp::oauth()->getAuthorizationUrl([
371371+ 'scope' => 'atproto transition:generic',
372372+]);
373373+```
374374+375375+### 4. Handle Missing Scopes Gracefully
376376+377377+Check for scope availability before attempting operations:
378378+379379+```php
380380+$checker = app(ScopeChecker::class);
381381+$session = $client->client->session();
382382+383383+if ($checker->hasScope($session, Scope::TransitionChat)) {
384384+ $conversations = $client->chat->getConversations();
385385+} else {
386386+ // Inform user they need to re-authorize with chat scope
387387+}
388388+```
389389+390390+### 5. Use Permissive Mode in Development
391391+392392+Start with permissive enforcement during development, then switch to strict for production:
393393+394394+```env
395395+# .env.local
396396+ATP_SCOPE_ENFORCEMENT=permissive
397397+398398+# .env.production
399399+ATP_SCOPE_ENFORCEMENT=strict
400400+```