Laravel AT Protocol Client (alpha & unstable)

Merge branch 'refs/heads/dev'

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Contracts; 4 + 5 + interface HasAtpSession 6 + { 7 + /** 8 + * Get the ATP DID associated with this model. 9 + */ 10 + public function getAtpDid(): ?string; 11 + }
+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
··· 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
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + enum AuthType: string 6 + { 7 + case OAuth = 'oauth'; 8 + case Legacy = 'legacy'; 9 + }
+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
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + enum ScopeAuthorizationFailure: string 6 + { 7 + case Abort = 'abort'; 8 + case Redirect = 'redirect'; 9 + case Exception = 'exception'; 10 + }
+9
src/Enums/ScopeEnforcementLevel.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + enum ScopeEnforcementLevel: string 6 + { 7 + case Strict = 'strict'; 8 + case Permissive = 'permissive'; 9 + }
+1 -1
src/Events/OAuthTokenRefreshed.php src/Events/TokenRefreshed.php
··· 7 7 use SocialDept\AtpClient\Data\AccessToken; 8 8 use SocialDept\AtpClient\Session\Session; 9 9 10 - class OAuthTokenRefreshed 10 + class TokenRefreshed 11 11 { 12 12 use Dispatchable, SerializesModels; 13 13
+1 -1
src/Events/OAuthTokenRefreshing.php src/Events/TokenRefreshing.php
··· 6 6 use Illuminate\Queue\SerializesModels; 7 7 use SocialDept\AtpClient\Session\Session; 8 8 9 - class OAuthTokenRefreshing 9 + class TokenRefreshing 10 10 { 11 11 use Dispatchable, SerializesModels; 12 12
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 30 30 handle: $token->handle, 31 31 issuer: $token->issuer, 32 32 scope: $token->scope, 33 + authType: $token->authType, 33 34 ); 34 35 } 35 36
+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
··· 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
··· 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
··· 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
··· 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);