+38
-38
docs/extensions.md
+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
+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
+
```