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