+167
-12
README.md
+167
-12
README.md
···
163
163
Sessions automatically refresh when tokens are about to expire (default: 5 minutes before expiration). Listen to events if you need to persist refreshed tokens:
164
164
165
165
```php
166
-
use SocialDept\AtpClient\Events\OAuthTokenRefreshed;
166
+
use SocialDept\AtpClient\Events\TokenRefreshed;
167
167
168
-
Event::listen(OAuthTokenRefreshed::class, function ($event) {
169
-
// $event->did - the user's DID (e.g., did:plc:abc123...)
168
+
Event::listen(TokenRefreshed::class, function ($event) {
169
+
// $event->session - the Session being refreshed
170
170
// $event->token - the new AccessToken
171
171
// Update your credential storage here
172
+
173
+
// Check auth type if needed
174
+
if ($event->session->isLegacy()) {
175
+
// App password session
176
+
}
172
177
});
173
178
```
174
179
···
514
519
}
515
520
```
516
521
522
+
### Built-in Credential Providers
523
+
524
+
The package includes several credential providers for different use cases:
525
+
526
+
| Provider | Persistence | Setup | Best For |
527
+
|----------|-------------|-------|----------|
528
+
| `ArrayCredentialProvider` | None (memory) | None | Testing, single requests |
529
+
| `CacheCredentialProvider` | Cache driver | None | Quick prototyping, APIs |
530
+
| `SessionCredentialProvider` | Session lifetime | None | Web apps with user sessions |
531
+
| `FileCredentialProvider` | Permanent (disk) | None | CLI tools, bots |
532
+
533
+
**CacheCredentialProvider** - Uses Laravel's cache system (file cache by default):
534
+
```php
535
+
// config/client.php
536
+
'credential_provider' => \SocialDept\AtpClient\Providers\CacheCredentialProvider::class,
537
+
```
538
+
539
+
**SessionCredentialProvider** - Credentials cleared when session expires or user logs out:
540
+
```php
541
+
// config/client.php
542
+
'credential_provider' => \SocialDept\AtpClient\Providers\SessionCredentialProvider::class,
543
+
```
544
+
545
+
**FileCredentialProvider** - Stores credentials in `storage/app/atp-credentials/`:
546
+
```php
547
+
// config/client.php
548
+
'credential_provider' => \SocialDept\AtpClient\Providers\FileCredentialProvider::class,
549
+
```
550
+
551
+
For production applications with multiple users, implement a database-backed provider as shown below.
552
+
517
553
### Database Migration
518
554
519
555
Create a migration for storing credentials:
···
532
568
$table->text('refresh_token'); // Single-use refresh token
533
569
$table->timestamp('expires_at'); // Token expiration time
534
570
$table->json('scope')->nullable(); // Granted OAuth scopes
571
+
$table->string('auth_type')->default('oauth'); // 'oauth' or 'legacy'
535
572
$table->timestamps();
536
573
});
537
574
```
···
547
584
use SocialDept\AtpClient\Contracts\CredentialProvider;
548
585
use SocialDept\AtpClient\Data\AccessToken;
549
586
use SocialDept\AtpClient\Data\Credentials;
587
+
use SocialDept\AtpClient\Enums\AuthType;
550
588
551
589
class DatabaseCredentialProvider implements CredentialProvider
552
590
{
···
566
604
handle: $record->handle,
567
605
issuer: $record->issuer,
568
606
scope: $record->scope ?? [],
607
+
authType: AuthType::from($record->auth_type),
569
608
);
570
609
}
571
610
···
580
619
'refresh_token' => $token->refreshJwt,
581
620
'expires_at' => $token->expiresAt,
582
621
'scope' => $token->scope,
622
+
'auth_type' => $token->authType->value,
583
623
]
584
624
);
585
625
}
···
593
633
'handle' => $token->handle,
594
634
'issuer' => $token->issuer,
595
635
'scope' => $token->scope,
636
+
'auth_type' => $token->authType->value,
596
637
]);
597
638
}
598
639
···
622
663
'refresh_token',
623
664
'expires_at',
624
665
'scope',
666
+
'auth_type',
625
667
];
626
668
627
669
protected $casts = [
···
713
755
| `refreshToken` | Token to get new access tokens (single-use!) |
714
756
| `expiresAt` | When the access token expires |
715
757
| `scope` | Array of granted scopes (e.g., `['atproto', 'transition:generic']`) |
758
+
| `authType` | Authentication method: `AuthType::OAuth` or `AuthType::Legacy` |
716
759
717
760
### Handling Token Refresh Events
718
761
719
762
When tokens are automatically refreshed, you can listen for events:
720
763
721
764
```php
722
-
use SocialDept\AtpClient\Events\OAuthTokenRefreshed;
765
+
use SocialDept\AtpClient\Events\TokenRefreshed;
723
766
724
767
// In EventServiceProvider or via Event::listen()
725
-
Event::listen(OAuthTokenRefreshed::class, function (OAuthTokenRefreshed $event) {
768
+
Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) {
726
769
// The CredentialProvider.updateCredentials() is already called,
727
770
// but you can do additional logging or notifications here
728
771
Log::info("Token refreshed for: {$event->session->did()}");
772
+
773
+
// Check if this is a legacy (app password) session
774
+
if ($event->session->isLegacy()) {
775
+
// Handle legacy sessions differently if needed
776
+
}
729
777
});
730
778
```
731
779
···
768
816
});
769
817
```
770
818
771
-
### OAuthTokenRefreshing / OAuthTokenRefreshed
819
+
### TokenRefreshing / TokenRefreshed
772
820
773
-
Fired before and after automatic token refresh. Use `OAuthTokenRefreshing` to invalidate your stored refresh token before it's used (refresh tokens are single-use):
821
+
Fired before and after automatic token refresh for both OAuth and legacy sessions. Use `TokenRefreshing` to invalidate your stored refresh token before it's used (refresh tokens are single-use):
774
822
775
823
```php
776
-
use SocialDept\AtpClient\Events\OAuthTokenRefreshing;
777
-
use SocialDept\AtpClient\Events\OAuthTokenRefreshed;
824
+
use SocialDept\AtpClient\Events\TokenRefreshing;
825
+
use SocialDept\AtpClient\Events\TokenRefreshed;
778
826
779
827
// Before token refresh - invalidate old refresh token
780
-
Event::listen(OAuthTokenRefreshing::class, function (OAuthTokenRefreshing $event) {
781
-
// $event->session gives access to did(), handle(), etc.
828
+
Event::listen(TokenRefreshing::class, function (TokenRefreshing $event) {
829
+
// $event->session gives access to did(), handle(), authType(), isLegacy(), etc.
782
830
Log::info('Refreshing token for: ' . $event->session->did());
783
831
});
784
832
785
833
// After token refresh - new tokens available
786
-
Event::listen(OAuthTokenRefreshed::class, function (OAuthTokenRefreshed $event) {
834
+
Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) {
787
835
// $event->session - the session being refreshed
788
836
// $event->token - the new AccessToken with fresh tokens
789
837
// CredentialProvider.updateCredentials() is already called automatically
790
838
Log::info('Token refreshed for: ' . $event->session->did());
839
+
840
+
// Check auth type if needed
841
+
if ($event->session->isLegacy()) {
842
+
// Legacy (app password) session
843
+
}
791
844
});
845
+
```
846
+
847
+
## Scope Authorization
848
+
849
+
The package provides Laravel-native authorization features for checking ATP OAuth scopes, similar to Laravel's Gate/Policy system.
850
+
851
+
### Setup
852
+
853
+
Have your User model implement the `HasAtpSession` interface:
854
+
855
+
```php
856
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
857
+
858
+
class User extends Authenticatable implements HasAtpSession
859
+
{
860
+
public function getAtpDid(): ?string
861
+
{
862
+
return $this->atp_did; // or however you store the DID
863
+
}
864
+
}
865
+
```
866
+
867
+
### Route Middleware
868
+
869
+
Protect routes by requiring specific scopes. Uses AND logic (all listed scopes required):
870
+
871
+
```php
872
+
use Illuminate\Support\Facades\Route;
873
+
874
+
// Requires transition:generic scope
875
+
Route::post('/posts', [PostController::class, 'store'])
876
+
->middleware('atp.scope:transition:generic');
877
+
878
+
// Requires BOTH scopes
879
+
Route::post('/dm', [MessageController::class, 'store'])
880
+
->middleware('atp.scope:transition:generic,transition:chat.bsky');
881
+
```
882
+
883
+
### AtpScope Facade
884
+
885
+
Use the `AtpScope` facade for programmatic scope checks:
886
+
887
+
```php
888
+
use SocialDept\AtpClient\Facades\AtpScope;
889
+
890
+
// Check if user has a scope
891
+
if (AtpScope::can('transition:generic')) {
892
+
// ...
893
+
}
894
+
895
+
// Check if user has any of the scopes
896
+
if (AtpScope::canAny(['transition:generic', 'transition:chat.bsky'])) {
897
+
// ...
898
+
}
899
+
900
+
// Check if user has all scopes
901
+
if (AtpScope::canAll(['atproto', 'transition:generic'])) {
902
+
// ...
903
+
}
904
+
905
+
// Authorize or fail (throws/aborts based on config)
906
+
AtpScope::authorize('transition:generic');
907
+
908
+
// Check for a specific user
909
+
AtpScope::forUser($did)->authorize('transition:generic');
910
+
911
+
// Get all granted scopes
912
+
$scopes = AtpScope::granted();
913
+
```
914
+
915
+
### Session Helper Methods
916
+
917
+
The Session class also has convenience methods:
918
+
919
+
```php
920
+
$session = Atp::as($did)->session();
921
+
922
+
$session->can('transition:generic');
923
+
$session->canAny(['transition:generic', 'transition:chat.bsky']);
924
+
$session->canAll(['atproto', 'transition:generic']);
925
+
$session->cannot('transition:chat.bsky');
926
+
```
927
+
928
+
### Configuration
929
+
930
+
Configure authorization failure behavior in `config/client.php`:
931
+
932
+
```php
933
+
'scope_authorization' => [
934
+
// What happens when scope check fails: 'abort', 'redirect', or 'exception'
935
+
'failure_action' => ScopeAuthorizationFailure::Abort,
936
+
937
+
// Redirect URL when failure_action is 'redirect'
938
+
'redirect_to' => '/login',
939
+
],
940
+
```
941
+
942
+
Or via environment variables:
943
+
944
+
```env
945
+
ATP_SCOPE_FAILURE_ACTION=abort
946
+
ATP_SCOPE_REDIRECT=/login
792
947
```
793
948
794
949
## Available Commands
+40
config/client.php
+40
config/client.php
···
1
1
<?php
2
2
3
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
4
+
use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;
5
+
3
6
return [
4
7
/*
5
8
|--------------------------------------------------------------------------
···
110
113
'times' => env('ATP_HTTP_RETRY_TIMES', 3),
111
114
'sleep' => env('ATP_HTTP_RETRY_SLEEP', 100),
112
115
],
116
+
],
117
+
118
+
/*
119
+
|--------------------------------------------------------------------------
120
+
| Scope Enforcement
121
+
|--------------------------------------------------------------------------
122
+
|
123
+
| Configure how scope requirements are enforced. Options:
124
+
| - 'strict': Throws MissingScopeException if required scopes are missing
125
+
| - 'permissive': Logs a warning but attempts the request anyway
126
+
|
127
+
*/
128
+
'scope_enforcement' => ScopeEnforcementLevel::tryFrom(
129
+
env('ATP_SCOPE_ENFORCEMENT', 'permissive')
130
+
) ?? ScopeEnforcementLevel::Permissive,
131
+
132
+
/*
133
+
|--------------------------------------------------------------------------
134
+
| Scope Authorization
135
+
|--------------------------------------------------------------------------
136
+
|
137
+
| Configure behavior for the AtpScope facade and atp.scope middleware.
138
+
|
139
+
| failure_action: What happens when a scope check fails
140
+
| - 'abort': Return a 403 HTTP response
141
+
| - 'redirect': Redirect to the configured URL
142
+
| - 'exception': Throw ScopeAuthorizationException
143
+
|
144
+
| redirect_to: URL to redirect to when failure_action is 'redirect'
145
+
|
146
+
*/
147
+
'scope_authorization' => [
148
+
'failure_action' => ScopeAuthorizationFailure::tryFrom(
149
+
env('ATP_SCOPE_FAILURE_ACTION', 'abort')
150
+
) ?? ScopeAuthorizationFailure::Abort,
151
+
152
+
'redirect_to' => env('ATP_SCOPE_REDIRECT', '/login'),
113
153
],
114
154
];
+38
-9
src/AtpClientServiceProvider.php
+38
-9
src/AtpClientServiceProvider.php
···
2
2
3
3
namespace SocialDept\AtpClient;
4
4
5
+
use Illuminate\Routing\Router;
5
6
use Illuminate\Support\Facades\Route;
6
7
use Illuminate\Support\ServiceProvider;
7
8
use SocialDept\AtpClient\Auth\ClientMetadataManager;
8
9
use SocialDept\AtpClient\Auth\DPoPKeyManager;
9
10
use SocialDept\AtpClient\Auth\DPoPNonceManager;
10
11
use SocialDept\AtpClient\Auth\OAuthEngine;
12
+
use SocialDept\AtpClient\Auth\ScopeChecker;
13
+
use SocialDept\AtpClient\Auth\ScopeGate;
11
14
use SocialDept\AtpClient\Auth\TokenRefresher;
15
+
use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;
16
+
use SocialDept\AtpClient\Http\Middleware\RequiresScopeMiddleware;
12
17
use SocialDept\AtpClient\Console\GenerateOAuthKeyCommand;
13
18
use SocialDept\AtpClient\Contracts\CredentialProvider;
14
19
use SocialDept\AtpClient\Contracts\KeyStore;
···
56
61
);
57
62
});
58
63
$this->app->singleton(OAuthEngine::class);
64
+
$this->app->singleton(ScopeChecker::class, function ($app) {
65
+
return new ScopeChecker(
66
+
config('atp-client.scope_enforcement', ScopeEnforcementLevel::Permissive)
67
+
);
68
+
});
69
+
70
+
// Register ScopeGate for AtpScope facade
71
+
$this->app->singleton('atp-scope', function ($app) {
72
+
return new ScopeGate(
73
+
$app->make(SessionManager::class),
74
+
$app->make(ScopeChecker::class),
75
+
);
76
+
});
59
77
60
78
// Register main client facade accessor
61
79
$this->app->bind('atp-client', function ($app) {
···
70
88
$this->app = $app;
71
89
}
72
90
73
-
public function as(string $handleOrDid): AtpClient
91
+
public function as(string $actor): AtpClient
74
92
{
75
93
return new AtpClient(
76
94
$this->app->make(SessionManager::class),
77
-
$handleOrDid
95
+
$actor
78
96
);
79
97
}
80
98
81
-
public function login(string $handleOrDid, string $password): AtpClient
99
+
public function login(string $actor, string $password): AtpClient
82
100
{
83
101
$this->app->make(SessionManager::class)
84
-
->fromAppPassword($handleOrDid, $password);
102
+
->fromAppPassword($actor, $password);
85
103
86
-
return $this->as($handleOrDid);
104
+
return $this->as($actor);
87
105
}
88
106
89
107
public function oauth(): OAuthEngine
···
116
134
}
117
135
118
136
$this->registerRoutes();
137
+
$this->registerMiddleware();
138
+
}
139
+
140
+
/**
141
+
* Register middleware aliases
142
+
*/
143
+
protected function registerMiddleware(): void
144
+
{
145
+
/** @var Router $router */
146
+
$router = $this->app->make(Router::class);
147
+
$router->aliasMiddleware('atp.scope', RequiresScopeMiddleware::class);
119
148
}
120
149
121
150
/**
···
137
166
->name('atp.oauth.jwks');
138
167
});
139
168
140
-
// Register standard .well-known endpoint
141
-
Route::get('.well-known/oauth-client-metadata', ClientMetadataController::class)
142
-
->name('atp.oauth.well-known');
169
+
// Register recommended client id convention (see: https://atproto.com/guides/oauth#clients)
170
+
Route::get('oauth-client-metadata.json', ClientMetadataController::class)
171
+
->name('atp.oauth.json');
143
172
}
144
173
145
174
/**
···
149
178
*/
150
179
public function provides(): array
151
180
{
152
-
return ['atp-client'];
181
+
return ['atp-client', 'atp-scope'];
153
182
}
154
183
}
+37
src/Attributes/RequiresScope.php
+37
src/Attributes/RequiresScope.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Attributes;
4
+
5
+
use Attribute;
6
+
use SocialDept\AtpClient\Enums\Scope;
7
+
8
+
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
9
+
class RequiresScope
10
+
{
11
+
public array $scopes;
12
+
13
+
/**
14
+
* @param string|Scope|array<string|Scope> $scopes Required scope(s) for this method
15
+
* @param string|null $granular Future granular scope equivalent
16
+
* @param string $description Human-readable description of scope requirement
17
+
*/
18
+
public function __construct(
19
+
string|Scope|array $scopes,
20
+
public readonly ?string $granular = null,
21
+
public readonly string $description = '',
22
+
) {
23
+
$this->scopes = $this->normalizeScopes($scopes);
24
+
}
25
+
26
+
protected function normalizeScopes(string|Scope|array $scopes): array
27
+
{
28
+
if (! is_array($scopes)) {
29
+
$scopes = [$scopes];
30
+
}
31
+
32
+
return array_map(
33
+
fn ($scope) => $scope instanceof Scope ? $scope->value : $scope,
34
+
$scopes
35
+
);
36
+
}
37
+
}
+268
src/Auth/ScopeChecker.php
+268
src/Auth/ScopeChecker.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Auth;
4
+
5
+
use Illuminate\Support\Facades\Log;
6
+
use SocialDept\AtpClient\Enums\Scope;
7
+
use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;
8
+
use SocialDept\AtpClient\Exceptions\MissingScopeException;
9
+
use SocialDept\AtpClient\Session\Session;
10
+
11
+
class ScopeChecker
12
+
{
13
+
public function __construct(
14
+
protected ScopeEnforcementLevel $enforcement = ScopeEnforcementLevel::Permissive
15
+
) {}
16
+
17
+
/**
18
+
* Check if the session has all required scopes.
19
+
*
20
+
* @param array<string|Scope> $requiredScopes
21
+
*/
22
+
public function check(Session $session, array $requiredScopes): bool
23
+
{
24
+
$required = $this->normalizeScopes($requiredScopes);
25
+
$granted = $session->scopes();
26
+
27
+
foreach ($required as $scope) {
28
+
if (! $this->sessionHasScope($session, $scope)) {
29
+
return false;
30
+
}
31
+
}
32
+
33
+
return true;
34
+
}
35
+
36
+
/**
37
+
* Check scopes and handle enforcement based on configuration.
38
+
*
39
+
* @param array<string|Scope> $requiredScopes
40
+
*
41
+
* @throws MissingScopeException
42
+
*/
43
+
public function checkOrFail(Session $session, array $requiredScopes): void
44
+
{
45
+
if ($this->check($session, $requiredScopes)) {
46
+
return;
47
+
}
48
+
49
+
$required = $this->normalizeScopes($requiredScopes);
50
+
$granted = $session->scopes();
51
+
$missing = array_diff($required, $granted);
52
+
53
+
if ($this->enforcement === ScopeEnforcementLevel::Strict) {
54
+
throw new MissingScopeException($missing, $granted);
55
+
}
56
+
57
+
Log::warning('ATP Client: Missing required scope(s)', [
58
+
'required' => $required,
59
+
'granted' => $granted,
60
+
'missing' => $missing,
61
+
'did' => $session->did(),
62
+
]);
63
+
}
64
+
65
+
/**
66
+
* Check if the session has a specific scope.
67
+
*/
68
+
public function hasScope(Session $session, string|Scope $scope): bool
69
+
{
70
+
$scope = $scope instanceof Scope ? $scope->value : $scope;
71
+
72
+
return $this->sessionHasScope($session, $scope);
73
+
}
74
+
75
+
/**
76
+
* Check if the session matches a granular scope pattern.
77
+
*
78
+
* Supports patterns like:
79
+
* - repo:app.bsky.feed.post?action=create
80
+
* - repo:app.bsky.feed.*
81
+
* - rpc:app.bsky.feed.*
82
+
* - blob:image/*
83
+
*/
84
+
public function matchesGranular(Session $session, string $pattern): bool
85
+
{
86
+
$granted = $session->scopes();
87
+
88
+
// Check for exact match first
89
+
if (in_array($pattern, $granted, true)) {
90
+
return true;
91
+
}
92
+
93
+
// Handle repo: scopes with action semantics
94
+
if (str_starts_with($pattern, 'repo:')) {
95
+
foreach ($granted as $scope) {
96
+
if (str_starts_with($scope, 'repo:') && $this->matchesRepoScope($pattern, $scope)) {
97
+
return true;
98
+
}
99
+
}
100
+
}
101
+
102
+
// Check for wildcard matches
103
+
$patternRegex = $this->patternToRegex($pattern);
104
+
105
+
foreach ($granted as $scope) {
106
+
if (preg_match($patternRegex, $scope)) {
107
+
return true;
108
+
}
109
+
}
110
+
111
+
// Check if granted scope is a superset (wildcard in granted scope)
112
+
foreach ($granted as $scope) {
113
+
$grantedRegex = $this->patternToRegex($scope);
114
+
if (preg_match($grantedRegex, $pattern)) {
115
+
return true;
116
+
}
117
+
}
118
+
119
+
return false;
120
+
}
121
+
122
+
/**
123
+
* Check if a required repo scope is satisfied by a granted repo scope.
124
+
*
125
+
* Per AT Protocol spec: "If not defined, all operations are allowed."
126
+
* - repo:collection (no action) grants ALL actions
127
+
* - repo:collection?action=create grants only create
128
+
* - repo:* grants all collections with all actions
129
+
*/
130
+
protected function matchesRepoScope(string $required, string $granted): bool
131
+
{
132
+
$requiredParsed = $this->parseRepoScope($required);
133
+
$grantedParsed = $this->parseRepoScope($granted);
134
+
135
+
// Check collection match (with wildcard support)
136
+
if (! $this->collectionsMatch($requiredParsed['collection'], $grantedParsed['collection'])) {
137
+
return false;
138
+
}
139
+
140
+
// If granted has no actions, it grants ALL actions
141
+
if (empty($grantedParsed['actions'])) {
142
+
return true;
143
+
}
144
+
145
+
// If required has no actions, we need all actions granted
146
+
if (empty($requiredParsed['actions'])) {
147
+
// Required needs all actions, but granted is restricted
148
+
return false;
149
+
}
150
+
151
+
// Check if all required actions are in granted actions
152
+
return empty(array_diff($requiredParsed['actions'], $grantedParsed['actions']));
153
+
}
154
+
155
+
/**
156
+
* Parse a repo scope into collection and actions.
157
+
*
158
+
* Handles formats like:
159
+
* - repo:app.bsky.feed.post
160
+
* - repo:app.bsky.feed.post?action=create
161
+
* - repo:app.bsky.feed.post?action=create&action=update&action=delete
162
+
* - repo:*
163
+
* - repo:*?action=delete
164
+
*
165
+
* @return array{collection: string, actions: array<string>}
166
+
*/
167
+
protected function parseRepoScope(string $scope): array
168
+
{
169
+
$parts = explode('?', $scope, 2);
170
+
$collection = substr($parts[0], 5); // Remove 'repo:'
171
+
172
+
$actions = [];
173
+
if (isset($parts[1])) {
174
+
// Parse action=create&action=update&action=delete format
175
+
// PHP's parse_str doesn't handle repeated params well
176
+
preg_match_all('/action=([^&]+)/', $parts[1], $matches);
177
+
if (! empty($matches[1])) {
178
+
$actions = array_map('urldecode', $matches[1]);
179
+
}
180
+
}
181
+
182
+
return ['collection' => $collection, 'actions' => $actions];
183
+
}
184
+
185
+
/**
186
+
* Check if a required collection matches a granted collection.
187
+
*/
188
+
protected function collectionsMatch(string $required, string $granted): bool
189
+
{
190
+
if ($granted === '*') {
191
+
return true;
192
+
}
193
+
194
+
return $required === $granted;
195
+
}
196
+
197
+
/**
198
+
* Check if the session has repo access for a specific collection and action.
199
+
*/
200
+
public function checkRepoScope(Session $session, string $collection, string $action): bool
201
+
{
202
+
$required = "repo:{$collection}?action={$action}";
203
+
204
+
return $this->sessionHasScope($session, $required);
205
+
}
206
+
207
+
/**
208
+
* Check repo scope and handle enforcement based on configuration.
209
+
*
210
+
* @throws MissingScopeException
211
+
*/
212
+
public function checkRepoScopeOrFail(Session $session, string $collection, string $action): void
213
+
{
214
+
$required = "repo:{$collection}?action={$action}";
215
+
216
+
$this->checkOrFail($session, [$required]);
217
+
}
218
+
219
+
/**
220
+
* Get the current enforcement level.
221
+
*/
222
+
public function enforcement(): ScopeEnforcementLevel
223
+
{
224
+
return $this->enforcement;
225
+
}
226
+
227
+
/**
228
+
* Create a new instance with a different enforcement level.
229
+
*/
230
+
public function withEnforcement(ScopeEnforcementLevel $enforcement): self
231
+
{
232
+
return new self($enforcement);
233
+
}
234
+
235
+
/**
236
+
* @param array<string|Scope> $scopes
237
+
* @return array<string>
238
+
*/
239
+
protected function normalizeScopes(array $scopes): array
240
+
{
241
+
return array_map(
242
+
fn ($scope) => $scope instanceof Scope ? $scope->value : $scope,
243
+
$scopes
244
+
);
245
+
}
246
+
247
+
protected function sessionHasScope(Session $session, string $scope): bool
248
+
{
249
+
// Direct match
250
+
if ($session->hasScope($scope)) {
251
+
return true;
252
+
}
253
+
254
+
// Check granular pattern matching
255
+
return $this->matchesGranular($session, $scope);
256
+
}
257
+
258
+
protected function patternToRegex(string $pattern): string
259
+
{
260
+
// Escape regex special characters except *
261
+
$escaped = preg_quote($pattern, '/');
262
+
263
+
// Replace \* with .* for wildcard matching
264
+
$regex = str_replace('\*', '.*', $escaped);
265
+
266
+
return '/^'.$regex.'$/';
267
+
}
268
+
}
+176
src/Auth/ScopeGate.php
+176
src/Auth/ScopeGate.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Auth;
4
+
5
+
use Illuminate\Contracts\Auth\Authenticatable;
6
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
7
+
use SocialDept\AtpClient\Enums\Scope;
8
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
9
+
use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
10
+
use SocialDept\AtpClient\Session\Session;
11
+
use SocialDept\AtpClient\Session\SessionManager;
12
+
13
+
class ScopeGate
14
+
{
15
+
protected ?Session $session = null;
16
+
17
+
public function __construct(
18
+
protected SessionManager $sessions,
19
+
protected ScopeChecker $checker,
20
+
) {}
21
+
22
+
/**
23
+
* Set the session context directly.
24
+
*/
25
+
public function forSession(Session $session): self
26
+
{
27
+
$instance = new self($this->sessions, $this->checker);
28
+
$instance->session = $session;
29
+
30
+
return $instance;
31
+
}
32
+
33
+
/**
34
+
* Set the session context via actor (handle or DID).
35
+
*/
36
+
public function forUser(string $actor): self
37
+
{
38
+
$instance = new self($this->sessions, $this->checker);
39
+
$instance->session = $this->sessions->session($actor);
40
+
41
+
return $instance;
42
+
}
43
+
44
+
/**
45
+
* Check if the session has the given scope.
46
+
*/
47
+
public function can(string|Scope $scope): bool
48
+
{
49
+
$session = $this->resolveSession();
50
+
51
+
if (! $session) {
52
+
return false;
53
+
}
54
+
55
+
return $this->checker->hasScope($session, $scope);
56
+
}
57
+
58
+
/**
59
+
* Check if the session has any of the given scopes.
60
+
*
61
+
* @param array<string|Scope> $scopes
62
+
*/
63
+
public function canAny(array $scopes): bool
64
+
{
65
+
$session = $this->resolveSession();
66
+
67
+
if (! $session) {
68
+
return false;
69
+
}
70
+
71
+
foreach ($scopes as $scope) {
72
+
if ($this->checker->hasScope($session, $scope)) {
73
+
return true;
74
+
}
75
+
}
76
+
77
+
return false;
78
+
}
79
+
80
+
/**
81
+
* Check if the session has all of the given scopes.
82
+
*
83
+
* @param array<string|Scope> $scopes
84
+
*/
85
+
public function canAll(array $scopes): bool
86
+
{
87
+
$session = $this->resolveSession();
88
+
89
+
if (! $session) {
90
+
return false;
91
+
}
92
+
93
+
return $this->checker->check($session, $scopes);
94
+
}
95
+
96
+
/**
97
+
* Check if the session does NOT have the given scope.
98
+
*/
99
+
public function cannot(string|Scope $scope): bool
100
+
{
101
+
return ! $this->can($scope);
102
+
}
103
+
104
+
/**
105
+
* Authorize the session has all given scopes, or handle failure.
106
+
*
107
+
* @param string|Scope ...$scopes
108
+
*
109
+
* @throws ScopeAuthorizationException
110
+
*/
111
+
public function authorize(string|Scope ...$scopes): void
112
+
{
113
+
if ($this->canAll($scopes)) {
114
+
return;
115
+
}
116
+
117
+
$session = $this->resolveSession();
118
+
$granted = $session ? $session->scopes() : [];
119
+
$required = array_map(
120
+
fn ($scope) => $scope instanceof Scope ? $scope->value : $scope,
121
+
$scopes
122
+
);
123
+
$missing = array_diff($required, $granted);
124
+
125
+
$exception = new ScopeAuthorizationException($missing, $granted);
126
+
127
+
$action = config('atp-client.scope_authorization.failure_action', ScopeAuthorizationFailure::Abort);
128
+
129
+
if ($action === ScopeAuthorizationFailure::Exception) {
130
+
throw $exception;
131
+
}
132
+
133
+
// For Abort and Redirect, let the exception render itself
134
+
throw $exception;
135
+
}
136
+
137
+
/**
138
+
* Get the granted scopes for the current session.
139
+
*/
140
+
public function granted(): array
141
+
{
142
+
$session = $this->resolveSession();
143
+
144
+
return $session ? $session->scopes() : [];
145
+
}
146
+
147
+
/**
148
+
* Resolve the session from context.
149
+
*/
150
+
protected function resolveSession(): ?Session
151
+
{
152
+
// If session was explicitly set, use it
153
+
if ($this->session) {
154
+
return $this->session;
155
+
}
156
+
157
+
// Try to resolve from authenticated user
158
+
$user = auth()->user();
159
+
160
+
if (! $user instanceof HasAtpSession) {
161
+
return null;
162
+
}
163
+
164
+
$did = $user->getAtpDid();
165
+
166
+
if (! $did) {
167
+
return null;
168
+
}
169
+
170
+
try {
171
+
return $this->sessions->session($did);
172
+
} catch (\Exception) {
173
+
return null;
174
+
}
175
+
}
176
+
}
+37
-2
src/Auth/TokenRefresher.php
+37
-2
src/Auth/TokenRefresher.php
···
2
2
3
3
namespace SocialDept\AtpClient\Auth;
4
4
5
+
use Illuminate\Support\Facades\Http;
5
6
use SocialDept\AtpClient\Data\AccessToken;
6
7
use SocialDept\AtpClient\Data\DPoPKey;
8
+
use SocialDept\AtpClient\Enums\AuthType;
7
9
use SocialDept\AtpClient\Exceptions\AuthenticationException;
8
10
use SocialDept\AtpClient\Http\DPoPClient;
9
11
···
14
16
) {}
15
17
16
18
/**
17
-
* Refresh access token using refresh token
19
+
* Refresh access token using refresh token.
18
20
* NOTE: Refresh tokens are single-use!
19
21
*/
20
22
public function refresh(
21
23
string $refreshToken,
22
24
string $pdsEndpoint,
23
25
DPoPKey $dpopKey,
24
-
?string $handle = null
26
+
?string $handle = null,
27
+
AuthType $authType = AuthType::OAuth,
28
+
): AccessToken {
29
+
return $authType === AuthType::Legacy
30
+
? $this->refreshLegacy($refreshToken, $pdsEndpoint, $handle)
31
+
: $this->refreshOAuth($refreshToken, $pdsEndpoint, $dpopKey, $handle);
32
+
}
33
+
34
+
/**
35
+
* Refresh OAuth session using /oauth/token endpoint with DPoP.
36
+
*/
37
+
protected function refreshOAuth(
38
+
string $refreshToken,
39
+
string $pdsEndpoint,
40
+
DPoPKey $dpopKey,
41
+
?string $handle,
25
42
): AccessToken {
26
43
$tokenUrl = $pdsEndpoint.'/oauth/token';
27
44
···
31
48
'grant_type' => 'refresh_token',
32
49
'refresh_token' => $refreshToken,
33
50
]);
51
+
52
+
if ($response->failed()) {
53
+
throw new AuthenticationException('Token refresh failed: '.$response->body());
54
+
}
55
+
56
+
return AccessToken::fromResponse($response->json(), $handle, $pdsEndpoint);
57
+
}
58
+
59
+
/**
60
+
* Refresh legacy session using /xrpc/com.atproto.server.refreshSession endpoint.
61
+
*/
62
+
protected function refreshLegacy(
63
+
string $refreshToken,
64
+
string $pdsEndpoint,
65
+
?string $handle,
66
+
): AccessToken {
67
+
$response = Http::withHeader('Authorization', 'Bearer '.$refreshToken)
68
+
->post($pdsEndpoint.'/xrpc/com.atproto.server.refreshSession');
34
69
35
70
if ($response->failed()) {
36
71
throw new AuthenticationException('Token refresh failed: '.$response->body());
+9
src/Client/Client.php
+9
src/Client/Client.php
···
5
5
use SocialDept\AtpClient\AtpClient;
6
6
use SocialDept\AtpClient\Http\DPoPClient;
7
7
use SocialDept\AtpClient\Http\HasHttp;
8
+
use SocialDept\AtpClient\Session\Session;
8
9
use SocialDept\AtpClient\Session\SessionManager;
9
10
10
11
class Client
···
25
26
$this->sessions = $sessions;
26
27
$this->did = $did;
27
28
$this->dpopClient = app(DPoPClient::class);
29
+
}
30
+
31
+
/**
32
+
* Get the current session.
33
+
*/
34
+
public function session(): Session
35
+
{
36
+
return $this->sessions->session($this->did);
28
37
}
29
38
}
+26
-22
src/Client/Records/FollowRecordClient.php
+26
-22
src/Client/Records/FollowRecordClient.php
···
3
3
namespace SocialDept\AtpClient\Client\Records;
4
4
5
5
use DateTimeInterface;
6
+
use SocialDept\AtpClient\Attributes\RequiresScope;
6
7
use SocialDept\AtpClient\Client\Requests\Request;
7
8
use SocialDept\AtpClient\Data\StrongRef;
9
+
use SocialDept\AtpClient\Enums\Scope;
8
10
9
11
class FollowRecordClient extends Request
10
12
{
11
13
/**
12
14
* Follow a user
15
+
*
16
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.graph.follow?action=create)
13
17
*/
18
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=create')]
14
20
public function create(
15
21
string $subject,
16
22
?DateTimeInterface $createdAt = null
···
21
27
'createdAt' => ($createdAt ?? now())->format('c'),
22
28
];
23
29
24
-
$response = $this->atp->client->post(
25
-
endpoint: 'com.atproto.repo.createRecord',
26
-
body: [
27
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
28
-
'collection' => 'app.bsky.graph.follow',
29
-
'record' => $record,
30
-
]
30
+
$response = $this->atp->atproto->repo->createRecord(
31
+
repo: $this->atp->client->session()->did(),
32
+
collection: 'app.bsky.graph.follow',
33
+
record: $record
31
34
);
32
35
33
36
return StrongRef::fromResponse($response->json());
···
35
38
36
39
/**
37
40
* Unfollow a user (delete follow record)
41
+
*
42
+
* @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.graph.follow?action=delete)
38
43
*/
44
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')]
45
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=delete')]
39
46
public function delete(string $rkey): void
40
47
{
41
-
$this->atp->client->post(
42
-
endpoint: 'com.atproto.repo.deleteRecord',
43
-
body: [
44
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
45
-
'collection' => 'app.bsky.graph.follow',
46
-
'rkey' => $rkey,
47
-
]
48
+
$this->atp->atproto->repo->deleteRecord(
49
+
repo: $this->atp->client->session()->did(),
50
+
collection: 'app.bsky.graph.follow',
51
+
rkey: $rkey
48
52
);
49
53
}
50
54
51
55
/**
52
56
* Get a follow record
57
+
*
58
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
53
59
*/
60
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
54
61
public function get(string $rkey, ?string $cid = null): array
55
62
{
56
-
$response = $this->atp->client->get(
57
-
endpoint: 'com.atproto.repo.getRecord',
58
-
params: array_filter([
59
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
60
-
'collection' => 'app.bsky.graph.follow',
61
-
'rkey' => $rkey,
62
-
'cid' => $cid,
63
-
])
63
+
$response = $this->atp->atproto->repo->getRecord(
64
+
repo: $this->atp->client->session()->did(),
65
+
collection: 'app.bsky.graph.follow',
66
+
rkey: $rkey,
67
+
cid: $cid
64
68
);
65
69
66
70
return $response->json('value');
+26
-22
src/Client/Records/LikeRecordClient.php
+26
-22
src/Client/Records/LikeRecordClient.php
···
3
3
namespace SocialDept\AtpClient\Client\Records;
4
4
5
5
use DateTimeInterface;
6
+
use SocialDept\AtpClient\Attributes\RequiresScope;
6
7
use SocialDept\AtpClient\Client\Requests\Request;
7
8
use SocialDept\AtpClient\Data\StrongRef;
9
+
use SocialDept\AtpClient\Enums\Scope;
8
10
9
11
class LikeRecordClient extends Request
10
12
{
11
13
/**
12
14
* Like a post
15
+
*
16
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.like?action=create)
13
17
*/
18
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=create')]
14
20
public function create(
15
21
StrongRef $subject,
16
22
?DateTimeInterface $createdAt = null
···
21
27
'createdAt' => ($createdAt ?? now())->format('c'),
22
28
];
23
29
24
-
$response = $this->atp->client->post(
25
-
endpoint: 'com.atproto.repo.createRecord',
26
-
body: [
27
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
28
-
'collection' => 'app.bsky.feed.like',
29
-
'record' => $record,
30
-
]
30
+
$response = $this->atp->atproto->repo->createRecord(
31
+
repo: $this->atp->client->session()->did(),
32
+
collection: 'app.bsky.feed.like',
33
+
record: $record
31
34
);
32
35
33
36
return StrongRef::fromResponse($response->json());
···
35
38
36
39
/**
37
40
* Unlike a post (delete like record)
41
+
*
42
+
* @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.feed.like?action=delete)
38
43
*/
44
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')]
45
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=delete')]
39
46
public function delete(string $rkey): void
40
47
{
41
-
$this->atp->client->post(
42
-
endpoint: 'com.atproto.repo.deleteRecord',
43
-
body: [
44
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
45
-
'collection' => 'app.bsky.feed.like',
46
-
'rkey' => $rkey,
47
-
]
48
+
$this->atp->atproto->repo->deleteRecord(
49
+
repo: $this->atp->client->session()->did(),
50
+
collection: 'app.bsky.feed.like',
51
+
rkey: $rkey
48
52
);
49
53
}
50
54
51
55
/**
52
56
* Get a like record
57
+
*
58
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
53
59
*/
60
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
54
61
public function get(string $rkey, ?string $cid = null): array
55
62
{
56
-
$response = $this->atp->client->get(
57
-
endpoint: 'com.atproto.repo.getRecord',
58
-
params: array_filter([
59
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
60
-
'collection' => 'app.bsky.feed.like',
61
-
'rkey' => $rkey,
62
-
'cid' => $cid,
63
-
])
63
+
$response = $this->atp->atproto->repo->getRecord(
64
+
repo: $this->atp->client->session()->did(),
65
+
collection: 'app.bsky.feed.like',
66
+
rkey: $rkey,
67
+
cid: $cid
64
68
);
65
69
66
70
return $response->json('value');
+51
-32
src/Client/Records/PostRecordClient.php
+51
-32
src/Client/Records/PostRecordClient.php
···
3
3
namespace SocialDept\AtpClient\Client\Records;
4
4
5
5
use DateTimeInterface;
6
+
use SocialDept\AtpClient\Attributes\RequiresScope;
6
7
use SocialDept\AtpClient\Client\Requests\Request;
7
8
use SocialDept\AtpClient\Contracts\Recordable;
8
9
use SocialDept\AtpClient\Data\StrongRef;
9
-
use SocialDept\AtpClient\Http\Response;
10
+
use SocialDept\AtpClient\Enums\Scope;
10
11
use SocialDept\AtpClient\RichText\TextBuilder;
11
12
12
13
class PostRecordClient extends Request
13
14
{
14
15
/**
15
16
* Create a post
17
+
*
18
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create)
16
19
*/
20
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
21
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')]
17
22
public function create(
18
23
string|array|Recordable $content,
19
24
?array $facets = null,
···
53
58
$record['$type'] = 'app.bsky.feed.post';
54
59
}
55
60
56
-
// Create record via XRPC
57
-
$response = $this->atp->client->post(
58
-
endpoint: 'com.atproto.repo.createRecord',
59
-
body: [
60
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
61
-
'collection' => 'app.bsky.feed.post',
62
-
'record' => $record,
63
-
]
61
+
$response = $this->atp->atproto->repo->createRecord(
62
+
repo: $this->atp->client->session()->did(),
63
+
collection: 'app.bsky.feed.post',
64
+
record: $record
64
65
);
65
66
66
67
return StrongRef::fromResponse($response->json());
···
68
69
69
70
/**
70
71
* Update a post
72
+
*
73
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.feed.post?action=update)
71
74
*/
75
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
76
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=update')]
72
77
public function update(string $rkey, array $record): StrongRef
73
78
{
74
79
// Ensure $type is set
···
76
81
$record['$type'] = 'app.bsky.feed.post';
77
82
}
78
83
79
-
$response = $this->atp->client->post(
80
-
endpoint: 'com.atproto.repo.putRecord',
81
-
body: [
82
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
83
-
'collection' => 'app.bsky.feed.post',
84
-
'rkey' => $rkey,
85
-
'record' => $record,
86
-
]
84
+
$response = $this->atp->atproto->repo->putRecord(
85
+
repo: $this->atp->client->session()->did(),
86
+
collection: 'app.bsky.feed.post',
87
+
rkey: $rkey,
88
+
record: $record
87
89
);
88
90
89
91
return StrongRef::fromResponse($response->json());
···
91
93
92
94
/**
93
95
* Delete a post
96
+
*
97
+
* @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.feed.post?action=delete)
94
98
*/
99
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')]
100
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=delete')]
95
101
public function delete(string $rkey): void
96
102
{
97
-
$this->atp->client->post(
98
-
endpoint: 'com.atproto.repo.deleteRecord',
99
-
body: [
100
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
101
-
'collection' => 'app.bsky.feed.post',
102
-
'rkey' => $rkey,
103
-
]
103
+
$this->atp->atproto->repo->deleteRecord(
104
+
repo: $this->atp->client->session()->did(),
105
+
collection: 'app.bsky.feed.post',
106
+
rkey: $rkey
104
107
);
105
108
}
106
109
107
110
/**
108
111
* Get a post
112
+
*
113
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
109
114
*/
115
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
110
116
public function get(string $rkey, ?string $cid = null): array
111
117
{
112
-
$response = $this->atp->client->get(
113
-
endpoint: 'com.atproto.repo.getRecord',
114
-
params: array_filter([
115
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
116
-
'collection' => 'app.bsky.feed.post',
117
-
'rkey' => $rkey,
118
-
'cid' => $cid,
119
-
])
118
+
$response = $this->atp->atproto->repo->getRecord(
119
+
repo: $this->atp->client->session()->did(),
120
+
collection: 'app.bsky.feed.post',
121
+
rkey: $rkey,
122
+
cid: $cid
120
123
);
121
124
122
125
return $response->json('value');
···
124
127
125
128
/**
126
129
* Create a reply to another post
130
+
*
131
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create)
127
132
*/
133
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
134
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')]
128
135
public function reply(
129
136
StrongRef $parent,
130
137
StrongRef $root,
···
151
158
152
159
/**
153
160
* Create a quote post (post with embedded post)
161
+
*
162
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create)
154
163
*/
164
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
165
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')]
155
166
public function quote(
156
167
StrongRef $quotedPost,
157
168
string|array|Recordable $content,
···
175
186
176
187
/**
177
188
* Create a post with images
189
+
*
190
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create)
178
191
*/
192
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
193
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')]
179
194
public function withImages(
180
195
string|array|Recordable $content,
181
196
array $images,
···
199
214
200
215
/**
201
216
* Create a post with external link embed
217
+
*
218
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create)
202
219
*/
220
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
221
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')]
203
222
public function withLink(
204
223
string|array|Recordable $content,
205
224
string $uri,
+34
-15
src/Client/Records/ProfileRecordClient.php
+34
-15
src/Client/Records/ProfileRecordClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Records;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
6
7
use SocialDept\AtpClient\Data\StrongRef;
8
+
use SocialDept\AtpClient\Enums\Scope;
7
9
8
10
class ProfileRecordClient extends Request
9
11
{
10
12
/**
11
13
* Update profile
14
+
*
15
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
12
16
*/
17
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
18
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
13
19
public function update(array $profile): StrongRef
14
20
{
15
21
// Ensure $type is set
···
17
23
$profile['$type'] = 'app.bsky.actor.profile';
18
24
}
19
25
20
-
$response = $this->atp->client->post(
21
-
endpoint: 'com.atproto.repo.putRecord',
22
-
body: [
23
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
24
-
'collection' => 'app.bsky.actor.profile',
25
-
'rkey' => 'self', // Profile records always use 'self' as rkey
26
-
'record' => $profile,
27
-
]
26
+
$response = $this->atp->atproto->repo->putRecord(
27
+
repo: $this->atp->client->session()->did(),
28
+
collection: 'app.bsky.actor.profile',
29
+
rkey: 'self', // Profile records always use 'self' as rkey
30
+
record: $profile
28
31
);
29
32
30
33
return StrongRef::fromResponse($response->json());
···
32
35
33
36
/**
34
37
* Get current profile
38
+
*
39
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
35
40
*/
41
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
36
42
public function get(): array
37
43
{
38
-
$response = $this->atp->client->get(
39
-
endpoint: 'com.atproto.repo.getRecord',
40
-
params: [
41
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
42
-
'collection' => 'app.bsky.actor.profile',
43
-
'rkey' => 'self',
44
-
]
44
+
$response = $this->atp->atproto->repo->getRecord(
45
+
repo: $this->atp->client->session()->did(),
46
+
collection: 'app.bsky.actor.profile',
47
+
rkey: 'self'
45
48
);
46
49
47
50
return $response->json('value');
···
49
52
50
53
/**
51
54
* Update display name
55
+
*
56
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
52
57
*/
58
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
59
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
53
60
public function updateDisplayName(string $displayName): StrongRef
54
61
{
55
62
$profile = $this->getOrCreateProfile();
···
60
67
61
68
/**
62
69
* Update description/bio
70
+
*
71
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
63
72
*/
73
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
74
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
64
75
public function updateDescription(string $description): StrongRef
65
76
{
66
77
$profile = $this->getOrCreateProfile();
···
71
82
72
83
/**
73
84
* Update avatar
85
+
*
86
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
74
87
*/
88
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
89
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
75
90
public function updateAvatar(array $avatarBlob): StrongRef
76
91
{
77
92
$profile = $this->getOrCreateProfile();
···
82
97
83
98
/**
84
99
* Update banner
100
+
*
101
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
85
102
*/
103
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
104
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
86
105
public function updateBanner(array $bannerBlob): StrongRef
87
106
{
88
107
$profile = $this->getOrCreateProfile();
+8
src/Client/Requests/Atproto/IdentityRequestClient.php
+8
src/Client/Requests/Atproto/IdentityRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class IdentityRequestClient extends Request
9
11
{
10
12
/**
11
13
* Resolve handle to DID
14
+
*
15
+
* @requires transition:generic (rpc:com.atproto.identity.resolveHandle)
12
16
*
13
17
* @see https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle
14
18
*/
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.identity.resolveHandle')]
15
20
public function resolveHandle(string $handle): Response
16
21
{
17
22
return $this->atp->client->get(
···
23
28
/**
24
29
* Update handle
25
30
*
31
+
* @requires atproto (identity:handle)
32
+
*
26
33
* @see https://docs.bsky.app/docs/api/com-atproto-identity-update-handle
27
34
*/
35
+
#[RequiresScope(Scope::Atproto, granular: 'identity:handle')]
28
36
public function updateHandle(string $handle): Response
29
37
{
30
38
return $this->atp->client->post(
+50
src/Client/Requests/Atproto/RepoRequestClient.php
+50
src/Client/Requests/Atproto/RepoRequestClient.php
···
4
4
5
5
use Illuminate\Http\UploadedFile;
6
6
use InvalidArgumentException;
7
+
use SocialDept\AtpClient\Attributes\RequiresScope;
8
+
use SocialDept\AtpClient\Auth\ScopeChecker;
7
9
use SocialDept\AtpClient\Client\Requests\Request;
10
+
use SocialDept\AtpClient\Enums\Scope;
8
11
use SocialDept\AtpClient\Http\Response;
9
12
use SplFileInfo;
10
13
use Throwable;
···
14
17
/**
15
18
* Create a record
16
19
*
20
+
* @requires transition:generic OR repo:[collection]?action=create
21
+
*
17
22
* @see https://docs.bsky.app/docs/api/com-atproto-repo-create-record
18
23
*/
24
+
#[RequiresScope(Scope::TransitionGeneric, description: 'Create records in repository')]
19
25
public function createRecord(
20
26
string $repo,
21
27
string $collection,
···
24
30
bool $validate = true,
25
31
?string $swapCommit = null
26
32
): Response {
33
+
$this->checkCollectionScope($collection, 'create');
34
+
27
35
return $this->atp->client->post(
28
36
endpoint: 'com.atproto.repo.createRecord',
29
37
body: array_filter(
···
35
43
36
44
/**
37
45
* Delete a record
46
+
*
47
+
* @requires transition:generic OR repo:[collection]?action=delete
38
48
*
39
49
* @see https://docs.bsky.app/docs/api/com-atproto-repo-delete-record
40
50
*/
51
+
#[RequiresScope(Scope::TransitionGeneric, description: 'Delete records from repository')]
41
52
public function deleteRecord(
42
53
string $repo,
43
54
string $collection,
···
45
56
?string $swapRecord = null,
46
57
?string $swapCommit = null
47
58
): Response {
59
+
$this->checkCollectionScope($collection, 'delete');
60
+
48
61
return $this->atp->client->post(
49
62
endpoint: 'com.atproto.repo.deleteRecord',
50
63
body: array_filter(
···
57
70
/**
58
71
* Put (upsert) a record
59
72
*
73
+
* @requires transition:generic OR repo:[collection]?action=update
74
+
*
60
75
* @see https://docs.bsky.app/docs/api/com-atproto-repo-put-record
61
76
*/
77
+
#[RequiresScope(Scope::TransitionGeneric, description: 'Update records in repository')]
62
78
public function putRecord(
63
79
string $repo,
64
80
string $collection,
···
68
84
?string $swapRecord = null,
69
85
?string $swapCommit = null
70
86
): Response {
87
+
$this->checkCollectionScope($collection, 'update');
88
+
71
89
return $this->atp->client->post(
72
90
endpoint: 'com.atproto.repo.putRecord',
73
91
body: array_filter(
···
80
98
/**
81
99
* Get a record
82
100
*
101
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
102
+
*
83
103
* @see https://docs.bsky.app/docs/api/com-atproto-repo-get-record
84
104
*/
105
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
85
106
public function getRecord(
86
107
string $repo,
87
108
string $collection,
···
97
118
/**
98
119
* List records in a collection
99
120
*
121
+
* @requires transition:generic (rpc:com.atproto.repo.listRecords)
122
+
*
100
123
* @see https://docs.bsky.app/docs/api/com-atproto-repo-list-records
101
124
*/
125
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.listRecords')]
102
126
public function listRecords(
103
127
string $repo,
104
128
string $collection,
···
117
141
*
118
142
* The blob will be deleted if it is not referenced within a time window.
119
143
*
144
+
* @requires transition:generic (blob:*\/*\)
145
+
*
120
146
* @param UploadedFile|SplFileInfo|string $file The file to upload
121
147
* @param string|null $mimeType MIME type (required for string input, auto-detected for file objects)
122
148
*
···
124
150
*
125
151
* @see https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
126
152
*/
153
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'blob:*/*')]
127
154
public function uploadBlob(UploadedFile|SplFileInfo|string $file, ?string $mimeType = null): Response
128
155
{
129
156
// Handle different input types
···
148
175
/**
149
176
* Describe the repository
150
177
*
178
+
* @requires transition:generic (rpc:com.atproto.repo.describeRepo)
179
+
*
151
180
* @see https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo
152
181
*/
182
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.describeRepo')]
153
183
public function describeRepo(string $repo): Response
154
184
{
155
185
return $this->atp->client->get(
156
186
endpoint: 'com.atproto.repo.describeRepo',
157
187
params: compact('repo')
158
188
);
189
+
}
190
+
191
+
/**
192
+
* Check if the session has repo access for a specific collection and action.
193
+
*
194
+
* This check is in addition to the transition:generic scope check.
195
+
* Users need either transition:generic OR the specific repo scope.
196
+
*/
197
+
protected function checkCollectionScope(string $collection, string $action): void
198
+
{
199
+
$session = $this->atp->client->session();
200
+
$checker = app(ScopeChecker::class);
201
+
202
+
// If user has transition:generic, they have broad access
203
+
if ($checker->hasScope($session, Scope::TransitionGeneric)) {
204
+
return;
205
+
}
206
+
207
+
// Otherwise, check for specific repo scope
208
+
$checker->checkRepoScopeOrFail($session, $collection, $action);
159
209
}
160
210
}
+8
src/Client/Requests/Atproto/ServerRequestClient.php
+8
src/Client/Requests/Atproto/ServerRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class ServerRequestClient extends Request
9
11
{
10
12
/**
11
13
* Get current session
14
+
*
15
+
* @requires atproto (rpc:com.atproto.server.getSession)
12
16
*
13
17
* @see https://docs.bsky.app/docs/api/com-atproto-server-get-session
14
18
*/
19
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.server.getSession')]
15
20
public function getSession(): Response
16
21
{
17
22
return $this->atp->client->get(
···
22
27
/**
23
28
* Describe server
24
29
*
30
+
* @requires atproto (rpc:com.atproto.server.describeServer)
31
+
*
25
32
* @see https://docs.bsky.app/docs/api/com-atproto-server-describe-server
26
33
*/
34
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.server.describeServer')]
27
35
public function describeServer(): Response
28
36
{
29
37
return $this->atp->client->get(
+26
src/Client/Requests/Atproto/SyncRequestClient.php
+26
src/Client/Requests/Atproto/SyncRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class SyncRequestClient extends Request
9
11
{
10
12
/**
11
13
* Get a blob associated with a given account
14
+
*
15
+
* @requires atproto (rpc:com.atproto.sync.getBlob)
12
16
*
13
17
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
14
18
*/
19
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.getBlob')]
15
20
public function getBlob(string $did, string $cid): Response
16
21
{
17
22
return $this->atp->client->get(
···
22
27
23
28
/**
24
29
* Download a repository export as CAR file
30
+
*
31
+
* @requires atproto (rpc:com.atproto.sync.getRepo)
25
32
*
26
33
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo
27
34
*/
35
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.getRepo')]
28
36
public function getRepo(string $did, ?string $since = null): Response
29
37
{
30
38
return $this->atp->client->get(
···
35
43
36
44
/**
37
45
* Enumerates all the DID, rev, and commit CID for all repos hosted by this service
46
+
*
47
+
* @requires atproto (rpc:com.atproto.sync.listRepos)
38
48
*
39
49
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-repos
40
50
*/
51
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.listRepos')]
41
52
public function listRepos(int $limit = 500, ?string $cursor = null): Response
42
53
{
43
54
return $this->atp->client->get(
···
48
59
49
60
/**
50
61
* Get the current commit CID & revision of the specified repo
62
+
*
63
+
* @requires atproto (rpc:com.atproto.sync.getLatestCommit)
51
64
*
52
65
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-latest-commit
53
66
*/
67
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.getLatestCommit')]
54
68
public function getLatestCommit(string $did): Response
55
69
{
56
70
return $this->atp->client->get(
···
62
76
/**
63
77
* Get data blocks needed to prove the existence or non-existence of record
64
78
*
79
+
* @requires atproto (rpc:com.atproto.sync.getRecord)
80
+
*
65
81
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-record
66
82
*/
83
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.getRecord')]
67
84
public function getRecord(string $did, string $collection, string $rkey): Response
68
85
{
69
86
return $this->atp->client->get(
···
75
92
/**
76
93
* List blob CIDs for an account, since some repo revision
77
94
*
95
+
* @requires atproto (rpc:com.atproto.sync.listBlobs)
96
+
*
78
97
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs
79
98
*/
99
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.listBlobs')]
80
100
public function listBlobs(
81
101
string $did,
82
102
?string $since = null,
···
92
112
/**
93
113
* Get data blocks from a given repo, by CID
94
114
*
115
+
* @requires atproto (rpc:com.atproto.sync.getBlocks)
116
+
*
95
117
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blocks
96
118
*/
119
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.getBlocks')]
97
120
public function getBlocks(string $did, array $cids): Response
98
121
{
99
122
return $this->atp->client->get(
···
105
128
/**
106
129
* Get the hosting status for a repository, on this server
107
130
*
131
+
* @requires atproto (rpc:com.atproto.sync.getRepoStatus)
132
+
*
108
133
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status
109
134
*/
135
+
#[RequiresScope(Scope::Atproto, granular: 'rpc:com.atproto.sync.getRepoStatus')]
110
136
public function getRepoStatus(string $did): Response
111
137
{
112
138
return $this->atp->client->get(
+5
src/Client/Requests/Bsky/ActorRequestClient.php
+5
src/Client/Requests/Bsky/ActorRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class ActorRequestClient extends Request
···
10
12
/**
11
13
* Get actor profile
12
14
*
15
+
* @requires transition:generic (rpc:app.bsky.actor.getProfile)
16
+
*
13
17
* @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profile
14
18
*/
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.actor.getProfile')]
15
20
public function getProfile(string $actor): Response
16
21
{
17
22
return $this->atp->client->get(
+20
src/Client/Requests/Bsky/FeedRequestClient.php
+20
src/Client/Requests/Bsky/FeedRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class FeedRequestClient extends Request
···
10
12
/**
11
13
* Get timeline feed
12
14
*
15
+
* @requires transition:generic (rpc:app.bsky.feed.getTimeline)
16
+
*
13
17
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-timeline
14
18
*/
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
15
20
public function getTimeline(int $limit = 50, ?string $cursor = null): Response
16
21
{
17
22
return $this->atp->client->get(
···
23
28
/**
24
29
* Get author feed
25
30
*
31
+
* @requires transition:generic (rpc:app.bsky.feed.getAuthorFeed)
32
+
*
26
33
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed
27
34
*/
35
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getAuthorFeed')]
28
36
public function getAuthorFeed(
29
37
string $actor,
30
38
int $limit = 50,
···
38
46
39
47
/**
40
48
* Get post thread
49
+
*
50
+
* @requires transition:generic (rpc:app.bsky.feed.getPostThread)
41
51
*
42
52
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
43
53
*/
54
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getPostThread')]
44
55
public function getPostThread(string $uri, int $depth = 6): Response
45
56
{
46
57
return $this->atp->client->get(
···
51
62
52
63
/**
53
64
* Search posts
65
+
*
66
+
* @requires transition:generic (rpc:app.bsky.feed.searchPosts)
54
67
*
55
68
* @see https://docs.bsky.app/docs/api/app-bsky-feed-search-posts
56
69
*/
70
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.searchPosts')]
57
71
public function searchPosts(
58
72
string $q,
59
73
int $limit = 25,
···
68
82
/**
69
83
* Get likes for a post
70
84
*
85
+
* @requires transition:generic (rpc:app.bsky.feed.getLikes)
86
+
*
71
87
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-likes
72
88
*/
89
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getLikes')]
73
90
public function getLikes(
74
91
string $uri,
75
92
int $limit = 50,
···
84
101
/**
85
102
* Get reposts for a post
86
103
*
104
+
* @requires transition:generic (rpc:app.bsky.feed.getRepostedBy)
105
+
*
87
106
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-reposted-by
88
107
*/
108
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getRepostedBy')]
89
109
public function getRepostedBy(
90
110
string $uri,
91
111
int $limit = 50,
+11
src/Client/Requests/Chat/ActorRequestClient.php
+11
src/Client/Requests/Chat/ActorRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Chat;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class ActorRequestClient extends Request
···
10
12
/**
11
13
* Get actor metadata
12
14
*
15
+
* @requires transition:chat.bsky (rpc:chat.bsky.actor.getActorMetadata)
16
+
*
13
17
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data
14
18
*/
19
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.getActorMetadata')]
15
20
public function getActorMetadata(): Response
16
21
{
17
22
return $this->atp->client->get(
···
22
27
/**
23
28
* Export account data
24
29
*
30
+
* @requires transition:chat.bsky (rpc:chat.bsky.actor.exportAccountData)
31
+
*
25
32
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data
26
33
*/
34
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.exportAccountData')]
27
35
public function exportAccountData(): Response
28
36
{
29
37
return $this->atp->client->get(
···
34
42
/**
35
43
* Delete account
36
44
*
45
+
* @requires transition:chat.bsky (rpc:chat.bsky.actor.deleteAccount)
46
+
*
37
47
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-delete-account
38
48
*/
49
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.deleteAccount')]
39
50
public function deleteAccount(): Response
40
51
{
41
52
return $this->atp->client->post(
+38
src/Client/Requests/Chat/ConvoRequestClient.php
+38
src/Client/Requests/Chat/ConvoRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Chat;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class ConvoRequestClient extends Request
···
10
12
/**
11
13
* Get conversation
12
14
*
15
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getConvo)
16
+
*
13
17
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo
14
18
*/
19
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getConvo')]
15
20
public function getConvo(string $convoId): Response
16
21
{
17
22
return $this->atp->client->get(
···
23
28
/**
24
29
* Get conversation for members
25
30
*
31
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getConvoForMembers)
32
+
*
26
33
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo-for-members
27
34
*/
35
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getConvoForMembers')]
28
36
public function getConvoForMembers(array $members): Response
29
37
{
30
38
return $this->atp->client->get(
···
35
43
36
44
/**
37
45
* List conversations
46
+
*
47
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.listConvos)
38
48
*
39
49
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-list-convos
40
50
*/
51
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.listConvos')]
41
52
public function listConvos(int $limit = 50, ?string $cursor = null): Response
42
53
{
43
54
return $this->atp->client->get(
···
48
59
49
60
/**
50
61
* Get messages
62
+
*
63
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getMessages)
51
64
*
52
65
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-messages
53
66
*/
67
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getMessages')]
54
68
public function getMessages(
55
69
string $convoId,
56
70
int $limit = 50,
···
65
79
/**
66
80
* Send message
67
81
*
82
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.sendMessage)
83
+
*
68
84
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message
69
85
*/
86
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.sendMessage')]
70
87
public function sendMessage(string $convoId, array $message): Response
71
88
{
72
89
return $this->atp->client->post(
···
77
94
78
95
/**
79
96
* Send message batch
97
+
*
98
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.sendMessageBatch)
80
99
*
81
100
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message-batch
82
101
*/
102
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.sendMessageBatch')]
83
103
public function sendMessageBatch(array $items): Response
84
104
{
85
105
return $this->atp->client->post(
···
90
110
91
111
/**
92
112
* Delete message for self
113
+
*
114
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.deleteMessageForSelf)
93
115
*
94
116
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-delete-message-for-self
95
117
*/
118
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.deleteMessageForSelf')]
96
119
public function deleteMessageForSelf(string $convoId, string $messageId): Response
97
120
{
98
121
return $this->atp->client->post(
···
104
127
/**
105
128
* Update read status
106
129
*
130
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.updateRead)
131
+
*
107
132
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-update-read
108
133
*/
134
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.updateRead')]
109
135
public function updateRead(string $convoId, ?string $messageId = null): Response
110
136
{
111
137
return $this->atp->client->post(
···
117
143
/**
118
144
* Mute conversation
119
145
*
146
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.muteConvo)
147
+
*
120
148
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-mute-convo
121
149
*/
150
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.muteConvo')]
122
151
public function muteConvo(string $convoId): Response
123
152
{
124
153
return $this->atp->client->post(
···
130
159
/**
131
160
* Unmute conversation
132
161
*
162
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.unmuteConvo)
163
+
*
133
164
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-unmute-convo
134
165
*/
166
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.unmuteConvo')]
135
167
public function unmuteConvo(string $convoId): Response
136
168
{
137
169
return $this->atp->client->post(
···
142
174
143
175
/**
144
176
* Leave conversation
177
+
*
178
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.leaveConvo)
145
179
*
146
180
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-leave-convo
147
181
*/
182
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.leaveConvo')]
148
183
public function leaveConvo(string $convoId): Response
149
184
{
150
185
return $this->atp->client->post(
···
156
191
/**
157
192
* Get log
158
193
*
194
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getLog)
195
+
*
159
196
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-log
160
197
*/
198
+
#[RequiresScope(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getLog')]
161
199
public function getLog(?string $cursor = null): Response
162
200
{
163
201
return $this->atp->client->get(
+26
src/Client/Requests/Ozone/ModerationRequestClient.php
+26
src/Client/Requests/Ozone/ModerationRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class ModerationRequestClient extends Request
9
11
{
10
12
/**
11
13
* Get moderation event
14
+
*
15
+
* @requires transition:generic (rpc:tools.ozone.moderation.getEvent)
12
16
*
13
17
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-event
14
18
*/
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getEvent')]
15
20
public function getModerationEvent(int $id): Response
16
21
{
17
22
return $this->atp->client->get(
···
22
27
23
28
/**
24
29
* Get moderation events
30
+
*
31
+
* @requires transition:generic (rpc:tools.ozone.moderation.getEvents)
25
32
*
26
33
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events
27
34
*/
35
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getEvents')]
28
36
public function getModerationEvents(
29
37
?string $subject = null,
30
38
?array $types = null,
···
44
52
/**
45
53
* Get record
46
54
*
55
+
* @requires transition:generic (rpc:tools.ozone.moderation.getRecord)
56
+
*
47
57
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-record
48
58
*/
59
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getRecord')]
49
60
public function getRecord(string $uri, ?string $cid = null): Response
50
61
{
51
62
return $this->atp->client->get(
···
56
67
57
68
/**
58
69
* Get repo
70
+
*
71
+
* @requires transition:generic (rpc:tools.ozone.moderation.getRepo)
59
72
*
60
73
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-repo
61
74
*/
75
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getRepo')]
62
76
public function getRepo(string $did): Response
63
77
{
64
78
return $this->atp->client->get(
···
70
84
/**
71
85
* Query events
72
86
*
87
+
* @requires transition:generic (rpc:tools.ozone.moderation.queryEvents)
88
+
*
73
89
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events
74
90
*/
91
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.queryEvents')]
75
92
public function queryEvents(
76
93
?array $types = null,
77
94
?string $createdBy = null,
···
92
109
/**
93
110
* Query statuses
94
111
*
112
+
* @requires transition:generic (rpc:tools.ozone.moderation.queryStatuses)
113
+
*
95
114
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-statuses
96
115
*/
116
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.queryStatuses')]
97
117
public function queryStatuses(
98
118
?string $subject = null,
99
119
?array $tags = null,
···
113
133
/**
114
134
* Search repos
115
135
*
136
+
* @requires transition:generic (rpc:tools.ozone.moderation.searchRepos)
137
+
*
116
138
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-search-repos
117
139
*/
140
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.searchRepos')]
118
141
public function searchRepos(
119
142
?string $term = null,
120
143
?string $invitedBy = null,
···
133
156
/**
134
157
* Emit moderation event
135
158
*
159
+
* @requires transition:generic (rpc:tools.ozone.moderation.emitEvent)
160
+
*
136
161
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-emit-event
137
162
*/
163
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.emitEvent')]
138
164
public function emitEvent(
139
165
array $event,
140
166
string $subject,
+8
src/Client/Requests/Ozone/ServerRequestClient.php
+8
src/Client/Requests/Ozone/ServerRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class ServerRequestClient extends Request
9
11
{
10
12
/**
11
13
* Get blob
14
+
*
15
+
* @requires transition:generic (rpc:tools.ozone.server.getBlob)
12
16
*
13
17
* @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config
14
18
*/
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.server.getBlob')]
15
20
public function getBlob(string $did, string $cid): Response
16
21
{
17
22
return $this->atp->client->get(
···
23
28
/**
24
29
* Get config
25
30
*
31
+
* @requires transition:generic (rpc:tools.ozone.server.getConfig)
32
+
*
26
33
* @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config
27
34
*/
35
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.server.getConfig')]
28
36
public function getConfig(): Response
29
37
{
30
38
return $this->atp->client->get(
+17
src/Client/Requests/Ozone/TeamRequestClient.php
+17
src/Client/Requests/Ozone/TeamRequestClient.php
···
2
2
3
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
4
5
+
use SocialDept\AtpClient\Attributes\RequiresScope;
5
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Enums\Scope;
6
8
use SocialDept\AtpClient\Http\Response;
7
9
8
10
class TeamRequestClient extends Request
···
10
12
/**
11
13
* Get team member
12
14
*
15
+
* @requires transition:generic (rpc:tools.ozone.team.getMember)
16
+
*
13
17
* @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members
14
18
*/
19
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.getMember')]
15
20
public function getTeamMember(string $did): Response
16
21
{
17
22
return $this->atp->client->get(
···
23
28
/**
24
29
* List team members
25
30
*
31
+
* @requires transition:generic (rpc:tools.ozone.team.listMembers)
32
+
*
26
33
* @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members
27
34
*/
35
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.listMembers')]
28
36
public function listTeamMembers(int $limit = 50, ?string $cursor = null): Response
29
37
{
30
38
return $this->atp->client->get(
···
36
44
/**
37
45
* Add team member
38
46
*
47
+
* @requires transition:generic (rpc:tools.ozone.team.addMember)
48
+
*
39
49
* @see https://docs.bsky.app/docs/api/tools-ozone-team-add-member
40
50
*/
51
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.addMember')]
41
52
public function addTeamMember(string $did, string $role): Response
42
53
{
43
54
return $this->atp->client->post(
···
48
59
49
60
/**
50
61
* Update team member
62
+
*
63
+
* @requires transition:generic (rpc:tools.ozone.team.updateMember)
51
64
*
52
65
* @see https://docs.bsky.app/docs/api/tools-ozone-team-update-member
53
66
*/
67
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.updateMember')]
54
68
public function updateTeamMember(
55
69
string $did,
56
70
?bool $disabled = null,
···
68
82
/**
69
83
* Delete team member
70
84
*
85
+
* @requires transition:generic (rpc:tools.ozone.team.deleteMember)
86
+
*
71
87
* @see https://docs.bsky.app/docs/api/tools-ozone-team-delete-member
72
88
*/
89
+
#[RequiresScope(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.deleteMember')]
73
90
public function deleteTeamMember(string $did): Response
74
91
{
75
92
return $this->atp->client->post(
+11
src/Contracts/HasAtpSession.php
+11
src/Contracts/HasAtpSession.php
+5
src/Data/AccessToken.php
+5
src/Data/AccessToken.php
···
2
2
3
3
namespace SocialDept\AtpClient\Data;
4
4
5
+
use SocialDept\AtpClient\Enums\AuthType;
6
+
5
7
class AccessToken
6
8
{
7
9
public function __construct(
···
12
14
public readonly ?string $handle = null,
13
15
public readonly ?string $issuer = null,
14
16
public readonly array $scope = [],
17
+
public readonly AuthType $authType = AuthType::OAuth,
15
18
) {}
16
19
17
20
/**
···
32
35
handle: $handle,
33
36
issuer: $issuer,
34
37
scope: isset($data['scope']) ? explode(' ', $data['scope']) : [],
38
+
authType: AuthType::OAuth,
35
39
);
36
40
}
37
41
···
44
48
handle: $data['handle'] ?? $handle,
45
49
issuer: $issuer,
46
50
scope: ['atproto', 'transition:generic', 'transition:email'],
51
+
authType: AuthType::Legacy,
47
52
);
48
53
}
49
54
}
+3
src/Data/Credentials.php
+3
src/Data/Credentials.php
···
2
2
3
3
namespace SocialDept\AtpClient\Data;
4
4
5
+
use SocialDept\AtpClient\Enums\AuthType;
6
+
5
7
class Credentials
6
8
{
7
9
public function __construct(
···
12
14
public readonly ?string $handle = null,
13
15
public readonly ?string $issuer = null,
14
16
public readonly array $scope = [],
17
+
public readonly AuthType $authType = AuthType::OAuth,
15
18
) {}
16
19
17
20
public function isExpired(): bool
+9
src/Enums/AuthType.php
+9
src/Enums/AuthType.php
+69
src/Enums/Scope.php
+69
src/Enums/Scope.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums;
4
+
5
+
enum Scope: string
6
+
{
7
+
// Transition scopes (current)
8
+
case Atproto = 'atproto';
9
+
case TransitionGeneric = 'transition:generic';
10
+
case TransitionEmail = 'transition:email';
11
+
case TransitionChat = 'transition:chat.bsky';
12
+
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 string|null $action The action (create, update, delete)
18
+
*/
19
+
public static function repo(string $collection, ?string $action = null): string
20
+
{
21
+
$scope = "repo:{$collection}";
22
+
23
+
if ($action !== null) {
24
+
$scope .= "?action={$action}";
25
+
}
26
+
27
+
return $scope;
28
+
}
29
+
30
+
/**
31
+
* Build an RPC scope string for endpoint access.
32
+
*
33
+
* @param string $lxm The lexicon method ID (e.g., 'app.bsky.feed.getTimeline')
34
+
*/
35
+
public static function rpc(string $lxm): string
36
+
{
37
+
return "rpc:{$lxm}";
38
+
}
39
+
40
+
/**
41
+
* Build a blob scope string for uploads.
42
+
*
43
+
* @param string|null $mimeType The mime type pattern (e.g., 'image/*', '*\/*')
44
+
*/
45
+
public static function blob(?string $mimeType = null): string
46
+
{
47
+
return 'blob:'.($mimeType ?? '*/*');
48
+
}
49
+
50
+
/**
51
+
* Build an account scope string.
52
+
*
53
+
* @param string $attr The account attribute (e.g., 'email', 'status')
54
+
*/
55
+
public static function account(string $attr): string
56
+
{
57
+
return "account:{$attr}";
58
+
}
59
+
60
+
/**
61
+
* Build an identity scope string.
62
+
*
63
+
* @param string $attr The identity attribute (e.g., 'handle')
64
+
*/
65
+
public static function identity(string $attr): string
66
+
{
67
+
return "identity:{$attr}";
68
+
}
69
+
}
+10
src/Enums/ScopeAuthorizationFailure.php
+10
src/Enums/ScopeAuthorizationFailure.php
+9
src/Enums/ScopeEnforcementLevel.php
+9
src/Enums/ScopeEnforcementLevel.php
+1
-1
src/Events/OAuthTokenRefreshed.php
src/Events/TokenRefreshed.php
+1
-1
src/Events/OAuthTokenRefreshed.php
src/Events/TokenRefreshed.php
+1
-1
src/Events/OAuthTokenRefreshing.php
src/Events/TokenRefreshing.php
+1
-1
src/Events/OAuthTokenRefreshing.php
src/Events/TokenRefreshing.php
+22
src/Exceptions/MissingScopeException.php
+22
src/Exceptions/MissingScopeException.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Exceptions;
4
+
5
+
class MissingScopeException extends \Exception
6
+
{
7
+
public function __construct(
8
+
public readonly array $required,
9
+
public readonly array $granted,
10
+
?string $message = null,
11
+
) {
12
+
parent::__construct($message ?? $this->buildMessage());
13
+
}
14
+
15
+
protected function buildMessage(): string
16
+
{
17
+
$required = implode(', ', $this->required);
18
+
$granted = empty($this->granted) ? 'none' : implode(', ', $this->granted);
19
+
20
+
return "Missing required scope(s): {$required}. Granted: {$granted}.";
21
+
}
22
+
}
+26
src/Exceptions/ScopeAuthorizationException.php
+26
src/Exceptions/ScopeAuthorizationException.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Exceptions;
4
+
5
+
use Illuminate\Http\Request;
6
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
7
+
use Symfony\Component\HttpFoundation\Response;
8
+
9
+
class ScopeAuthorizationException extends MissingScopeException
10
+
{
11
+
/**
12
+
* Render the exception as an HTTP response.
13
+
*/
14
+
public function render(Request $request): Response
15
+
{
16
+
$action = config('atp-client.scope_authorization.failure_action', ScopeAuthorizationFailure::Abort);
17
+
18
+
return match ($action) {
19
+
ScopeAuthorizationFailure::Redirect => redirect(
20
+
config('atp-client.scope_authorization.redirect_to', '/login')
21
+
),
22
+
ScopeAuthorizationFailure::Exception => throw $this,
23
+
default => abort(403, $this->getMessage()),
24
+
};
25
+
}
26
+
}
+2
-2
src/Facades/Atp.php
+2
-2
src/Facades/Atp.php
···
8
8
use SocialDept\AtpClient\Contracts\CredentialProvider;
9
9
10
10
/**
11
-
* @method static AtpClient as(string $handleOrDid)
12
-
* @method static AtpClient login(string $handleOrDid, string $password)
11
+
* @method static AtpClient as(string $actor)
12
+
* @method static AtpClient login(string $actor, string $password)
13
13
* @method static OAuthEngine oauth()
14
14
* @method static void setDefaultProvider(CredentialProvider $provider)
15
15
*
+28
src/Facades/AtpScope.php
+28
src/Facades/AtpScope.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Facades;
4
+
5
+
use Illuminate\Support\Facades\Facade;
6
+
use SocialDept\AtpClient\Auth\ScopeGate;
7
+
use SocialDept\AtpClient\Enums\Scope;
8
+
use SocialDept\AtpClient\Session\Session;
9
+
10
+
/**
11
+
* @method static ScopeGate forSession(Session $session)
12
+
* @method static ScopeGate forUser(string $actor)
13
+
* @method static bool can(string|Scope $scope)
14
+
* @method static bool canAny(array $scopes)
15
+
* @method static bool canAll(array $scopes)
16
+
* @method static bool cannot(string|Scope $scope)
17
+
* @method static void authorize(string|Scope ...$scopes)
18
+
* @method static array granted()
19
+
*
20
+
* @see \SocialDept\AtpClient\Auth\ScopeGate
21
+
*/
22
+
class AtpScope extends Facade
23
+
{
24
+
protected static function getFacadeAccessor(): string
25
+
{
26
+
return 'atp-scope';
27
+
}
28
+
}
+53
-1
src/Http/HasHttp.php
+53
-1
src/Http/HasHttp.php
···
3
3
namespace SocialDept\AtpClient\Http;
4
4
5
5
use Illuminate\Http\Client\Response as LaravelResponse;
6
+
use Illuminate\Support\Facades\Http;
6
7
use InvalidArgumentException;
8
+
use SocialDept\AtpClient\Auth\ScopeChecker;
9
+
use SocialDept\AtpClient\Enums\Scope;
7
10
use SocialDept\AtpClient\Exceptions\ValidationException;
8
11
use SocialDept\AtpClient\Session\Session;
9
12
use SocialDept\AtpClient\Session\SessionManager;
···
16
19
protected string $did;
17
20
18
21
protected DPoPClient $dpopClient;
22
+
23
+
protected ?ScopeChecker $scopeChecker = null;
19
24
20
25
/**
21
26
* Make XRPC call
···
48
53
}
49
54
50
55
/**
51
-
* Build authenticated request with DPoP proof and automatic nonce retry
56
+
* Build authenticated request.
57
+
*
58
+
* OAuth sessions use DPoP proof with Bearer token.
59
+
* Legacy sessions use plain Bearer token.
52
60
*/
53
61
protected function buildAuthenticatedRequest(
54
62
Session $session,
55
63
string $url,
56
64
string $method
57
65
): \Illuminate\Http\Client\PendingRequest {
66
+
if ($session->isLegacy()) {
67
+
return Http::withHeader('Authorization', 'Bearer '.$session->accessToken());
68
+
}
69
+
58
70
return $this->dpopClient->request(
59
71
pdsEndpoint: $session->pdsEndpoint(),
60
72
url: $url,
···
118
130
->post($url);
119
131
120
132
return new Response($response);
133
+
}
134
+
135
+
/**
136
+
* Require specific scopes before making a request.
137
+
*
138
+
* Checks if the session has the required scopes. In strict mode, throws
139
+
* MissingScopeException if scopes are missing. In permissive mode, logs
140
+
* a warning but allows the request to proceed.
141
+
*
142
+
* @param string|Scope ...$scopes The required scopes
143
+
*
144
+
* @throws \SocialDept\AtpClient\Exceptions\MissingScopeException
145
+
*/
146
+
protected function requireScopes(string|Scope ...$scopes): void
147
+
{
148
+
$session = $this->sessions->session($this->did);
149
+
150
+
$this->getScopeChecker()->checkOrFail($session, $scopes);
151
+
}
152
+
153
+
/**
154
+
* Check if the session has a specific scope.
155
+
*/
156
+
protected function hasScope(string|Scope $scope): bool
157
+
{
158
+
$session = $this->sessions->session($this->did);
159
+
160
+
return $this->getScopeChecker()->hasScope($session, $scope);
161
+
}
162
+
163
+
/**
164
+
* Get the scope checker instance.
165
+
*/
166
+
protected function getScopeChecker(): ScopeChecker
167
+
{
168
+
if ($this->scopeChecker === null) {
169
+
$this->scopeChecker = app(ScopeChecker::class);
170
+
}
171
+
172
+
return $this->scopeChecker;
121
173
}
122
174
}
+85
src/Http/Middleware/RequiresScopeMiddleware.php
+85
src/Http/Middleware/RequiresScopeMiddleware.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Http\Middleware;
4
+
5
+
use Closure;
6
+
use Illuminate\Http\Request;
7
+
use SocialDept\AtpClient\Auth\ScopeChecker;
8
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
9
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
10
+
use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
11
+
use SocialDept\AtpClient\Session\SessionManager;
12
+
use Symfony\Component\HttpFoundation\Response;
13
+
14
+
class RequiresScopeMiddleware
15
+
{
16
+
public function __construct(
17
+
protected SessionManager $sessions,
18
+
protected ScopeChecker $checker,
19
+
) {}
20
+
21
+
/**
22
+
* Handle an incoming request.
23
+
*
24
+
* @param string ...$scopes
25
+
*/
26
+
public function handle(Request $request, Closure $next, string ...$scopes): Response
27
+
{
28
+
$user = $request->user();
29
+
30
+
// Ensure user is authenticated
31
+
if (! $user) {
32
+
return $this->handleFailure(
33
+
new ScopeAuthorizationException($scopes, [], 'User not authenticated.')
34
+
);
35
+
}
36
+
37
+
// Ensure user implements HasAtpSession
38
+
if (! $user instanceof HasAtpSession) {
39
+
return $this->handleFailure(
40
+
new ScopeAuthorizationException($scopes, [], 'User model must implement HasAtpSession interface.')
41
+
);
42
+
}
43
+
44
+
$did = $user->getAtpDid();
45
+
46
+
if (! $did) {
47
+
return $this->handleFailure(
48
+
new ScopeAuthorizationException($scopes, [], 'User has no ATP session.')
49
+
);
50
+
}
51
+
52
+
try {
53
+
$session = $this->sessions->session($did);
54
+
} catch (\Exception $e) {
55
+
return $this->handleFailure(
56
+
new ScopeAuthorizationException($scopes, [], 'Could not retrieve ATP session: '.$e->getMessage())
57
+
);
58
+
}
59
+
60
+
// Check ALL scopes (AND logic)
61
+
if (! $this->checker->check($session, $scopes)) {
62
+
$granted = $session->scopes();
63
+
$missing = array_diff($scopes, $granted);
64
+
65
+
return $this->handleFailure(
66
+
new ScopeAuthorizationException($missing, $granted)
67
+
);
68
+
}
69
+
70
+
return $next($request);
71
+
}
72
+
73
+
protected function handleFailure(ScopeAuthorizationException $exception): Response
74
+
{
75
+
$action = config('atp-client.scope_authorization.failure_action', ScopeAuthorizationFailure::Abort);
76
+
77
+
return match ($action) {
78
+
ScopeAuthorizationFailure::Redirect => redirect(
79
+
config('atp-client.scope_authorization.redirect_to', '/login')
80
+
),
81
+
ScopeAuthorizationFailure::Exception => throw $exception,
82
+
default => abort(403, $exception->getMessage()),
83
+
};
84
+
}
85
+
}
+1
src/Providers/ArrayCredentialProvider.php
+1
src/Providers/ArrayCredentialProvider.php
+52
src/Providers/CacheCredentialProvider.php
+52
src/Providers/CacheCredentialProvider.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Providers;
4
+
5
+
use Illuminate\Support\Facades\Cache;
6
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
7
+
use SocialDept\AtpClient\Data\AccessToken;
8
+
use SocialDept\AtpClient\Data\Credentials;
9
+
10
+
class CacheCredentialProvider implements CredentialProvider
11
+
{
12
+
protected string $prefix = 'atp:credentials:';
13
+
14
+
public function getCredentials(string $did): ?Credentials
15
+
{
16
+
return Cache::get($this->key($did));
17
+
}
18
+
19
+
public function storeCredentials(string $did, AccessToken $token): void
20
+
{
21
+
Cache::put($this->key($did), $this->toCredentials($token));
22
+
}
23
+
24
+
public function updateCredentials(string $did, AccessToken $token): void
25
+
{
26
+
$this->storeCredentials($did, $token);
27
+
}
28
+
29
+
public function removeCredentials(string $did): void
30
+
{
31
+
Cache::forget($this->key($did));
32
+
}
33
+
34
+
protected function key(string $did): string
35
+
{
36
+
return $this->prefix.$did;
37
+
}
38
+
39
+
protected function toCredentials(AccessToken $token): Credentials
40
+
{
41
+
return new Credentials(
42
+
did: $token->did,
43
+
accessToken: $token->accessJwt,
44
+
refreshToken: $token->refreshJwt,
45
+
expiresAt: $token->expiresAt,
46
+
handle: $token->handle,
47
+
issuer: $token->issuer,
48
+
scope: $token->scope,
49
+
authType: $token->authType,
50
+
);
51
+
}
52
+
}
+70
src/Providers/FileCredentialProvider.php
+70
src/Providers/FileCredentialProvider.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Providers;
4
+
5
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
6
+
use SocialDept\AtpClient\Data\AccessToken;
7
+
use SocialDept\AtpClient\Data\Credentials;
8
+
9
+
class FileCredentialProvider implements CredentialProvider
10
+
{
11
+
protected string $storagePath;
12
+
13
+
public function __construct(?string $storagePath = null)
14
+
{
15
+
$this->storagePath = $storagePath ?? storage_path('app/atp-credentials');
16
+
17
+
if (! is_dir($this->storagePath)) {
18
+
mkdir($this->storagePath, 0755, true);
19
+
}
20
+
}
21
+
22
+
public function getCredentials(string $did): ?Credentials
23
+
{
24
+
$path = $this->path($did);
25
+
26
+
if (! file_exists($path)) {
27
+
return null;
28
+
}
29
+
30
+
return unserialize(file_get_contents($path));
31
+
}
32
+
33
+
public function storeCredentials(string $did, AccessToken $token): void
34
+
{
35
+
file_put_contents($this->path($did), serialize($this->toCredentials($token)));
36
+
}
37
+
38
+
public function updateCredentials(string $did, AccessToken $token): void
39
+
{
40
+
$this->storeCredentials($did, $token);
41
+
}
42
+
43
+
public function removeCredentials(string $did): void
44
+
{
45
+
$path = $this->path($did);
46
+
47
+
if (file_exists($path)) {
48
+
unlink($path);
49
+
}
50
+
}
51
+
52
+
protected function path(string $did): string
53
+
{
54
+
return $this->storagePath.'/'.hash('sha256', $did).'.cred';
55
+
}
56
+
57
+
protected function toCredentials(AccessToken $token): Credentials
58
+
{
59
+
return new Credentials(
60
+
did: $token->did,
61
+
accessToken: $token->accessJwt,
62
+
refreshToken: $token->refreshJwt,
63
+
expiresAt: $token->expiresAt,
64
+
handle: $token->handle,
65
+
issuer: $token->issuer,
66
+
scope: $token->scope,
67
+
authType: $token->authType,
68
+
);
69
+
}
70
+
}
+52
src/Providers/SessionCredentialProvider.php
+52
src/Providers/SessionCredentialProvider.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Providers;
4
+
5
+
use Illuminate\Support\Facades\Session;
6
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
7
+
use SocialDept\AtpClient\Data\AccessToken;
8
+
use SocialDept\AtpClient\Data\Credentials;
9
+
10
+
class SessionCredentialProvider implements CredentialProvider
11
+
{
12
+
protected string $prefix = 'atp.credentials.';
13
+
14
+
public function getCredentials(string $did): ?Credentials
15
+
{
16
+
return Session::get($this->key($did));
17
+
}
18
+
19
+
public function storeCredentials(string $did, AccessToken $token): void
20
+
{
21
+
Session::put($this->key($did), $this->toCredentials($token));
22
+
}
23
+
24
+
public function updateCredentials(string $did, AccessToken $token): void
25
+
{
26
+
$this->storeCredentials($did, $token);
27
+
}
28
+
29
+
public function removeCredentials(string $did): void
30
+
{
31
+
Session::forget($this->key($did));
32
+
}
33
+
34
+
protected function key(string $did): string
35
+
{
36
+
return $this->prefix.$did;
37
+
}
38
+
39
+
protected function toCredentials(AccessToken $token): Credentials
40
+
{
41
+
return new Credentials(
42
+
did: $token->did,
43
+
accessToken: $token->accessJwt,
44
+
refreshToken: $token->refreshJwt,
45
+
expiresAt: $token->expiresAt,
46
+
handle: $token->handle,
47
+
issuer: $token->issuer,
48
+
scope: $token->scope,
49
+
authType: $token->authType,
50
+
);
51
+
}
52
+
}
+62
src/Session/Session.php
+62
src/Session/Session.php
···
4
4
5
5
use SocialDept\AtpClient\Data\Credentials;
6
6
use SocialDept\AtpClient\Data\DPoPKey;
7
+
use SocialDept\AtpClient\Enums\AuthType;
8
+
use SocialDept\AtpClient\Enums\Scope;
7
9
8
10
class Session
9
11
{
···
61
63
public function hasScope(string $scope): bool
62
64
{
63
65
return in_array($scope, $this->credentials->scope, true);
66
+
}
67
+
68
+
/**
69
+
* Check if the session has the given scope (alias for hasScope with Scope enum support).
70
+
*/
71
+
public function can(string|Scope $scope): bool
72
+
{
73
+
$scopeValue = $scope instanceof Scope ? $scope->value : $scope;
74
+
75
+
return $this->hasScope($scopeValue);
76
+
}
77
+
78
+
/**
79
+
* Check if the session has any of the given scopes.
80
+
*
81
+
* @param array<string|Scope> $scopes
82
+
*/
83
+
public function canAny(array $scopes): bool
84
+
{
85
+
foreach ($scopes as $scope) {
86
+
if ($this->can($scope)) {
87
+
return true;
88
+
}
89
+
}
90
+
91
+
return false;
92
+
}
93
+
94
+
/**
95
+
* Check if the session has all of the given scopes.
96
+
*
97
+
* @param array<string|Scope> $scopes
98
+
*/
99
+
public function canAll(array $scopes): bool
100
+
{
101
+
foreach ($scopes as $scope) {
102
+
if (! $this->can($scope)) {
103
+
return false;
104
+
}
105
+
}
106
+
107
+
return true;
108
+
}
109
+
110
+
/**
111
+
* Check if the session does NOT have the given scope.
112
+
*/
113
+
public function cannot(string|Scope $scope): bool
114
+
{
115
+
return ! $this->can($scope);
116
+
}
117
+
118
+
public function authType(): AuthType
119
+
{
120
+
return $this->credentials->authType;
121
+
}
122
+
123
+
public function isLegacy(): bool
124
+
{
125
+
return $this->credentials->authType === AuthType::Legacy;
64
126
}
65
127
66
128
public function withCredentials(Credentials $credentials): self
+23
-21
src/Session/SessionManager.php
+23
-21
src/Session/SessionManager.php
···
8
8
use SocialDept\AtpClient\Contracts\CredentialProvider;
9
9
use SocialDept\AtpClient\Contracts\KeyStore;
10
10
use SocialDept\AtpClient\Data\AccessToken;
11
-
use SocialDept\AtpClient\Events\OAuthTokenRefreshed;
12
-
use SocialDept\AtpClient\Events\OAuthTokenRefreshing;
11
+
use SocialDept\AtpClient\Events\TokenRefreshed;
12
+
use SocialDept\AtpClient\Events\TokenRefreshing;
13
13
use SocialDept\AtpClient\Exceptions\AuthenticationException;
14
14
use SocialDept\AtpClient\Exceptions\HandleResolutionException;
15
15
use SocialDept\AtpClient\Exceptions\SessionExpiredException;
16
16
use SocialDept\AtpResolver\Facades\Resolver;
17
+
use SocialDept\Resolver\Support\Identity;
17
18
18
19
class SessionManager
19
20
{
···
28
29
) {}
29
30
30
31
/**
31
-
* Resolve a handle or DID to a DID.
32
+
* Resolve an actor (handle or DID) to a DID.
32
33
*
33
34
* @throws HandleResolutionException
34
35
*/
35
-
protected function resolveToDid(string $handleOrDid): string
36
+
protected function resolveToDid(string $actor): string
36
37
{
37
38
// If already a DID, return as-is
38
-
if (str_starts_with($handleOrDid, 'did:')) {
39
-
return $handleOrDid;
39
+
if (Identity::isDid($actor)) {
40
+
return $actor;
40
41
}
41
42
42
43
// Resolve handle to DID
43
-
$did = Resolver::handleToDid($handleOrDid);
44
+
$did = Resolver::handleToDid($actor);
44
45
45
46
if (! $did) {
46
-
throw new HandleResolutionException($handleOrDid);
47
+
throw new HandleResolutionException($actor);
47
48
}
48
49
49
50
return $did;
50
51
}
51
52
52
53
/**
53
-
* Get or create session for handle or DID
54
+
* Get or create session for an actor.
54
55
*/
55
-
public function session(string $handleOrDid): Session
56
+
public function session(string $actor): Session
56
57
{
57
-
$did = $this->resolveToDid($handleOrDid);
58
+
$did = $this->resolveToDid($actor);
58
59
59
60
if (! isset($this->sessions[$did])) {
60
61
$this->sessions[$did] = $this->createSession($did);
···
64
65
}
65
66
66
67
/**
67
-
* Ensure session is valid, refresh if needed
68
+
* Ensure session is valid, refresh if needed.
68
69
*/
69
-
public function ensureValid(string $handleOrDid): Session
70
+
public function ensureValid(string $actor): Session
70
71
{
71
-
$session = $this->session($handleOrDid);
72
+
$session = $this->session($actor);
72
73
73
74
// Check if token needs refresh
74
75
if ($session->expiresIn() < $this->refreshThreshold) {
···
79
80
}
80
81
81
82
/**
82
-
* Create session from app password
83
+
* Create session from app password.
83
84
*/
84
85
public function fromAppPassword(
85
-
string $handleOrDid,
86
+
string $actor,
86
87
string $password
87
88
): Session {
88
-
$did = $this->resolveToDid($handleOrDid);
89
+
$did = $this->resolveToDid($actor);
89
90
$pdsEndpoint = Resolver::resolvePds($did);
90
91
91
92
$response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [
92
-
'identifier' => $handleOrDid,
93
+
'identifier' => $actor,
93
94
'password' => $password,
94
95
]);
95
96
···
97
98
throw new AuthenticationException('Login failed');
98
99
}
99
100
100
-
$token = AccessToken::fromResponse($response->json(), $handleOrDid, $pdsEndpoint);
101
+
$token = AccessToken::fromResponse($response->json(), $actor, $pdsEndpoint);
101
102
102
103
// Store credentials using DID as key
103
104
$this->credentials->storeCredentials($did, $token);
···
138
139
$did = $session->did();
139
140
140
141
// Fire event before refresh (allows developers to invalidate old token)
141
-
event(new OAuthTokenRefreshing($session));
142
+
event(new TokenRefreshing($session));
142
143
143
144
$newToken = $this->refresher->refresh(
144
145
refreshToken: $session->refreshToken(),
145
146
pdsEndpoint: $session->pdsEndpoint(),
146
147
dpopKey: $session->dpopKey(),
147
148
handle: $session->handle(),
149
+
authType: $session->authType(),
148
150
);
149
151
150
152
// Update credentials (CRITICAL: refresh tokens are single-use)
151
153
$this->credentials->updateCredentials($did, $newToken);
152
154
153
155
// Fire event after successful refresh
154
-
event(new OAuthTokenRefreshed($session, $newToken));
156
+
event(new TokenRefreshed($session, $newToken));
155
157
156
158
// Update session
157
159
$newCreds = $this->credentials->getCredentials($did);