···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;
223223+// Public mode - no authentication
224224+$publicClient = Atp::public('https://public.api.bsky.app');
225225+$publicClient->bsky->actor->getProfile('someone.bsky.social');
234226235235-// Domain client extension
236236-AtpPublicClient::extend('discover', fn($atp) => new DiscoverClient($atp));
237237-238238-// 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 `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes to document the authentication requirements of your extension methods:
429429+430430+```php
431431+use SocialDept\AtpClient\Attributes\PublicEndpoint;
432432+use SocialDept\AtpClient\Attributes\ScopedEndpoint;
433433+use SocialDept\AtpClient\Client\Requests\Request;
434434+use SocialDept\AtpClient\Enums\Scope;
435435+436436+class BskyMetricsClient extends Request
437437+{
438438+ #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
439439+ public function getTimelineMetrics(): array
440440+ {
441441+ $timeline = $this->atp->bsky->feed->getTimeline();
442442+ // Process and return metrics...
443443+ }
444444+445445+ #[PublicEndpoint]
446446+ public function getPublicPostMetrics(string $uri): array
447447+ {
448448+ $thread = $this->atp->bsky->feed->getPostThread($uri);
449449+ // Process and return metrics...
450450+ }
451451+}
452452+```
453453+454454+> **Note:** These attributes currently serve as documentation only. Runtime scope enforcement will be implemented in a future release. Using them correctly now ensures forward compatibility.
455455+456456+Methods with `#[ScopedEndpoint]` indicate they require authentication, while methods with `#[PublicEndpoint]` work without authentication. See [scopes.md](scopes.md) for full documentation on scope handling.
454457455458## Available Domains
456459
+408
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 attributes for documenting scope requirements on endpoints.
44+55+> **Note:** The `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes currently serve as documentation only. Runtime scope validation and enforcement will be implemented in a future release. Using these attributes correctly now ensures forward compatibility.
66+77+## Quick Reference
88+99+### Scope Enum
1010+1111+```php
1212+use SocialDept\AtpClient\Enums\Scope;
1313+1414+// Transition scopes (current AT Protocol scopes)
1515+Scope::Atproto // 'atproto' - Full access
1616+Scope::TransitionGeneric // 'transition:generic' - General API access
1717+Scope::TransitionEmail // 'transition:email' - Email access
1818+Scope::TransitionChat // 'transition:chat.bsky' - Chat access
1919+2020+// Granular scope builders (future AT Protocol scopes)
2121+Scope::repo('app.bsky.feed.post', ['create', 'delete']) // Record operations
2222+Scope::rpc('app.bsky.feed.getTimeline') // RPC endpoint access
2323+Scope::blob('image/*') // Blob upload access
2424+Scope::account('email') // Account attribute access
2525+Scope::identity('handle') // Identity attribute access
2626+```
2727+2828+### ScopedEndpoint Attribute
2929+3030+```php
3131+use SocialDept\AtpClient\Attributes\ScopedEndpoint;
3232+use SocialDept\AtpClient\Enums\Scope;
3333+3434+#[ScopedEndpoint(Scope::TransitionGeneric)]
3535+public function getTimeline(): GetTimelineResponse
3636+{
3737+ // Method implementation
3838+}
3939+```
4040+4141+## Understanding AT Protocol Scopes
4242+4343+### Current Transition Scopes
4444+4545+The AT Protocol is currently in a transition period where broad "transition scopes" are used:
4646+4747+| Scope | Description |
4848+|-------|-------------|
4949+| `atproto` | Full access to the AT Protocol |
5050+| `transition:generic` | General API access for most operations |
5151+| `transition:email` | Access to email-related operations |
5252+| `transition:chat.bsky` | Access to Bluesky chat features |
5353+5454+### Future Granular Scopes
5555+5656+The AT Protocol is moving toward granular scopes that provide fine-grained access control:
5757+5858+```php
5959+// Record operations
6060+'repo:app.bsky.feed.post' // All operations on posts
6161+'repo:app.bsky.feed.post?action=create' // Only create posts
6262+'repo:app.bsky.feed.like?action=create&action=delete' // Create or delete likes
6363+'repo:*' // All collections, all actions
6464+6565+// RPC endpoint access
6666+'rpc:app.bsky.feed.getTimeline' // Access to timeline endpoint
6767+'rpc:app.bsky.feed.*' // All feed endpoints
6868+6969+// Blob operations
7070+'blob:image/*' // Upload images
7171+'blob:*/*' // Upload any blob type
7272+7373+// Account and identity
7474+'account:email' // Access email
7575+'identity:handle' // Manage handle
7676+```
7777+7878+## The ScopedEndpoint Attribute
7979+8080+The `#[ScopedEndpoint]` attribute documents scope requirements on methods that require authentication.
8181+8282+### Basic Usage
8383+8484+```php
8585+<?php
8686+8787+namespace App\Atp;
8888+8989+use SocialDept\AtpClient\Attributes\ScopedEndpoint;
9090+use SocialDept\AtpClient\Client\Requests\Request;
9191+use SocialDept\AtpClient\Enums\Scope;
9292+9393+class CustomClient extends Request
9494+{
9595+ #[ScopedEndpoint(Scope::TransitionGeneric)]
9696+ public function getTimeline(): array
9797+ {
9898+ return $this->atp->client->get('app.bsky.feed.getTimeline')->json();
9999+ }
100100+}
101101+```
102102+103103+### With Granular Scope
104104+105105+Document the future granular scope that will replace the transition scope:
106106+107107+```php
108108+#[ScopedEndpoint(
109109+ Scope::TransitionGeneric,
110110+ granular: 'rpc:app.bsky.feed.getTimeline'
111111+)]
112112+public function getTimeline(): GetTimelineResponse
113113+{
114114+ // ...
115115+}
116116+```
117117+118118+### With Description
119119+120120+Add a human-readable description for documentation:
121121+122122+```php
123123+#[ScopedEndpoint(
124124+ Scope::TransitionGeneric,
125125+ granular: 'rpc:app.bsky.feed.getTimeline',
126126+ description: 'Access to the user\'s home timeline'
127127+)]
128128+public function getTimeline(): GetTimelineResponse
129129+{
130130+ // ...
131131+}
132132+```
133133+134134+### Multiple Scopes (AND Logic)
135135+136136+When a method requires multiple scopes, all must be present:
137137+138138+```php
139139+#[ScopedEndpoint([Scope::TransitionGeneric, Scope::TransitionEmail])]
140140+public function getEmailPreferences(): array
141141+{
142142+ // Requires BOTH scopes
143143+}
144144+```
145145+146146+### Multiple Attributes (OR Logic)
147147+148148+Use multiple attributes for alternative scope requirements:
149149+150150+```php
151151+#[ScopedEndpoint(Scope::Atproto)]
152152+#[ScopedEndpoint(Scope::TransitionGeneric)]
153153+public function getProfile(string $actor): ProfileViewDetailed
154154+{
155155+ // Either scope satisfies the requirement
156156+}
157157+```
158158+159159+## Scope Enforcement (Planned)
160160+161161+> **Coming Soon:** Runtime scope enforcement is not yet implemented. The following documentation describes planned functionality for a future release.
162162+163163+### Configuration
164164+165165+Configure scope enforcement in `config/client.php` or via environment variables:
166166+167167+```php
168168+'scope_enforcement' => ScopeEnforcementLevel::Permissive,
169169+```
170170+171171+| Level | Behavior |
172172+|-------|----------|
173173+| `Strict` | Throws `MissingScopeException` if required scopes are missing |
174174+| `Permissive` | Logs a warning but attempts the request anyway |
175175+176176+Set via environment variable:
177177+178178+```env
179179+ATP_SCOPE_ENFORCEMENT=strict
180180+```
181181+182182+### Programmatic Scope Checking
183183+184184+Check scopes programmatically using the `ScopeChecker`:
185185+186186+```php
187187+use SocialDept\AtpClient\Auth\ScopeChecker;
188188+use SocialDept\AtpClient\Facades\Atp;
189189+190190+$checker = app(ScopeChecker::class);
191191+$session = Atp::as($did)->client->session();
192192+193193+// Check if session has a scope
194194+if ($checker->hasScope($session, Scope::TransitionGeneric)) {
195195+ // Session has the scope
196196+}
197197+198198+// Check multiple scopes
199199+if ($checker->check($session, [Scope::TransitionGeneric, Scope::TransitionEmail])) {
200200+ // Session has ALL required scopes
201201+}
202202+203203+// Check and fail if missing (respects enforcement level)
204204+$checker->checkOrFail($session, [Scope::TransitionGeneric]);
205205+206206+// Check repo scope for specific action
207207+if ($checker->checkRepoScope($session, 'app.bsky.feed.post', 'create')) {
208208+ // Can create posts
209209+}
210210+```
211211+212212+### Granular Pattern Matching
213213+214214+The scope checker supports wildcard patterns:
215215+216216+```php
217217+// Check if session can access any feed endpoint
218218+$checker->matchesGranular($session, 'rpc:app.bsky.feed.*');
219219+220220+// Check if session can upload images
221221+$checker->matchesGranular($session, 'blob:image/*');
222222+223223+// Check if session has any repo access
224224+$checker->matchesGranular($session, 'repo:*');
225225+```
226226+227227+## Route Middleware (Planned)
228228+229229+> **Coming Soon:** Route middleware is not yet implemented. The following documentation describes planned functionality for a future release.
230230+231231+Protect Laravel routes based on ATP session scopes:
232232+233233+```php
234234+use Illuminate\Support\Facades\Route;
235235+236236+// Single scope
237237+Route::get('/timeline', TimelineController::class)
238238+ ->middleware('atp.scope:transition:generic');
239239+240240+// Multiple scopes (AND logic)
241241+Route::get('/email-settings', EmailSettingsController::class)
242242+ ->middleware('atp.scope:transition:generic,transition:email');
243243+```
244244+245245+### Middleware Configuration
246246+247247+Configure middleware behavior in `config/client.php`:
248248+249249+```php
250250+'scope_authorization' => [
251251+ // What to do when scope check fails
252252+ 'failure_action' => ScopeAuthorizationFailure::Abort, // abort, redirect, or exception
253253+254254+ // Where to redirect (when failure_action is 'redirect')
255255+ 'redirect_to' => '/login',
256256+],
257257+```
258258+259259+| Failure Action | Behavior |
260260+|----------------|----------|
261261+| `Abort` | Returns 403 Forbidden response |
262262+| `Redirect` | Redirects to configured URL |
263263+| `Exception` | Throws `ScopeAuthorizationException` |
264264+265265+Set via environment variables:
266266+267267+```env
268268+ATP_SCOPE_FAILURE_ACTION=redirect
269269+ATP_SCOPE_REDIRECT=/auth/login
270270+```
271271+272272+### User Model Integration
273273+274274+For the middleware to work, your User model must implement `HasAtpSession`:
275275+276276+```php
277277+<?php
278278+279279+namespace App\Models;
280280+281281+use Illuminate\Foundation\Auth\User as Authenticatable;
282282+use SocialDept\AtpClient\Contracts\HasAtpSession;
283283+284284+class User extends Authenticatable implements HasAtpSession
285285+{
286286+ public function getAtpDid(): ?string
287287+ {
288288+ return $this->atp_did;
289289+ }
290290+}
291291+```
292292+293293+## Public Mode and Scopes
294294+295295+Methods marked with `#[PublicEndpoint]` can be called without authentication using `Atp::public()`:
296296+297297+```php
298298+// Public mode - no authentication required
299299+$client = Atp::public('https://public.api.bsky.app');
300300+$client->bsky->actor->getProfile('someone.bsky.social'); // Works without auth
301301+302302+// Authenticated mode - for endpoints requiring scopes
303303+$client = Atp::as($did);
304304+$client->bsky->feed->getTimeline(); // Requires transition:generic scope
305305+```
306306+307307+Methods with `#[PublicEndpoint]` work in both modes, while methods with `#[ScopedEndpoint]` require authentication.
308308+309309+## Exception Handling (Planned)
310310+311311+> **Coming Soon:** These exceptions will be thrown when scope enforcement is implemented in a future release.
312312+313313+### MissingScopeException
314314+315315+Will be thrown when required scopes are missing and enforcement is strict:
316316+317317+```php
318318+use SocialDept\AtpClient\Exceptions\MissingScopeException;
319319+320320+try {
321321+ $timeline = $client->bsky->feed->getTimeline();
322322+} catch (MissingScopeException $e) {
323323+ $missing = $e->getMissingScopes(); // Scopes that are missing
324324+ $granted = $e->getGrantedScopes(); // Scopes the session has
325325+326326+ // Handle missing scope
327327+}
328328+```
329329+330330+### ScopeAuthorizationException
331331+332332+Will be thrown by middleware when route access is denied:
333333+334334+```php
335335+use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
336336+337337+try {
338338+ // Route protected by atp.scope middleware
339339+} catch (ScopeAuthorizationException $e) {
340340+ $required = $e->getRequiredScopes();
341341+ $granted = $e->getGrantedScopes();
342342+ $message = $e->getMessage();
343343+}
344344+```
345345+346346+## Best Practices
347347+348348+### 1. Document All Scope Requirements
349349+350350+Always add `#[ScopedEndpoint]` to methods that require authentication:
351351+352352+```php
353353+#[ScopedEndpoint(
354354+ Scope::TransitionGeneric,
355355+ granular: 'rpc:app.bsky.feed.getTimeline',
356356+ description: 'Fetches the authenticated user\'s home timeline'
357357+)]
358358+public function getTimeline(): GetTimelineResponse
359359+```
360360+361361+### 2. Use the Scope Enum
362362+363363+Prefer the `Scope` enum over string literals for type safety:
364364+365365+```php
366366+// Good
367367+#[ScopedEndpoint(Scope::TransitionGeneric)]
368368+369369+// Avoid
370370+#[ScopedEndpoint('transition:generic')]
371371+```
372372+373373+### 3. Request Minimal Scopes
374374+375375+When implementing OAuth, request only the scopes your application needs:
376376+377377+```php
378378+$authUrl = Atp::oauth()->getAuthorizationUrl([
379379+ 'scope' => 'atproto transition:generic',
380380+]);
381381+```
382382+383383+### 4. Handle Missing Scopes Gracefully
384384+385385+Check for scope availability before attempting operations:
386386+387387+```php
388388+$checker = app(ScopeChecker::class);
389389+$session = $client->client->session();
390390+391391+if ($checker->hasScope($session, Scope::TransitionChat)) {
392392+ $conversations = $client->chat->getConversations();
393393+} else {
394394+ // Inform user they need to re-authorize with chat scope
395395+}
396396+```
397397+398398+### 5. Use Permissive Mode in Development
399399+400400+Start with permissive enforcement during development, then switch to strict for production:
401401+402402+```env
403403+# .env.local
404404+ATP_SCOPE_ENFORCEMENT=permissive
405405+406406+# .env.production
407407+ATP_SCOPE_ENFORCEMENT=strict
408408+```
+14-4
src/AtpClient.php
···1313class AtpClient
1414{
1515 use HasExtensions;
1616+1617 /**
1718 * Raw API communication/networking class
1819 */
···3940 public OzoneClient $ozone;
40414142 public function __construct(
4242- SessionManager $sessions,
4343- string $did,
4343+ ?SessionManager $sessions = null,
4444+ ?string $did = null,
4545+ ?string $serviceUrl = null,
4446 ) {
4545- // Load the network client
4646- $this->client = new Client($this, $sessions, $did);
4747+ // Load the network client (supports both public and authenticated modes)
4848+ $this->client = new Client($this, $sessions, $did, $serviceUrl);
47494850 // Load all function collections
4951 $this->bsky = new BskyClient($this);
5052 $this->atproto = new AtprotoClient($this);
5153 $this->chat = new ChatClient($this);
5254 $this->ozone = new OzoneClient($this);
5555+ }
5656+5757+ /**
5858+ * Check if client is in public mode (no authentication).
5959+ */
6060+ public function isPublicMode(): bool
6161+ {
6262+ return $this->client->isPublicMode();
5363 }
5464}
···11+<?php
22+33+namespace SocialDept\AtpClient\Attributes;
44+55+use Attribute;
66+77+/**
88+ * Documents that a method is a public endpoint that does not require authentication.
99+ *
1010+ * This attribute currently serves as documentation to indicate which AT Protocol
1111+ * endpoints can be called without an authenticated session. It helps developers
1212+ * understand which endpoints work with `Atp::public()` against public API endpoints
1313+ * like `https://public.api.bsky.app`.
1414+ *
1515+ * While this attribute does not currently perform runtime enforcement, scope
1616+ * validation will be implemented in a future release. Correctly attributing
1717+ * endpoints now ensures forward compatibility when enforcement is enabled.
1818+ *
1919+ * Public endpoints typically include operations like:
2020+ * - Reading public profiles and posts
2121+ * - Searching actors and content
2222+ * - Resolving handles to DIDs
2323+ * - Accessing repository data (sync endpoints)
2424+ * - Describing servers and feed generators
2525+ *
2626+ * @example Basic usage
2727+ * ```php
2828+ * #[PublicEndpoint]
2929+ * public function getProfile(string $actor): ProfileViewDetailed
3030+ * ```
3131+ *
3232+ * @see \SocialDept\AtpClient\Attributes\ScopedEndpoint For endpoints that require authentication
3333+ */
3434+#[Attribute(Attribute::TARGET_METHOD)]
3535+class PublicEndpoint
3636+{
3737+ /**
3838+ * @param string $description Human-readable description of the endpoint
3939+ */
4040+ public function __construct(
4141+ public readonly string $description = '',
4242+ ) {}
4343+}
···1010 case GetBlob = 'com.atproto.sync.getBlob';
1111 case GetRepo = 'com.atproto.sync.getRepo';
1212 case ListRepos = 'com.atproto.sync.listRepos';
1313+ case ListReposByCollection = 'com.atproto.sync.listReposByCollection';
1314 case GetLatestCommit = 'com.atproto.sync.getLatestCommit';
1415 case GetRecord = 'com.atproto.sync.getRecord';
1516 case ListBlobs = 'com.atproto.sync.listBlobs';