Laravel AT Protocol Client (alpha & unstable)

Compare changes

Choose any two refs to compare.

Changed files
+7990 -855
config
docs
src
Attributes
Auth
Builders
Client
Concerns
Console
Contracts
Data
Responses
Enums
Events
Exceptions
Facades
Http
Providers
RichText
Session
Storage
+450 -30
README.md
··· 166 166 use SocialDept\AtpClient\Events\TokenRefreshed; 167 167 168 168 Event::listen(TokenRefreshed::class, function ($event) { 169 - // $event->identifier - the user identifier 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 ··· 478 483 479 484 ## Credential Storage 480 485 481 - The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end). 486 + The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end). For production applications, you need to implement persistent storage. 487 + 488 + ### Why You Need a Credential Provider 489 + 490 + AT Protocol OAuth uses **single-use refresh tokens**. When a token is refreshed: 491 + 1. The old refresh token is immediately invalidated 492 + 2. A new refresh token is issued 493 + 3. You must store the new token before using it again 494 + 495 + If you lose the refresh token, the user must re-authenticate. The `CredentialProvider` ensures tokens are safely persisted. 496 + 497 + ### How Handle Resolution Works 498 + 499 + When you call `Atp::as('user.bsky.social')` or `Atp::login('user.bsky.social', $password)`, the package automatically resolves the handle to a DID (Decentralized Identifier). The DID is then used as the storage key for credentials. This ensures consistency even if a user changes their handle. 500 + 501 + If resolution fails (invalid handle, network error, etc.), a `HandleResolutionException` is thrown. 502 + 503 + ### The CredentialProvider Interface 504 + 505 + ```php 506 + interface CredentialProvider 507 + { 508 + // Get stored credentials by DID 509 + public function getCredentials(string $did): ?Credentials; 510 + 511 + // Store credentials after initial OAuth or app password login 512 + public function storeCredentials(string $did, AccessToken $token): void; 513 + 514 + // Update credentials after token refresh (CRITICAL: refresh tokens are single-use!) 515 + public function updateCredentials(string $did, AccessToken $token): void; 516 + 517 + // Remove credentials (logout) 518 + public function removeCredentials(string $did): void; 519 + } 520 + ``` 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 + 553 + ### Database Migration 554 + 555 + Create a migration for storing credentials: 482 556 483 - ### Implementing Custom Storage 557 + ```bash 558 + php artisan make:migration create_atp_credentials_table 559 + ``` 484 560 485 561 ```php 562 + Schema::create('atp_credentials', function (Blueprint $table) { 563 + $table->id(); 564 + $table->string('did')->unique(); // Decentralized identifier (primary key) 565 + $table->string('handle')->nullable(); // User's handle (e.g., user.bsky.social) 566 + $table->string('issuer')->nullable(); // PDS endpoint URL 567 + $table->text('access_token'); // JWT access token 568 + $table->text('refresh_token'); // Single-use refresh token 569 + $table->timestamp('expires_at'); // Token expiration time 570 + $table->json('scope')->nullable(); // Granted OAuth scopes 571 + $table->string('auth_type')->default('oauth'); // 'oauth' or 'legacy' 572 + $table->timestamps(); 573 + }); 574 + ``` 575 + 576 + ### Implementing a Database Provider 577 + 578 + ```php 579 + <?php 580 + 581 + namespace App\Providers; 582 + 583 + use App\Models\AtpCredential; 486 584 use SocialDept\AtpClient\Contracts\CredentialProvider; 487 585 use SocialDept\AtpClient\Data\AccessToken; 488 586 use SocialDept\AtpClient\Data\Credentials; 587 + use SocialDept\AtpClient\Enums\AuthType; 489 588 490 589 class DatabaseCredentialProvider implements CredentialProvider 491 590 { 492 - public function getCredentials(string $identifier): ?Credentials 591 + public function getCredentials(string $did): ?Credentials 493 592 { 494 - $record = AtpCredential::where('identifier', $identifier)->first(); 593 + $record = AtpCredential::where('did', $did)->first(); 495 594 496 - if (!$record) { 595 + if (! $record) { 497 596 return null; 498 597 } 499 598 500 599 return new Credentials( 501 - identifier: $record->identifier, 502 600 did: $record->did, 503 601 accessToken: $record->access_token, 504 602 refreshToken: $record->refresh_token, 505 603 expiresAt: $record->expires_at, 604 + handle: $record->handle, 605 + issuer: $record->issuer, 606 + scope: $record->scope ?? [], 607 + authType: AuthType::from($record->auth_type), 506 608 ); 507 609 } 508 610 509 - public function storeCredentials(string $identifier, AccessToken $token): void 611 + public function storeCredentials(string $did, AccessToken $token): void 510 612 { 511 - AtpCredential::create([ 512 - 'identifier' => $identifier, 513 - 'did' => $token->did, 514 - 'access_token' => $token->accessJwt, 515 - 'refresh_token' => $token->refreshJwt, 516 - 'expires_at' => $token->expiresAt, 517 - ]); 613 + AtpCredential::updateOrCreate( 614 + ['did' => $did], 615 + [ 616 + 'handle' => $token->handle, 617 + 'issuer' => $token->issuer, 618 + 'access_token' => $token->accessJwt, 619 + 'refresh_token' => $token->refreshJwt, 620 + 'expires_at' => $token->expiresAt, 621 + 'scope' => $token->scope, 622 + 'auth_type' => $token->authType->value, 623 + ] 624 + ); 518 625 } 519 626 520 - public function updateCredentials(string $identifier, AccessToken $token): void 627 + public function updateCredentials(string $did, AccessToken $token): void 521 628 { 522 - AtpCredential::where('identifier', $identifier)->update([ 629 + AtpCredential::where('did', $did)->update([ 523 630 'access_token' => $token->accessJwt, 524 631 'refresh_token' => $token->refreshJwt, 525 632 'expires_at' => $token->expiresAt, 633 + 'handle' => $token->handle, 634 + 'issuer' => $token->issuer, 635 + 'scope' => $token->scope, 636 + 'auth_type' => $token->authType->value, 526 637 ]); 527 638 } 528 639 529 - public function removeCredentials(string $identifier): void 640 + public function removeCredentials(string $did): void 530 641 { 531 - AtpCredential::where('identifier', $identifier)->delete(); 642 + AtpCredential::where('did', $did)->delete(); 532 643 } 533 644 } 534 645 ``` 535 646 536 - Register your provider in the config: 647 + ### The AtpCredential Model 537 648 538 649 ```php 650 + <?php 651 + 652 + namespace App\Models; 653 + 654 + use Illuminate\Database\Eloquent\Model; 655 + 656 + class AtpCredential extends Model 657 + { 658 + protected $fillable = [ 659 + 'did', 660 + 'handle', 661 + 'issuer', 662 + 'access_token', 663 + 'refresh_token', 664 + 'expires_at', 665 + 'scope', 666 + 'auth_type', 667 + ]; 668 + 669 + protected $casts = [ 670 + 'expires_at' => 'datetime', 671 + 'scope' => 'array', 672 + ]; 673 + 674 + protected $hidden = [ 675 + 'access_token', 676 + 'refresh_token', 677 + ]; 678 + } 679 + ``` 680 + 681 + ### Register Your Provider 682 + 683 + Update your config file: 684 + 685 + ```php 686 + // config/client.php 687 + 539 688 'credential_provider' => App\Providers\DatabaseCredentialProvider::class, 540 689 ``` 541 690 691 + Or bind it in a service provider: 692 + 693 + ```php 694 + // app/Providers/AppServiceProvider.php 695 + 696 + use SocialDept\AtpClient\Contracts\CredentialProvider; 697 + use App\Providers\DatabaseCredentialProvider; 698 + 699 + public function register(): void 700 + { 701 + $this->app->singleton(CredentialProvider::class, DatabaseCredentialProvider::class); 702 + } 703 + ``` 704 + 705 + ### Linking to Your User Model 706 + 707 + If you want to associate ATP credentials with your application's users: 708 + 709 + ```php 710 + // Migration 711 + Schema::table('atp_credentials', function (Blueprint $table) { 712 + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); 713 + }); 714 + 715 + // AtpCredential model 716 + public function user() 717 + { 718 + return $this->belongsTo(User::class); 719 + } 720 + 721 + // User model 722 + public function atpCredential() 723 + { 724 + return $this->hasOne(AtpCredential::class); 725 + } 726 + ``` 727 + 728 + Then update your provider to work with the authenticated user: 729 + 730 + ```php 731 + public function storeCredentials(string $did, AccessToken $token): void 732 + { 733 + AtpCredential::updateOrCreate( 734 + ['did' => $did], 735 + [ 736 + 'user_id' => auth()->id(), // Link to current user 737 + 'handle' => $token->handle, 738 + 'issuer' => $token->issuer, 739 + 'access_token' => $token->accessJwt, 740 + 'refresh_token' => $token->refreshJwt, 741 + 'expires_at' => $token->expiresAt, 742 + ] 743 + ); 744 + } 745 + ``` 746 + 747 + ### Understanding the Credential Fields 748 + 749 + | Field | Description | 750 + |-------|-------------| 751 + | `did` | Decentralized Identifier - the stable, permanent user ID (e.g., `did:plc:abc123...`) | 752 + | `handle` | User's handle (e.g., `user.bsky.social`) - can change | 753 + | `issuer` | The user's PDS endpoint URL (avoids repeated lookups) | 754 + | `accessToken` | JWT for API authentication (short-lived) | 755 + | `refreshToken` | Token to get new access tokens (single-use!) | 756 + | `expiresAt` | When the access token expires | 757 + | `scope` | Array of granted scopes (e.g., `['atproto', 'transition:generic']`) | 758 + | `authType` | Authentication method: `AuthType::OAuth` or `AuthType::Legacy` | 759 + 760 + ### Handling Token Refresh Events 761 + 762 + When tokens are automatically refreshed, you can listen for events: 763 + 764 + ```php 765 + use SocialDept\AtpClient\Events\TokenRefreshed; 766 + 767 + // In EventServiceProvider or via Event::listen() 768 + Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) { 769 + // The CredentialProvider.updateCredentials() is already called, 770 + // but you can do additional logging or notifications here 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 + } 777 + }); 778 + ``` 779 + 542 780 ## Events 543 781 544 782 The package dispatches events you can listen to: 545 783 784 + ### OAuthUserAuthenticated 785 + 786 + Fired after a successful OAuth callback. Use this to create or update users in your application: 787 + 788 + ```php 789 + use SocialDept\AtpClient\Events\OAuthUserAuthenticated; 790 + use SocialDept\AtpClient\Facades\Atp; 791 + 792 + Event::listen(OAuthUserAuthenticated::class, function (OAuthUserAuthenticated $event) { 793 + // $event->token contains: did, accessJwt, refreshJwt, handle, issuer, expiresAt, scope 794 + 795 + // Check granted scopes 796 + if (in_array('atproto', $event->token->scope)) { 797 + // User granted AT Protocol access 798 + } 799 + 800 + // Fetch the user's profile 801 + $client = Atp::as($event->token->did); 802 + $profile = $client->bsky->actor->getProfile($event->token->did); 803 + 804 + // Create or update user in your database 805 + $user = User::updateOrCreate( 806 + ['did' => $event->token->did], 807 + [ 808 + 'handle' => $event->token->handle, 809 + 'name' => $profile->json('displayName'), 810 + 'avatar' => $profile->json('avatar'), 811 + ] 812 + ); 813 + 814 + // Log them in 815 + Auth::login($user); 816 + }); 817 + ``` 818 + 819 + ### TokenRefreshing / TokenRefreshed 820 + 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): 822 + 546 823 ```php 547 824 use SocialDept\AtpClient\Events\TokenRefreshing; 548 825 use SocialDept\AtpClient\Events\TokenRefreshed; 549 826 550 - // Before token refresh 551 - Event::listen(TokenRefreshing::class, function ($event) { 552 - Log::info('Refreshing token for: ' . $event->identifier); 827 + // Before token refresh - invalidate old refresh token 828 + Event::listen(TokenRefreshing::class, function (TokenRefreshing $event) { 829 + // $event->session gives access to did(), handle(), authType(), isLegacy(), etc. 830 + Log::info('Refreshing token for: ' . $event->session->did()); 553 831 }); 554 832 555 - // After token refresh 556 - Event::listen(TokenRefreshed::class, function ($event) { 557 - // Update your stored credentials 558 - $this->credentialProvider->updateCredentials( 559 - $event->identifier, 560 - $event->token 561 - ); 833 + // After token refresh - new tokens available 834 + Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) { 835 + // $event->session - the session being refreshed 836 + // $event->token - the new AccessToken with fresh tokens 837 + // CredentialProvider.updateCredentials() is already called automatically 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 + } 562 844 }); 563 845 ``` 564 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 947 + ``` 948 + 949 + ## Extending the Client 950 + 951 + Add custom functionality to AtpClient by registering your own domain clients or request clients. Extensions are lazily instantiated on first access. 952 + 953 + ### Register Extensions 954 + 955 + Register extensions in your service provider's `boot()` method: 956 + 957 + ```php 958 + use SocialDept\AtpClient\AtpClient; 959 + 960 + // Add a new domain client: $client->analytics 961 + AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp)); 962 + 963 + // Add to an existing domain: $client->bsky->metrics 964 + AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new MetricsClient($bsky)); 965 + ``` 966 + 967 + ### Usage 968 + 969 + ```php 970 + $client = Atp::as('user.bsky.social'); 971 + 972 + $client->analytics->trackEvent('post_created'); 973 + $client->bsky->metrics->getEngagement(); 974 + ``` 975 + 976 + For complete documentation including creating custom clients, testing, and advanced patterns, see [docs/extensions.md](docs/extensions.md). 977 + 565 978 ## Available Commands 566 979 567 980 ```bash 568 981 # Generate OAuth private key 569 982 php artisan atp-client:generate-key 983 + 984 + # Create a domain client extension 985 + php artisan make:atp-client AnalyticsClient 986 + 987 + # Create a request client extension for an existing domain 988 + php artisan make:atp-request MetricsClient --domain=bsky 570 989 ``` 571 990 572 991 ## Requirements ··· 587 1006 - [AT Protocol Documentation](https://atproto.com/) 588 1007 - [Bluesky API Docs](https://docs.bsky.app/) 589 1008 - [CRYPTO.md](CRYPTO.md) - Cryptographic implementation details 1009 + - [docs/extensions.md](docs/extensions.md) - Client extensions guide 590 1010 591 1011 ## Support & Contributing 592 1012
+3 -5
composer.json
··· 45 45 } 46 46 }, 47 47 "scripts": { 48 - "test": "vendor/bin/pest", 49 - "test-coverage": "vendor/bin/pest --coverage", 48 + "test": "vendor/bin/phpunit", 49 + "test-coverage": "vendor/bin/phpunit --coverage-html coverage", 50 50 "format": "vendor/bin/php-cs-fixer fix" 51 51 }, 52 52 "extra": { ··· 62 62 "minimum-stability": "dev", 63 63 "prefer-stable": true, 64 64 "config": { 65 - "allow-plugins": { 66 - "pestphp/pest-plugin": false 67 - } 65 + "sort-packages": true 68 66 } 69 67 }
+102 -7
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 |-------------------------------------------------------------------------- 6 - | Client Metadata 9 + | Client Configuration 7 10 |-------------------------------------------------------------------------- 8 11 | 9 - | OAuth client configuration. The metadata URL must be publicly accessible 10 - | and serve the client-metadata.json file. 12 + | OAuth client configuration. The client_id is a URL that serves as the 13 + | unique identifier for your OAuth client. In production, this must be 14 + | an HTTPS URL pointing to your publicly accessible client metadata. 15 + | 16 + | For local development, use 'http://localhost' (no port) as the client_id. 17 + | The redirect_uri for localhost must use 127.0.0.1 with a port. 18 + | 19 + | @see https://atproto.com/specs/oauth#clients 11 20 | 12 21 */ 13 22 'client' => [ 14 23 'name' => env('ATP_CLIENT_NAME', config('app.name')), 15 24 'url' => env('ATP_CLIENT_URL', config('app.url')), 16 - 'metadata_url' => env('ATP_CLIENT_METADATA_URL'), 17 - 'redirect_uris' => [ 18 - env('ATP_CLIENT_REDIRECT_URI', config('app.url').'/auth/atp/callback'), 19 - ], 25 + 26 + // The client_id is the URL to your client metadata document. 27 + // For production: 'https://example.com/oauth/client-metadata.json' 28 + // For localhost: 'http://localhost' (exactly, no port) 29 + 'client_id' => env('ATP_CLIENT_ID'), 30 + 31 + // Redirect URIs for OAuth callback. 32 + // For localhost development, use 'http://127.0.0.1:<port>/callback' 33 + 'redirect_uris' => array_filter([ 34 + env('ATP_CLIENT_REDIRECT_URI'), 35 + ]), 36 + 20 37 'scopes' => ['atproto', 'transition:generic'], 21 38 ], 22 39 ··· 96 113 'times' => env('ATP_HTTP_RETRY_TIMES', 3), 97 114 'sleep' => env('ATP_HTTP_RETRY_SLEEP', 100), 98 115 ], 116 + ], 117 + 118 + /* 119 + |-------------------------------------------------------------------------- 120 + | Schema Validation 121 + |-------------------------------------------------------------------------- 122 + | 123 + | Enable or disable response validation against AT Protocol lexicon schemas. 124 + | When enabled, responses are validated and ValidationException is thrown 125 + | if the response doesn't match the expected schema. 126 + | 127 + */ 128 + 'schema_validation' => env('ATP_SCHEMA_VALIDATION', false), 129 + 130 + /* 131 + |-------------------------------------------------------------------------- 132 + | Public API Configuration 133 + |-------------------------------------------------------------------------- 134 + | 135 + | Configuration for unauthenticated public API access. The public API 136 + | allows reading public data without authentication. 137 + | 138 + */ 139 + 'public' => [ 140 + 'service_url' => env('ATP_PUBLIC_SERVICE_URL', 'https://public.api.bsky.app'), 141 + ], 142 + 143 + /* 144 + |-------------------------------------------------------------------------- 145 + | Scope Enforcement 146 + |-------------------------------------------------------------------------- 147 + | 148 + | Configure how scope requirements are enforced. Options: 149 + | - 'strict': Throws MissingScopeException if required scopes are missing 150 + | - 'permissive': Logs a warning but attempts the request anyway 151 + | 152 + */ 153 + 'scope_enforcement' => ScopeEnforcementLevel::tryFrom( 154 + env('ATP_SCOPE_ENFORCEMENT', 'permissive') 155 + ) ?? ScopeEnforcementLevel::Permissive, 156 + 157 + /* 158 + |-------------------------------------------------------------------------- 159 + | Scope Authorization 160 + |-------------------------------------------------------------------------- 161 + | 162 + | Configure behavior for the AtpScope facade and atp.scope middleware. 163 + | 164 + | failure_action: What happens when a scope check fails 165 + | - 'abort': Return a 403 HTTP response 166 + | - 'redirect': Redirect to the configured URL 167 + | - 'exception': Throw ScopeAuthorizationException 168 + | 169 + | redirect_to: URL to redirect to when failure_action is 'redirect' 170 + | 171 + */ 172 + 'scope_authorization' => [ 173 + 'failure_action' => ScopeAuthorizationFailure::tryFrom( 174 + env('ATP_SCOPE_FAILURE_ACTION', 'abort') 175 + ) ?? ScopeAuthorizationFailure::Abort, 176 + 177 + 'redirect_to' => env('ATP_SCOPE_REDIRECT', '/login'), 178 + ], 179 + 180 + /* 181 + |-------------------------------------------------------------------------- 182 + | Generator Settings 183 + |-------------------------------------------------------------------------- 184 + | 185 + | Configure paths for the make:atp-client and make:atp-request commands. 186 + | Paths are relative to the application base path. 187 + | 188 + */ 189 + 'generators' => [ 190 + 'client_path' => 'app/Services/Clients', 191 + 'client_public_path' => 'app/Services/Clients/Public', 192 + 'request_path' => 'app/Services/Clients/Requests', 193 + 'request_public_path' => 'app/Services/Clients/Public/Requests', 99 194 ], 100 195 ];
+467
docs/extensions.md
··· 1 + # Client Extensions 2 + 3 + AtpClient provides an extension system that allows you to add custom functionality. You can register domain clients (like `$client->myDomain`) or request clients on existing domains (like `$client->bsky->myFeature`). 4 + 5 + ## Quick Reference 6 + 7 + ### Available Methods 8 + 9 + | Method | Description | 10 + |--------|-------------| 11 + | `AtpClient::extend($name, $callback)` | Register a domain client extension | 12 + | `AtpClient::extendDomain($domain, $name, $callback)` | Register a request client on an existing domain | 13 + | `AtpClient::hasExtension($name)` | Check if a domain extension is registered | 14 + | `AtpClient::hasDomainExtension($domain, $name)` | Check if a request client extension is registered | 15 + | `AtpClient::flushExtensions()` | Clear all extensions (useful for testing) | 16 + 17 + ### Extension Types 18 + 19 + | Type | Access Pattern | Use Case | 20 + |------|----------------|----------| 21 + | Domain Client | `$client->myDomain` | Group related functionality under a namespace | 22 + | Request Client | `$client->bsky->myFeature` | Add methods to an existing domain | 23 + 24 + ### Generator Commands 25 + 26 + Quickly scaffold extension classes using artisan commands: 27 + 28 + ```bash 29 + # Create a domain client extension 30 + php artisan make:atp-client AnalyticsClient 31 + 32 + # Create a request client extension for an existing domain 33 + php artisan make:atp-request MetricsClient --domain=bsky 34 + ``` 35 + 36 + The generated files are placed in configurable directories. You can customize these paths in `config/client.php`: 37 + 38 + ```php 39 + 'generators' => [ 40 + 'client_path' => 'app/Services/Clients', 41 + 'request_path' => 'app/Services/Clients/Requests', 42 + ], 43 + ``` 44 + 45 + ## Understanding Extensions 46 + 47 + Extensions follow a lazy-loading pattern. When you register an extension, the callback is stored but not executed. The extension is only instantiated when first accessed: 48 + 49 + ```php 50 + // Registration - callback stored, not executed 51 + AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp)); 52 + 53 + // First access - callback executed, instance cached 54 + $client->analytics->trackEvent('login'); 55 + 56 + // Subsequent access - cached instance returned 57 + $client->analytics->trackEvent('post_created'); 58 + ``` 59 + 60 + This ensures extensions don't add overhead unless they're actually used. 61 + 62 + ## Creating a Domain Client 63 + 64 + A domain client adds a new namespace to AtpClient, accessible as a property. 65 + 66 + ### Step 1: Create Your Client Class 67 + 68 + ```php 69 + <?php 70 + 71 + namespace App\Atp; 72 + 73 + use SocialDept\AtpClient\AtpClient; 74 + 75 + class AnalyticsClient 76 + { 77 + protected AtpClient $atp; 78 + 79 + public function __construct(AtpClient $parent) 80 + { 81 + $this->atp = $parent; 82 + } 83 + 84 + public function trackEvent(string $event, array $properties = []): void 85 + { 86 + // Your analytics logic here 87 + // You have full access to the authenticated client via $this->atp 88 + } 89 + 90 + public function getEngagementStats(string $actor): array 91 + { 92 + $profile = $this->atp->bsky->actor->getProfile($actor); 93 + 94 + return [ 95 + 'followers' => $profile->followersCount, 96 + 'following' => $profile->followsCount, 97 + 'posts' => $profile->postsCount, 98 + ]; 99 + } 100 + } 101 + ``` 102 + 103 + ### Step 2: Register the Extension 104 + 105 + In your `AppServiceProvider`: 106 + 107 + ```php 108 + <?php 109 + 110 + namespace App\Providers; 111 + 112 + use App\Atp\AnalyticsClient; 113 + use Illuminate\Support\ServiceProvider; 114 + use SocialDept\AtpClient\AtpClient; 115 + 116 + class AppServiceProvider extends ServiceProvider 117 + { 118 + public function boot(): void 119 + { 120 + AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp)); 121 + } 122 + } 123 + ``` 124 + 125 + ### Step 3: Use Your Extension 126 + 127 + ```php 128 + use SocialDept\AtpClient\Facades\Atp; 129 + 130 + $client = Atp::as('user.bsky.social'); 131 + 132 + $client->analytics->trackEvent('page_view', ['page' => '/feed']); 133 + 134 + $stats = $client->analytics->getEngagementStats('someone.bsky.social'); 135 + ``` 136 + 137 + ## Creating a Request Client 138 + 139 + A request client extends an existing domain (like `bsky`, `atproto`, `chat`, or `ozone`). This is useful when you want to add methods that logically belong alongside the built-in functionality. 140 + 141 + ### Step 1: Create Your Request Client Class 142 + 143 + Extend the base `Request` class to get access to the parent AtpClient: 144 + 145 + ```php 146 + <?php 147 + 148 + namespace App\Atp; 149 + 150 + use SocialDept\AtpClient\Client\Requests\Request; 151 + 152 + class BskyMetricsClient extends Request 153 + { 154 + public function getPostEngagement(string $uri): array 155 + { 156 + $thread = $this->atp->bsky->feed->getPostThread($uri); 157 + $post = $thread->thread['post'] ?? null; 158 + 159 + if (! $post) { 160 + return []; 161 + } 162 + 163 + return [ 164 + 'likes' => $post['likeCount'] ?? 0, 165 + 'reposts' => $post['repostCount'] ?? 0, 166 + 'replies' => $post['replyCount'] ?? 0, 167 + 'quotes' => $post['quoteCount'] ?? 0, 168 + ]; 169 + } 170 + 171 + public function getAuthorMetrics(string $actor): array 172 + { 173 + $feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 100); 174 + $posts = $feed->feed; 175 + 176 + $totalLikes = 0; 177 + $totalReposts = 0; 178 + 179 + foreach ($posts as $item) { 180 + $totalLikes += $item['post']['likeCount'] ?? 0; 181 + $totalReposts += $item['post']['repostCount'] ?? 0; 182 + } 183 + 184 + return [ 185 + 'posts_analyzed' => count($posts), 186 + 'total_likes' => $totalLikes, 187 + 'total_reposts' => $totalReposts, 188 + 'avg_likes' => count($posts) > 0 ? $totalLikes / count($posts) : 0, 189 + ]; 190 + } 191 + } 192 + ``` 193 + 194 + ### Step 2: Register the Extension 195 + 196 + ```php 197 + use App\Atp\BskyMetricsClient; 198 + use SocialDept\AtpClient\AtpClient; 199 + 200 + public function boot(): void 201 + { 202 + AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky)); 203 + } 204 + ``` 205 + 206 + The callback receives the domain client instance (`BskyClient` in this case), which is passed to your request client's constructor. 207 + 208 + ### Step 3: Use Your Extension 209 + 210 + ```php 211 + $client = Atp::as('user.bsky.social'); 212 + 213 + $engagement = $client->bsky->metrics->getPostEngagement('at://did:plc:.../app.bsky.feed.post/...'); 214 + 215 + $authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social'); 216 + ``` 217 + 218 + ## Public vs Authenticated Mode 219 + 220 + The `AtpClient` class works in both public and authenticated modes. Both `Atp::public()` and `Atp::as()` return the same `AtpClient` class: 221 + 222 + ```php 223 + // Public mode - no authentication 224 + $publicClient = Atp::public('https://public.api.bsky.app'); 225 + $publicClient->bsky->actor->getProfile('someone.bsky.social'); 226 + 227 + // Authenticated mode - with session 228 + $authClient = Atp::as('did:plc:xxx'); 229 + $authClient->bsky->actor->getProfile('someone.bsky.social'); 230 + ``` 231 + 232 + Extensions registered on `AtpClient` work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present. 233 + 234 + ## Registering Multiple Extensions 235 + 236 + You can register multiple extensions in your service provider: 237 + 238 + ```php 239 + public function boot(): void 240 + { 241 + // Domain clients 242 + AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp)); 243 + AtpClient::extend('moderation', fn($atp) => new ModerationClient($atp)); 244 + 245 + // Request clients 246 + AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky)); 247 + AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky)); 248 + AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto)); 249 + } 250 + ``` 251 + 252 + ## Conditional Registration 253 + 254 + Register extensions conditionally based on environment or configuration: 255 + 256 + ```php 257 + public function boot(): void 258 + { 259 + if (config('services.analytics.enabled')) { 260 + AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp)); 261 + } 262 + 263 + if (app()->environment('local')) { 264 + AtpClient::extend('debug', fn($atp) => new DebugClient($atp)); 265 + } 266 + } 267 + ``` 268 + 269 + ## Testing Extensions 270 + 271 + ### Test Isolation 272 + 273 + Use `flushExtensions()` to clear registered extensions between tests: 274 + 275 + ```php 276 + use SocialDept\AtpClient\AtpClient; 277 + use PHPUnit\Framework\TestCase; 278 + 279 + class MyExtensionTest extends TestCase 280 + { 281 + protected function setUp(): void 282 + { 283 + parent::setUp(); 284 + AtpClient::flushExtensions(); 285 + } 286 + 287 + protected function tearDown(): void 288 + { 289 + AtpClient::flushExtensions(); 290 + parent::tearDown(); 291 + } 292 + 293 + public function test_extension_is_registered(): void 294 + { 295 + AtpClient::extend('test', fn($atp) => new TestClient($atp)); 296 + 297 + $this->assertTrue(AtpClient::hasExtension('test')); 298 + } 299 + } 300 + ``` 301 + 302 + ### Checking Registration 303 + 304 + Use the static methods to verify extensions are registered: 305 + 306 + ```php 307 + // Check domain extension 308 + if (AtpClient::hasExtension('analytics')) { 309 + $client->analytics->trackEvent('test'); 310 + } 311 + 312 + // Check request client extension 313 + if (AtpClient::hasDomainExtension('bsky', 'metrics')) { 314 + $metrics = $client->bsky->metrics->getAuthorMetrics($actor); 315 + } 316 + ``` 317 + 318 + ## Advanced Patterns 319 + 320 + ### Accessing the HTTP Client 321 + 322 + Domain client extensions can access the underlying HTTP client for custom API calls: 323 + 324 + ```php 325 + class CustomApiClient 326 + { 327 + protected AtpClient $atp; 328 + 329 + public function __construct(AtpClient $parent) 330 + { 331 + $this->atp = $parent; 332 + } 333 + 334 + public function customEndpoint(array $params): array 335 + { 336 + // Access the authenticated HTTP client 337 + $response = $this->atp->client->get('com.example.customEndpoint', $params); 338 + 339 + return $response->json(); 340 + } 341 + 342 + public function customProcedure(array $data): array 343 + { 344 + $response = $this->atp->client->post('com.example.customProcedure', $data); 345 + 346 + return $response->json(); 347 + } 348 + } 349 + ``` 350 + 351 + ### Using Typed Responses 352 + 353 + Return typed response objects for better IDE support: 354 + 355 + ```php 356 + use SocialDept\AtpClient\Data\Responses\Response; 357 + 358 + class MetricsResponse extends Response 359 + { 360 + public function __construct( 361 + public readonly int $likes, 362 + public readonly int $reposts, 363 + public readonly int $replies, 364 + ) {} 365 + 366 + public static function fromArray(array $data): static 367 + { 368 + return new static( 369 + likes: $data['likes'] ?? 0, 370 + reposts: $data['reposts'] ?? 0, 371 + replies: $data['replies'] ?? 0, 372 + ); 373 + } 374 + } 375 + 376 + class BskyMetricsClient extends Request 377 + { 378 + public function getPostMetrics(string $uri): MetricsResponse 379 + { 380 + $thread = $this->atp->bsky->feed->getPostThread($uri); 381 + $post = $thread->thread['post'] ?? []; 382 + 383 + return MetricsResponse::fromArray([ 384 + 'likes' => $post['likeCount'] ?? 0, 385 + 'reposts' => $post['repostCount'] ?? 0, 386 + 'replies' => $post['replyCount'] ?? 0, 387 + ]); 388 + } 389 + } 390 + ``` 391 + 392 + ### Composing Multiple Clients 393 + 394 + Extensions can use other extensions or built-in clients: 395 + 396 + ```php 397 + class DashboardClient 398 + { 399 + protected AtpClient $atp; 400 + 401 + public function __construct(AtpClient $parent) 402 + { 403 + $this->atp = $parent; 404 + } 405 + 406 + public function getOverview(string $actor): array 407 + { 408 + // Use built-in clients 409 + $profile = $this->atp->bsky->actor->getProfile($actor); 410 + $feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 10); 411 + 412 + // Use other extensions (if registered) 413 + $metrics = AtpClient::hasDomainExtension('bsky', 'metrics') 414 + ? $this->atp->bsky->metrics->getAuthorMetrics($actor) 415 + : null; 416 + 417 + return [ 418 + 'profile' => $profile, 419 + 'recent_posts' => $feed->feed, 420 + 'metrics' => $metrics, 421 + ]; 422 + } 423 + } 424 + ``` 425 + 426 + ### Documenting Scope Requirements 427 + 428 + Use the `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes to document the authentication requirements of your extension methods: 429 + 430 + ```php 431 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 432 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 433 + use SocialDept\AtpClient\Client\Requests\Request; 434 + use SocialDept\AtpClient\Enums\Scope; 435 + 436 + class BskyMetricsClient extends Request 437 + { 438 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')] 439 + public function getTimelineMetrics(): array 440 + { 441 + $timeline = $this->atp->bsky->feed->getTimeline(); 442 + // Process and return metrics... 443 + } 444 + 445 + #[PublicEndpoint] 446 + public function getPublicPostMetrics(string $uri): array 447 + { 448 + $thread = $this->atp->bsky->feed->getPostThread($uri); 449 + // Process and return metrics... 450 + } 451 + } 452 + ``` 453 + 454 + > **Note:** These attributes currently serve as documentation only. Runtime scope enforcement will be implemented in a future release. Using them correctly now ensures forward compatibility. 455 + 456 + Methods with `#[ScopedEndpoint]` indicate they require authentication, while methods with `#[PublicEndpoint]` work without authentication. See [scopes.md](scopes.md) for full documentation on scope handling. 457 + 458 + ## Available Domains 459 + 460 + You can extend these built-in domains with `extendDomain()`: 461 + 462 + | Domain | Description | 463 + |--------|-------------| 464 + | `bsky` | Bluesky-specific operations (app.bsky.*) | 465 + | `atproto` | AT Protocol core operations (com.atproto.*) | 466 + | `chat` | Direct messaging operations (chat.bsky.*) | 467 + | `ozone` | Moderation tools (tools.ozone.*) |
+408
docs/scopes.md
··· 1 + # OAuth Scopes 2 + 3 + The AT Protocol uses OAuth scopes to control what actions an application can perform on behalf of a user. AtpClient provides attributes for documenting scope requirements on endpoints. 4 + 5 + > **Note:** The `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes currently serve as documentation only. Runtime scope validation and enforcement will be implemented in a future release. Using these attributes correctly now ensures forward compatibility. 6 + 7 + ## Quick Reference 8 + 9 + ### Scope Enum 10 + 11 + ```php 12 + use SocialDept\AtpClient\Enums\Scope; 13 + 14 + // Transition scopes (current AT Protocol scopes) 15 + Scope::Atproto // 'atproto' - Full access 16 + Scope::TransitionGeneric // 'transition:generic' - General API access 17 + Scope::TransitionEmail // 'transition:email' - Email access 18 + Scope::TransitionChat // 'transition:chat.bsky' - Chat access 19 + 20 + // Granular scope builders (future AT Protocol scopes) 21 + Scope::repo('app.bsky.feed.post', ['create', 'delete']) // Record operations 22 + Scope::rpc('app.bsky.feed.getTimeline') // RPC endpoint access 23 + Scope::blob('image/*') // Blob upload access 24 + Scope::account('email') // Account attribute access 25 + Scope::identity('handle') // Identity attribute access 26 + ``` 27 + 28 + ### ScopedEndpoint Attribute 29 + 30 + ```php 31 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 32 + use SocialDept\AtpClient\Enums\Scope; 33 + 34 + #[ScopedEndpoint(Scope::TransitionGeneric)] 35 + public function getTimeline(): GetTimelineResponse 36 + { 37 + // Method implementation 38 + } 39 + ``` 40 + 41 + ## Understanding AT Protocol Scopes 42 + 43 + ### Current Transition Scopes 44 + 45 + The AT Protocol is currently in a transition period where broad "transition scopes" are used: 46 + 47 + | Scope | Description | 48 + |-------|-------------| 49 + | `atproto` | Full access to the AT Protocol | 50 + | `transition:generic` | General API access for most operations | 51 + | `transition:email` | Access to email-related operations | 52 + | `transition:chat.bsky` | Access to Bluesky chat features | 53 + 54 + ### Future Granular Scopes 55 + 56 + The AT Protocol is moving toward granular scopes that provide fine-grained access control: 57 + 58 + ```php 59 + // Record operations 60 + 'repo:app.bsky.feed.post' // All operations on posts 61 + 'repo:app.bsky.feed.post?action=create' // Only create posts 62 + 'repo:app.bsky.feed.like?action=create&action=delete' // Create or delete likes 63 + 'repo:*' // All collections, all actions 64 + 65 + // RPC endpoint access 66 + 'rpc:app.bsky.feed.getTimeline' // Access to timeline endpoint 67 + 'rpc:app.bsky.feed.*' // All feed endpoints 68 + 69 + // Blob operations 70 + 'blob:image/*' // Upload images 71 + 'blob:*/*' // Upload any blob type 72 + 73 + // Account and identity 74 + 'account:email' // Access email 75 + 'identity:handle' // Manage handle 76 + ``` 77 + 78 + ## The ScopedEndpoint Attribute 79 + 80 + The `#[ScopedEndpoint]` attribute documents scope requirements on methods that require authentication. 81 + 82 + ### Basic Usage 83 + 84 + ```php 85 + <?php 86 + 87 + namespace App\Atp; 88 + 89 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 90 + use SocialDept\AtpClient\Client\Requests\Request; 91 + use SocialDept\AtpClient\Enums\Scope; 92 + 93 + class CustomClient extends Request 94 + { 95 + #[ScopedEndpoint(Scope::TransitionGeneric)] 96 + public function getTimeline(): array 97 + { 98 + return $this->atp->client->get('app.bsky.feed.getTimeline')->json(); 99 + } 100 + } 101 + ``` 102 + 103 + ### With Granular Scope 104 + 105 + Document the future granular scope that will replace the transition scope: 106 + 107 + ```php 108 + #[ScopedEndpoint( 109 + Scope::TransitionGeneric, 110 + granular: 'rpc:app.bsky.feed.getTimeline' 111 + )] 112 + public function getTimeline(): GetTimelineResponse 113 + { 114 + // ... 115 + } 116 + ``` 117 + 118 + ### With Description 119 + 120 + Add a human-readable description for documentation: 121 + 122 + ```php 123 + #[ScopedEndpoint( 124 + Scope::TransitionGeneric, 125 + granular: 'rpc:app.bsky.feed.getTimeline', 126 + description: 'Access to the user\'s home timeline' 127 + )] 128 + public function getTimeline(): GetTimelineResponse 129 + { 130 + // ... 131 + } 132 + ``` 133 + 134 + ### Multiple Scopes (AND Logic) 135 + 136 + When a method requires multiple scopes, all must be present: 137 + 138 + ```php 139 + #[ScopedEndpoint([Scope::TransitionGeneric, Scope::TransitionEmail])] 140 + public function getEmailPreferences(): array 141 + { 142 + // Requires BOTH scopes 143 + } 144 + ``` 145 + 146 + ### Multiple Attributes (OR Logic) 147 + 148 + Use multiple attributes for alternative scope requirements: 149 + 150 + ```php 151 + #[ScopedEndpoint(Scope::Atproto)] 152 + #[ScopedEndpoint(Scope::TransitionGeneric)] 153 + public function getProfile(string $actor): ProfileViewDetailed 154 + { 155 + // Either scope satisfies the requirement 156 + } 157 + ``` 158 + 159 + ## Scope Enforcement (Planned) 160 + 161 + > **Coming Soon:** Runtime scope enforcement is not yet implemented. The following documentation describes planned functionality for a future release. 162 + 163 + ### Configuration 164 + 165 + Configure scope enforcement in `config/client.php` or via environment variables: 166 + 167 + ```php 168 + 'scope_enforcement' => ScopeEnforcementLevel::Permissive, 169 + ``` 170 + 171 + | Level | Behavior | 172 + |-------|----------| 173 + | `Strict` | Throws `MissingScopeException` if required scopes are missing | 174 + | `Permissive` | Logs a warning but attempts the request anyway | 175 + 176 + Set via environment variable: 177 + 178 + ```env 179 + ATP_SCOPE_ENFORCEMENT=strict 180 + ``` 181 + 182 + ### Programmatic Scope Checking 183 + 184 + Check scopes programmatically using the `ScopeChecker`: 185 + 186 + ```php 187 + use SocialDept\AtpClient\Auth\ScopeChecker; 188 + use SocialDept\AtpClient\Facades\Atp; 189 + 190 + $checker = app(ScopeChecker::class); 191 + $session = Atp::as($did)->client->session(); 192 + 193 + // Check if session has a scope 194 + if ($checker->hasScope($session, Scope::TransitionGeneric)) { 195 + // Session has the scope 196 + } 197 + 198 + // Check multiple scopes 199 + if ($checker->check($session, [Scope::TransitionGeneric, Scope::TransitionEmail])) { 200 + // Session has ALL required scopes 201 + } 202 + 203 + // Check and fail if missing (respects enforcement level) 204 + $checker->checkOrFail($session, [Scope::TransitionGeneric]); 205 + 206 + // Check repo scope for specific action 207 + if ($checker->checkRepoScope($session, 'app.bsky.feed.post', 'create')) { 208 + // Can create posts 209 + } 210 + ``` 211 + 212 + ### Granular Pattern Matching 213 + 214 + The scope checker supports wildcard patterns: 215 + 216 + ```php 217 + // Check if session can access any feed endpoint 218 + $checker->matchesGranular($session, 'rpc:app.bsky.feed.*'); 219 + 220 + // Check if session can upload images 221 + $checker->matchesGranular($session, 'blob:image/*'); 222 + 223 + // Check if session has any repo access 224 + $checker->matchesGranular($session, 'repo:*'); 225 + ``` 226 + 227 + ## Route Middleware (Planned) 228 + 229 + > **Coming Soon:** Route middleware is not yet implemented. The following documentation describes planned functionality for a future release. 230 + 231 + Protect Laravel routes based on ATP session scopes: 232 + 233 + ```php 234 + use Illuminate\Support\Facades\Route; 235 + 236 + // Single scope 237 + Route::get('/timeline', TimelineController::class) 238 + ->middleware('atp.scope:transition:generic'); 239 + 240 + // Multiple scopes (AND logic) 241 + Route::get('/email-settings', EmailSettingsController::class) 242 + ->middleware('atp.scope:transition:generic,transition:email'); 243 + ``` 244 + 245 + ### Middleware Configuration 246 + 247 + Configure middleware behavior in `config/client.php`: 248 + 249 + ```php 250 + 'scope_authorization' => [ 251 + // What to do when scope check fails 252 + 'failure_action' => ScopeAuthorizationFailure::Abort, // abort, redirect, or exception 253 + 254 + // Where to redirect (when failure_action is 'redirect') 255 + 'redirect_to' => '/login', 256 + ], 257 + ``` 258 + 259 + | Failure Action | Behavior | 260 + |----------------|----------| 261 + | `Abort` | Returns 403 Forbidden response | 262 + | `Redirect` | Redirects to configured URL | 263 + | `Exception` | Throws `ScopeAuthorizationException` | 264 + 265 + Set via environment variables: 266 + 267 + ```env 268 + ATP_SCOPE_FAILURE_ACTION=redirect 269 + ATP_SCOPE_REDIRECT=/auth/login 270 + ``` 271 + 272 + ### User Model Integration 273 + 274 + For the middleware to work, your User model must implement `HasAtpSession`: 275 + 276 + ```php 277 + <?php 278 + 279 + namespace App\Models; 280 + 281 + use Illuminate\Foundation\Auth\User as Authenticatable; 282 + use SocialDept\AtpClient\Contracts\HasAtpSession; 283 + 284 + class User extends Authenticatable implements HasAtpSession 285 + { 286 + public function getAtpDid(): ?string 287 + { 288 + return $this->atp_did; 289 + } 290 + } 291 + ``` 292 + 293 + ## Public Mode and Scopes 294 + 295 + Methods marked with `#[PublicEndpoint]` can be called without authentication using `Atp::public()`: 296 + 297 + ```php 298 + // Public mode - no authentication required 299 + $client = Atp::public('https://public.api.bsky.app'); 300 + $client->bsky->actor->getProfile('someone.bsky.social'); // Works without auth 301 + 302 + // Authenticated mode - for endpoints requiring scopes 303 + $client = Atp::as($did); 304 + $client->bsky->feed->getTimeline(); // Requires transition:generic scope 305 + ``` 306 + 307 + Methods with `#[PublicEndpoint]` work in both modes, while methods with `#[ScopedEndpoint]` require authentication. 308 + 309 + ## Exception Handling (Planned) 310 + 311 + > **Coming Soon:** These exceptions will be thrown when scope enforcement is implemented in a future release. 312 + 313 + ### MissingScopeException 314 + 315 + Will be thrown when required scopes are missing and enforcement is strict: 316 + 317 + ```php 318 + use SocialDept\AtpClient\Exceptions\MissingScopeException; 319 + 320 + try { 321 + $timeline = $client->bsky->feed->getTimeline(); 322 + } catch (MissingScopeException $e) { 323 + $missing = $e->getMissingScopes(); // Scopes that are missing 324 + $granted = $e->getGrantedScopes(); // Scopes the session has 325 + 326 + // Handle missing scope 327 + } 328 + ``` 329 + 330 + ### ScopeAuthorizationException 331 + 332 + Will be thrown by middleware when route access is denied: 333 + 334 + ```php 335 + use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException; 336 + 337 + try { 338 + // Route protected by atp.scope middleware 339 + } catch (ScopeAuthorizationException $e) { 340 + $required = $e->getRequiredScopes(); 341 + $granted = $e->getGrantedScopes(); 342 + $message = $e->getMessage(); 343 + } 344 + ``` 345 + 346 + ## Best Practices 347 + 348 + ### 1. Document All Scope Requirements 349 + 350 + Always add `#[ScopedEndpoint]` to methods that require authentication: 351 + 352 + ```php 353 + #[ScopedEndpoint( 354 + Scope::TransitionGeneric, 355 + granular: 'rpc:app.bsky.feed.getTimeline', 356 + description: 'Fetches the authenticated user\'s home timeline' 357 + )] 358 + public function getTimeline(): GetTimelineResponse 359 + ``` 360 + 361 + ### 2. Use the Scope Enum 362 + 363 + Prefer the `Scope` enum over string literals for type safety: 364 + 365 + ```php 366 + // Good 367 + #[ScopedEndpoint(Scope::TransitionGeneric)] 368 + 369 + // Avoid 370 + #[ScopedEndpoint('transition:generic')] 371 + ``` 372 + 373 + ### 3. Request Minimal Scopes 374 + 375 + When implementing OAuth, request only the scopes your application needs: 376 + 377 + ```php 378 + $authUrl = Atp::oauth()->getAuthorizationUrl([ 379 + 'scope' => 'atproto transition:generic', 380 + ]); 381 + ``` 382 + 383 + ### 4. Handle Missing Scopes Gracefully 384 + 385 + Check for scope availability before attempting operations: 386 + 387 + ```php 388 + $checker = app(ScopeChecker::class); 389 + $session = $client->client->session(); 390 + 391 + if ($checker->hasScope($session, Scope::TransitionChat)) { 392 + $conversations = $client->chat->getConversations(); 393 + } else { 394 + // Inform user they need to re-authorize with chat scope 395 + } 396 + ``` 397 + 398 + ### 5. Use Permissive Mode in Development 399 + 400 + Start with permissive enforcement during development, then switch to strict for production: 401 + 402 + ```env 403 + # .env.local 404 + ATP_SCOPE_ENFORCEMENT=permissive 405 + 406 + # .env.production 407 + ATP_SCOPE_ENFORCEMENT=strict 408 + ```
+16 -4
src/AtpClient.php
··· 7 7 use SocialDept\AtpClient\Client\ChatClient; 8 8 use SocialDept\AtpClient\Client\Client; 9 9 use SocialDept\AtpClient\Client\OzoneClient; 10 + use SocialDept\AtpClient\Concerns\HasExtensions; 10 11 use SocialDept\AtpClient\Session\SessionManager; 11 12 12 13 class AtpClient 13 14 { 15 + use HasExtensions; 16 + 14 17 /** 15 18 * Raw API communication/networking class 16 19 */ ··· 37 40 public OzoneClient $ozone; 38 41 39 42 public function __construct( 40 - SessionManager $sessions, 41 - string $identifier, 43 + ?SessionManager $sessions = null, 44 + ?string $did = null, 45 + ?string $serviceUrl = null, 42 46 ) { 43 - // Load the network client 44 - $this->client = new Client($this, $sessions, $identifier); 47 + // Load the network client (supports both public and authenticated modes) 48 + $this->client = new Client($this, $sessions, $did, $serviceUrl); 45 49 46 50 // Load all function collections 47 51 $this->bsky = new BskyClient($this); 48 52 $this->atproto = new AtprotoClient($this); 49 53 $this->chat = new ChatClient($this); 50 54 $this->ozone = new OzoneClient($this); 55 + } 56 + 57 + /** 58 + * Check if client is in public mode (no authentication). 59 + */ 60 + public function isPublicMode(): bool 61 + { 62 + return $this->client->isPublicMode(); 51 63 } 52 64 }
+54 -10
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; 8 + use SocialDept\AtpClient\Auth\ClientAssertionManager; 7 9 use SocialDept\AtpClient\Auth\ClientMetadataManager; 8 10 use SocialDept\AtpClient\Auth\DPoPKeyManager; 9 11 use SocialDept\AtpClient\Auth\DPoPNonceManager; 10 12 use SocialDept\AtpClient\Auth\OAuthEngine; 13 + use SocialDept\AtpClient\Auth\ScopeChecker; 14 + use SocialDept\AtpClient\Auth\ScopeGate; 11 15 use SocialDept\AtpClient\Auth\TokenRefresher; 16 + use SocialDept\AtpClient\Enums\ScopeEnforcementLevel; 17 + use SocialDept\AtpClient\Http\Middleware\RequiresScopeMiddleware; 12 18 use SocialDept\AtpClient\Console\GenerateOAuthKeyCommand; 19 + use SocialDept\AtpClient\Console\MakeAtpClientCommand; 20 + use SocialDept\AtpClient\Console\MakeAtpRequestCommand; 13 21 use SocialDept\AtpClient\Contracts\CredentialProvider; 14 22 use SocialDept\AtpClient\Contracts\KeyStore; 15 23 use SocialDept\AtpClient\Http\Controllers\ClientMetadataController; ··· 42 50 43 51 // Register core services 44 52 $this->app->singleton(ClientMetadataManager::class); 53 + $this->app->singleton(ClientAssertionManager::class); 45 54 $this->app->singleton(DPoPKeyManager::class); 46 55 $this->app->singleton(DPoPNonceManager::class); 47 56 $this->app->singleton(DPoPClient::class); ··· 56 65 ); 57 66 }); 58 67 $this->app->singleton(OAuthEngine::class); 68 + $this->app->singleton(ScopeChecker::class, function ($app) { 69 + return new ScopeChecker( 70 + config('atp-client.scope_enforcement', ScopeEnforcementLevel::Permissive) 71 + ); 72 + }); 73 + 74 + // Register ScopeGate for AtpScope facade 75 + $this->app->singleton('atp-scope', function ($app) { 76 + return new ScopeGate( 77 + $app->make(SessionManager::class), 78 + $app->make(ScopeChecker::class), 79 + ); 80 + }); 59 81 60 82 // Register main client facade accessor 61 83 $this->app->bind('atp-client', function ($app) { ··· 70 92 $this->app = $app; 71 93 } 72 94 73 - public function as(string $identifier): AtpClient 95 + public function as(string $actor): AtpClient 74 96 { 75 97 return new AtpClient( 76 98 $this->app->make(SessionManager::class), 77 - $identifier 99 + $actor 78 100 ); 79 101 } 80 102 81 - public function login(string $identifier, string $password): AtpClient 103 + public function login(string $actor, string $password): AtpClient 82 104 { 83 - $session = $this->app->make(SessionManager::class) 84 - ->fromAppPassword($identifier, $password); 105 + $this->app->make(SessionManager::class) 106 + ->fromAppPassword($actor, $password); 85 107 86 - return $this->as($identifier); 108 + return $this->as($actor); 87 109 } 88 110 89 111 public function oauth(): OAuthEngine ··· 96 118 $this->defaultProvider = $provider; 97 119 $this->app->instance(CredentialProvider::class, $provider); 98 120 } 121 + 122 + public function public(?string $service = null): AtpClient 123 + { 124 + return new AtpClient( 125 + sessions: null, 126 + did: null, 127 + serviceUrl: $service ?? config('atp-client.public.service_url', 'https://public.api.bsky.app') 128 + ); 129 + } 99 130 }; 100 131 }); 101 132 } ··· 112 143 113 144 $this->commands([ 114 145 GenerateOAuthKeyCommand::class, 146 + MakeAtpClientCommand::class, 147 + MakeAtpRequestCommand::class, 115 148 ]); 116 149 } 117 150 118 151 $this->registerRoutes(); 152 + $this->registerMiddleware(); 153 + } 154 + 155 + /** 156 + * Register middleware aliases 157 + */ 158 + protected function registerMiddleware(): void 159 + { 160 + /** @var Router $router */ 161 + $router = $this->app->make(Router::class); 162 + $router->aliasMiddleware('atp.scope', RequiresScopeMiddleware::class); 119 163 } 120 164 121 165 /** ··· 137 181 ->name('atp.oauth.jwks'); 138 182 }); 139 183 140 - // Register standard .well-known endpoint 141 - Route::get('.well-known/oauth-client-metadata', ClientMetadataController::class) 142 - ->name('atp.oauth.well-known'); 184 + // Register recommended client id convention (see: https://atproto.com/guides/oauth#clients) 185 + Route::get('oauth-client-metadata.json', ClientMetadataController::class) 186 + ->name('atp.oauth.json'); 143 187 } 144 188 145 189 /** ··· 149 193 */ 150 194 public function provides(): array 151 195 { 152 - return ['atp-client']; 196 + return ['atp-client', 'atp-scope']; 153 197 } 154 198 }
+43
src/Attributes/PublicEndpoint.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Attributes; 4 + 5 + use Attribute; 6 + 7 + /** 8 + * Documents that a method is a public endpoint that does not require authentication. 9 + * 10 + * This attribute currently serves as documentation to indicate which AT Protocol 11 + * endpoints can be called without an authenticated session. It helps developers 12 + * understand which endpoints work with `Atp::public()` against public API endpoints 13 + * like `https://public.api.bsky.app`. 14 + * 15 + * While this attribute does not currently perform runtime enforcement, scope 16 + * validation will be implemented in a future release. Correctly attributing 17 + * endpoints now ensures forward compatibility when enforcement is enabled. 18 + * 19 + * Public endpoints typically include operations like: 20 + * - Reading public profiles and posts 21 + * - Searching actors and content 22 + * - Resolving handles to DIDs 23 + * - Accessing repository data (sync endpoints) 24 + * - Describing servers and feed generators 25 + * 26 + * @example Basic usage 27 + * ```php 28 + * #[PublicEndpoint] 29 + * public function getProfile(string $actor): ProfileViewDetailed 30 + * ``` 31 + * 32 + * @see \SocialDept\AtpClient\Attributes\ScopedEndpoint For endpoints that require authentication 33 + */ 34 + #[Attribute(Attribute::TARGET_METHOD)] 35 + class PublicEndpoint 36 + { 37 + /** 38 + * @param string $description Human-readable description of the endpoint 39 + */ 40 + public function __construct( 41 + public readonly string $description = '', 42 + ) {} 43 + }
+67
src/Attributes/ScopedEndpoint.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Attributes; 4 + 5 + use Attribute; 6 + use SocialDept\AtpClient\Enums\Scope; 7 + 8 + /** 9 + * Documents that a method requires authentication with specific OAuth scopes. 10 + * 11 + * This attribute currently serves as documentation to indicate which AT Protocol 12 + * endpoints require authentication and what scopes they need. It helps developers 13 + * understand scope requirements when building applications. 14 + * 15 + * While this attribute does not currently perform runtime enforcement, scope 16 + * validation will be implemented in a future release. Correctly attributing 17 + * endpoints now ensures forward compatibility when enforcement is enabled. 18 + * 19 + * The AT Protocol currently uses "transition scopes" (like `transition:generic`) while 20 + * moving toward more granular scopes. The `granular` parameter allows documenting the 21 + * future granular scope that will replace the transition scope. 22 + * 23 + * @example Basic usage with a transition scope 24 + * ```php 25 + * #[ScopedEndpoint(Scope::TransitionGeneric)] 26 + * public function getTimeline(): GetTimelineResponse 27 + * ``` 28 + * 29 + * @example With future granular scope documented 30 + * ```php 31 + * #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')] 32 + * public function getTimeline(): GetTimelineResponse 33 + * ``` 34 + * 35 + * @see \SocialDept\AtpClient\Attributes\PublicEndpoint For endpoints that don't require authentication 36 + * @see \SocialDept\AtpClient\Enums\Scope For available scope values 37 + */ 38 + #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] 39 + class ScopedEndpoint 40 + { 41 + public array $scopes; 42 + 43 + /** 44 + * @param string|Scope|array<string|Scope> $scopes Required scope(s) for this method 45 + * @param string|null $granular Future granular scope equivalent 46 + * @param string $description Human-readable description of scope requirement 47 + */ 48 + public function __construct( 49 + string|Scope|array $scopes, 50 + public readonly ?string $granular = null, 51 + public readonly string $description = '', 52 + ) { 53 + $this->scopes = $this->normalizeScopes($scopes); 54 + } 55 + 56 + protected function normalizeScopes(string|Scope|array $scopes): array 57 + { 58 + if (! is_array($scopes)) { 59 + $scopes = [$scopes]; 60 + } 61 + 62 + return array_map( 63 + fn ($scope) => $scope instanceof Scope ? $scope->value : $scope, 64 + $scopes 65 + ); 66 + } 67 + }
+77
src/Auth/ClientAssertionManager.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use Firebase\JWT\JWT; 6 + 7 + class ClientAssertionManager 8 + { 9 + public function __construct( 10 + protected ClientMetadataManager $metadata, 11 + ) {} 12 + 13 + /** 14 + * Check if client assertion is required (private key is configured) 15 + */ 16 + public function isRequired(): bool 17 + { 18 + return ! empty(config('client.oauth.private_key')); 19 + } 20 + 21 + /** 22 + * Create a client assertion JWT for private_key_jwt authentication 23 + */ 24 + public function createAssertion(string $audience): string 25 + { 26 + $key = OAuthKey::load(); 27 + $now = time(); 28 + 29 + $payload = [ 30 + 'iss' => $this->metadata->getClientId(), 31 + 'sub' => $this->metadata->getClientId(), 32 + 'aud' => $audience, 33 + 'jti' => bin2hex(random_bytes(16)), 34 + 'iat' => $now, 35 + 'exp' => $now + 60, 36 + ]; 37 + 38 + $header = [ 39 + 'alg' => 'ES256', 40 + 'kid' => config('client.oauth.kid', 'atp-client-key'), 41 + 'typ' => 'JWT', 42 + ]; 43 + 44 + return JWT::encode( 45 + payload: $payload, 46 + key: $key->toPEM(), 47 + alg: 'ES256', 48 + head: $header 49 + ); 50 + } 51 + 52 + /** 53 + * Get the client assertion type for OAuth requests 54 + */ 55 + public function getAssertionType(): string 56 + { 57 + return 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; 58 + } 59 + 60 + /** 61 + * Get client authentication parameters for OAuth requests 62 + */ 63 + public function getAuthParams(string $audience): array 64 + { 65 + if (! $this->isRequired()) { 66 + return [ 67 + 'client_id' => $this->metadata->getClientId(), 68 + ]; 69 + } 70 + 71 + return [ 72 + 'client_id' => $this->metadata->getClientId(), 73 + 'client_assertion_type' => $this->getAssertionType(), 74 + 'client_assertion' => $this->createAssertion($audience), 75 + ]; 76 + } 77 + }
+120 -9
src/Auth/ClientMetadataManager.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Auth; 4 4 5 + /** 6 + * Manages OAuth client metadata for AT Protocol authentication. 7 + * 8 + * The client_id in atproto OAuth is a URL that serves as both the unique 9 + * identifier and the location of the client metadata document. 10 + * 11 + * For production: Use an HTTPS URL pointing to your client metadata. 12 + * For localhost: Use exactly 'http://localhost' (no port). 13 + * 14 + * @see https://atproto.com/specs/oauth#clients 15 + */ 5 16 class ClientMetadataManager 6 17 { 7 18 /** 8 - * Get the client ID (typically the client URL) 19 + * Get the client ID (URL to client metadata document). 20 + * 21 + * For production clients, this is an HTTPS URL like: 22 + * 'https://example.com/oauth/client-metadata.json' 23 + * 24 + * For localhost development, this must be exactly 'http://localhost' 25 + * (no port number allowed per atproto spec). 9 26 */ 10 27 public function getClientId(): string 11 28 { 12 - return config('client.client.url'); 29 + $clientId = config('client.client.client_id'); 30 + 31 + if ($clientId) { 32 + return $clientId; 33 + } 34 + 35 + // Fall back to auto-generated client_id based on app URL 36 + return $this->generateClientId(); 13 37 } 14 38 15 39 /** 16 - * Get the client metadata URL 40 + * Check if this is a localhost development client. 17 41 */ 18 - public function getMetadataUrl(): ?string 42 + public function isLocalhost(): bool 19 43 { 20 - return config('client.client.metadata_url'); 44 + return str_starts_with($this->getClientId(), 'http://localhost'); 21 45 } 22 46 23 47 /** 24 - * Get the redirect URIs 48 + * Get the redirect URIs. 49 + * 50 + * For localhost development, redirect URIs must use 127.0.0.1 51 + * (not localhost) and can include a port number. 25 52 * 26 53 * @return array<string> 27 54 */ 28 55 public function getRedirectUris(): array 29 56 { 30 - return config('client.client.redirect_uris', []); 57 + $uris = config('client.client.redirect_uris', []); 58 + 59 + if (! empty($uris)) { 60 + return $uris; 61 + } 62 + 63 + // Default redirect URI based on environment 64 + if ($this->isLocalhost()) { 65 + // For localhost, use 127.0.0.1 66 + return ['http://127.0.0.1']; 67 + } 68 + 69 + // For production, use app URL 70 + return [config('client.client.url').'/auth/atp/callback']; 31 71 } 32 72 33 73 /** 34 - * Get the OAuth scopes 74 + * Get the OAuth scopes. 35 75 * 36 76 * @return array<string> 37 77 */ ··· 41 81 } 42 82 43 83 /** 44 - * Get the client metadata as an array 84 + * Get the client metadata as an array. 85 + * 86 + * This is the structure served at the client_id URL. 45 87 * 46 88 * @return array<string, mixed> 47 89 */ ··· 62 104 'application_type' => 'web', 63 105 'dpop_bound_access_tokens' => true, 64 106 ]; 107 + } 108 + 109 + /** 110 + * Generate client_id from app configuration. 111 + * 112 + * In production, points to the package's client-metadata.json endpoint. 113 + * For localhost detection, checks if app URL contains localhost or .test. 114 + * 115 + * For localhost clients, the client_id includes query parameters for 116 + * redirect_uri and scope since there's no metadata document to fetch. 117 + * 118 + * @see https://atproto.com/specs/oauth#clients 119 + */ 120 + protected function generateClientId(): string 121 + { 122 + $appUrl = config('client.client.url') ?? config('app.url'); 123 + $host = parse_url($appUrl, PHP_URL_HOST); 124 + 125 + // Detect local development environments 126 + if ($this->isLocalDevelopment($host)) { 127 + return $this->buildLocalhostClientId(); 128 + } 129 + 130 + // Production: point to client metadata endpoint 131 + $prefix = config('client.oauth.prefix', '/atp/oauth/'); 132 + 133 + return rtrim($appUrl, '/').rtrim($prefix, '/').'/client-metadata.json'; 134 + } 135 + 136 + /** 137 + * Build localhost client_id with query parameters. 138 + * 139 + * For localhost clients, metadata is passed via query parameters: 140 + * - redirect_uri: The callback URL (using 127.0.0.1) 141 + * - scope: Space-separated list of requested scopes 142 + */ 143 + protected function buildLocalhostClientId(): string 144 + { 145 + $params = []; 146 + 147 + // Add redirect URI 148 + $redirectUris = config('client.client.redirect_uris', []); 149 + if (! empty($redirectUris)) { 150 + $params['redirect_uri'] = $redirectUris[0]; 151 + } else { 152 + $params['redirect_uri'] = 'http://127.0.0.1'; 153 + } 154 + 155 + // Add scopes 156 + $scopes = config('client.client.scopes', ['atproto']); 157 + $params['scope'] = implode(' ', $scopes); 158 + 159 + return 'http://localhost?'.http_build_query($params); 160 + } 161 + 162 + /** 163 + * Check if the host indicates a local development environment. 164 + */ 165 + protected function isLocalDevelopment(?string $host): bool 166 + { 167 + if (! $host) { 168 + return false; 169 + } 170 + 171 + return $host === 'localhost' 172 + || $host === '127.0.0.1' 173 + || str_ends_with($host, '.localhost') 174 + || str_ends_with($host, '.test') 175 + || str_ends_with($host, '.local'); 65 176 } 66 177 }
+44 -32
src/Auth/OAuthEngine.php
··· 6 6 use SocialDept\AtpClient\Data\AccessToken; 7 7 use SocialDept\AtpClient\Data\AuthorizationRequest; 8 8 use SocialDept\AtpClient\Data\DPoPKey; 9 + use SocialDept\AtpClient\Events\SessionAuthenticated; 10 + use SocialDept\AtpClient\Contracts\KeyStore; 9 11 use SocialDept\AtpClient\Exceptions\AuthenticationException; 10 12 use SocialDept\AtpClient\Http\DPoPClient; 11 13 use SocialDept\AtpResolver\Facades\Resolver; ··· 16 18 protected DPoPKeyManager $dpopManager, 17 19 protected ClientMetadataManager $metadata, 18 20 protected DPoPClient $dpopClient, 21 + protected ClientAssertionManager $clientAssertion, 22 + protected KeyStore $keyStore, 19 23 ) {} 20 24 21 25 /** ··· 23 27 */ 24 28 public function authorize( 25 29 string $identifier, 26 - array $scopes = ['atproto', 'transition:generic'], 30 + ?array $scopes = null, 27 31 ?string $pdsEndpoint = null 28 32 ): AuthorizationRequest { 33 + // Use configured scopes if none provided 34 + $scopes = $scopes ?? $this->metadata->getScopes(); 35 + 29 36 // Resolve PDS endpoint 30 37 if (! $pdsEndpoint) { 31 38 $pdsEndpoint = Resolver::resolvePds($identifier); ··· 46 53 $pdsEndpoint, 47 54 $scopes, 48 55 $codeChallenge, 56 + $state, 49 57 $dpopKey 50 58 ); 51 59 ··· 61 69 codeVerifier: $codeVerifier, 62 70 dpopKey: $dpopKey, 63 71 requestUri: $parResponse['request_uri'], 72 + pdsEndpoint: $pdsEndpoint, 73 + handle: $identifier, 64 74 ); 65 75 } 66 76 ··· 76 86 throw new AuthenticationException('State mismatch'); 77 87 } 78 88 79 - // Get PDS endpoint from request 80 - $pdsEndpoint = $this->extractPdsFromRequestUri($request->requestUri); 81 - $tokenUrl = $pdsEndpoint.'/oauth/token'; 89 + $tokenUrl = $request->pdsEndpoint.'/oauth/token'; 82 90 83 - $response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $request->dpopKey) 91 + $response = $this->dpopClient->request($request->pdsEndpoint, $tokenUrl, 'POST', $request->dpopKey) 84 92 ->asForm() 85 - ->post($tokenUrl, [ 86 - 'grant_type' => 'authorization_code', 87 - 'code' => $code, 88 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 89 - 'client_id' => $this->metadata->getClientId(), 90 - 'code_verifier' => $request->codeVerifier, 91 - ]); 93 + ->post($tokenUrl, array_merge( 94 + $this->clientAssertion->getAuthParams($request->pdsEndpoint), 95 + [ 96 + 'grant_type' => 'authorization_code', 97 + 'code' => $code, 98 + 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 99 + 'code_verifier' => $request->codeVerifier, 100 + ] 101 + )); 92 102 93 103 if ($response->failed()) { 94 104 throw new AuthenticationException('Token exchange failed: '.$response->body()); 95 105 } 96 106 97 - return AccessToken::fromResponse($response->json()); 107 + $token = AccessToken::fromResponse($response->json(), $request->handle, $request->pdsEndpoint); 108 + 109 + // Store the DPoP key with the session ID so future requests can use it 110 + // The token is bound to this key's thumbprint (cnf.jkt claim) 111 + $sessionId = 'session_'.hash('sha256', $token->did); 112 + $this->keyStore->store($sessionId, $request->dpopKey); 113 + 114 + event(new SessionAuthenticated($token)); 115 + 116 + return $token; 98 117 } 99 118 100 119 /** ··· 104 123 string $pdsEndpoint, 105 124 array $scopes, 106 125 string $codeChallenge, 126 + string $state, 107 127 DPoPKey $dpopKey 108 128 ): array { 109 129 $parUrl = $pdsEndpoint.'/oauth/par'; 110 130 111 131 $response = $this->dpopClient->request($pdsEndpoint, $parUrl, 'POST', $dpopKey) 112 132 ->asForm() 113 - ->post($parUrl, [ 114 - 'client_id' => $this->metadata->getClientId(), 115 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 116 - 'response_type' => 'code', 117 - 'scope' => implode(' ', $scopes), 118 - 'code_challenge' => $codeChallenge, 119 - 'code_challenge_method' => 'S256', 120 - 'state' => Str::random(32), 121 - ]); 133 + ->post($parUrl, array_merge( 134 + $this->clientAssertion->getAuthParams($pdsEndpoint), 135 + [ 136 + 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 137 + 'response_type' => 'code', 138 + 'scope' => implode(' ', $scopes), 139 + 'code_challenge' => $codeChallenge, 140 + 'code_challenge_method' => 'S256', 141 + 'state' => $state, 142 + ] 143 + )); 122 144 123 145 if ($response->failed()) { 124 146 throw new AuthenticationException('PAR failed: '.$response->body()); ··· 133 155 protected function generatePkceChallenge(string $verifier): string 134 156 { 135 157 return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); 136 - } 137 - 138 - /** 139 - * Extract PDS endpoint from request URI 140 - */ 141 - protected function extractPdsFromRequestUri(string $requestUri): string 142 - { 143 - $parts = parse_url($requestUri); 144 - 145 - return ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? ''); 146 158 } 147 159 }
+271
src/Auth/ScopeChecker.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use BackedEnum; 6 + use Illuminate\Support\Facades\Log; 7 + use SocialDept\AtpClient\Enums\Scope; 8 + use SocialDept\AtpClient\Enums\ScopeEnforcementLevel; 9 + use SocialDept\AtpClient\Exceptions\MissingScopeException; 10 + use SocialDept\AtpClient\Session\Session; 11 + 12 + class ScopeChecker 13 + { 14 + public function __construct( 15 + protected ScopeEnforcementLevel $enforcement = ScopeEnforcementLevel::Permissive 16 + ) {} 17 + 18 + /** 19 + * Check if the session has all required scopes. 20 + * 21 + * @param array<string|Scope> $requiredScopes 22 + */ 23 + public function check(Session $session, array $requiredScopes): bool 24 + { 25 + $required = $this->normalizeScopes($requiredScopes); 26 + $granted = $session->scopes(); 27 + 28 + foreach ($required as $scope) { 29 + if (! $this->sessionHasScope($session, $scope)) { 30 + return false; 31 + } 32 + } 33 + 34 + return true; 35 + } 36 + 37 + /** 38 + * Check scopes and handle enforcement based on configuration. 39 + * 40 + * @param array<string|Scope> $requiredScopes 41 + * 42 + * @throws MissingScopeException 43 + */ 44 + public function checkOrFail(Session $session, array $requiredScopes): void 45 + { 46 + if ($this->check($session, $requiredScopes)) { 47 + return; 48 + } 49 + 50 + $required = $this->normalizeScopes($requiredScopes); 51 + $granted = $session->scopes(); 52 + $missing = array_diff($required, $granted); 53 + 54 + if ($this->enforcement === ScopeEnforcementLevel::Strict) { 55 + throw new MissingScopeException($missing, $granted); 56 + } 57 + 58 + Log::warning('ATP Client: Missing required scope(s)', [ 59 + 'required' => $required, 60 + 'granted' => $granted, 61 + 'missing' => $missing, 62 + 'did' => $session->did(), 63 + ]); 64 + } 65 + 66 + /** 67 + * Check if the session has a specific scope. 68 + */ 69 + public function hasScope(Session $session, string|Scope $scope): bool 70 + { 71 + $scope = $scope instanceof Scope ? $scope->value : $scope; 72 + 73 + return $this->sessionHasScope($session, $scope); 74 + } 75 + 76 + /** 77 + * Check if the session matches a granular scope pattern. 78 + * 79 + * Supports patterns like: 80 + * - repo:app.bsky.feed.post?action=create 81 + * - repo:app.bsky.feed.* 82 + * - rpc:app.bsky.feed.* 83 + * - blob:image/* 84 + */ 85 + public function matchesGranular(Session $session, string $pattern): bool 86 + { 87 + $granted = $session->scopes(); 88 + 89 + // Check for exact match first 90 + if (in_array($pattern, $granted, true)) { 91 + return true; 92 + } 93 + 94 + // Handle repo: scopes with action semantics 95 + if (str_starts_with($pattern, 'repo:')) { 96 + foreach ($granted as $scope) { 97 + if (str_starts_with($scope, 'repo:') && $this->matchesRepoScope($pattern, $scope)) { 98 + return true; 99 + } 100 + } 101 + } 102 + 103 + // Check for wildcard matches 104 + $patternRegex = $this->patternToRegex($pattern); 105 + 106 + foreach ($granted as $scope) { 107 + if (preg_match($patternRegex, $scope)) { 108 + return true; 109 + } 110 + } 111 + 112 + // Check if granted scope is a superset (wildcard in granted scope) 113 + foreach ($granted as $scope) { 114 + $grantedRegex = $this->patternToRegex($scope); 115 + if (preg_match($grantedRegex, $pattern)) { 116 + return true; 117 + } 118 + } 119 + 120 + return false; 121 + } 122 + 123 + /** 124 + * Check if a required repo scope is satisfied by a granted repo scope. 125 + * 126 + * Per AT Protocol spec: "If not defined, all operations are allowed." 127 + * - repo:collection (no action) grants ALL actions 128 + * - repo:collection?action=create grants only create 129 + * - repo:* grants all collections with all actions 130 + */ 131 + protected function matchesRepoScope(string $required, string $granted): bool 132 + { 133 + $requiredParsed = $this->parseRepoScope($required); 134 + $grantedParsed = $this->parseRepoScope($granted); 135 + 136 + // Check collection match (with wildcard support) 137 + if (! $this->collectionsMatch($requiredParsed['collection'], $grantedParsed['collection'])) { 138 + return false; 139 + } 140 + 141 + // If granted has no actions, it grants ALL actions 142 + if (empty($grantedParsed['actions'])) { 143 + return true; 144 + } 145 + 146 + // If required has no actions, we need all actions granted 147 + if (empty($requiredParsed['actions'])) { 148 + // Required needs all actions, but granted is restricted 149 + return false; 150 + } 151 + 152 + // Check if all required actions are in granted actions 153 + return empty(array_diff($requiredParsed['actions'], $grantedParsed['actions'])); 154 + } 155 + 156 + /** 157 + * Parse a repo scope into collection and actions. 158 + * 159 + * Handles formats like: 160 + * - repo:app.bsky.feed.post 161 + * - repo:app.bsky.feed.post?action=create 162 + * - repo:app.bsky.feed.post?action=create&action=update&action=delete 163 + * - repo:* 164 + * - repo:*?action=delete 165 + * 166 + * @return array{collection: string, actions: array<string>} 167 + */ 168 + protected function parseRepoScope(string $scope): array 169 + { 170 + $parts = explode('?', $scope, 2); 171 + $collection = substr($parts[0], 5); // Remove 'repo:' 172 + 173 + $actions = []; 174 + if (isset($parts[1])) { 175 + // Parse action=create&action=update&action=delete format 176 + // PHP's parse_str doesn't handle repeated params well 177 + preg_match_all('/action=([^&]+)/', $parts[1], $matches); 178 + if (! empty($matches[1])) { 179 + $actions = array_map('urldecode', $matches[1]); 180 + } 181 + } 182 + 183 + return ['collection' => $collection, 'actions' => $actions]; 184 + } 185 + 186 + /** 187 + * Check if a required collection matches a granted collection. 188 + */ 189 + protected function collectionsMatch(string $required, string $granted): bool 190 + { 191 + if ($granted === '*') { 192 + return true; 193 + } 194 + 195 + return $required === $granted; 196 + } 197 + 198 + /** 199 + * Check if the session has repo access for a specific collection and action. 200 + */ 201 + public function checkRepoScope(Session $session, string|BackedEnum $collection, string $action): bool 202 + { 203 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 204 + $required = "repo:{$collection}?action={$action}"; 205 + 206 + return $this->sessionHasScope($session, $required); 207 + } 208 + 209 + /** 210 + * Check repo scope and handle enforcement based on configuration. 211 + * 212 + * @throws MissingScopeException 213 + */ 214 + public function checkRepoScopeOrFail(Session $session, string|BackedEnum $collection, string $action): void 215 + { 216 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 217 + $required = "repo:{$collection}?action={$action}"; 218 + 219 + $this->checkOrFail($session, [$required]); 220 + } 221 + 222 + /** 223 + * Get the current enforcement level. 224 + */ 225 + public function enforcement(): ScopeEnforcementLevel 226 + { 227 + return $this->enforcement; 228 + } 229 + 230 + /** 231 + * Create a new instance with a different enforcement level. 232 + */ 233 + public function withEnforcement(ScopeEnforcementLevel $enforcement): self 234 + { 235 + return new self($enforcement); 236 + } 237 + 238 + /** 239 + * @param array<string|Scope> $scopes 240 + * @return array<string> 241 + */ 242 + protected function normalizeScopes(array $scopes): array 243 + { 244 + return array_map( 245 + fn ($scope) => $scope instanceof Scope ? $scope->value : $scope, 246 + $scopes 247 + ); 248 + } 249 + 250 + protected function sessionHasScope(Session $session, string $scope): bool 251 + { 252 + // Direct match 253 + if ($session->hasScope($scope)) { 254 + return true; 255 + } 256 + 257 + // Check granular pattern matching 258 + return $this->matchesGranular($session, $scope); 259 + } 260 + 261 + protected function patternToRegex(string $pattern): string 262 + { 263 + // Escape regex special characters except * 264 + $escaped = preg_quote($pattern, '/'); 265 + 266 + // Replace \* with .* for wildcard matching 267 + $regex = str_replace('\*', '.*', $escaped); 268 + 269 + return '/^'.$regex.'$/'; 270 + } 271 + }
+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 + }
+48 -7
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 ··· 11 13 { 12 14 public function __construct( 13 15 protected DPoPClient $dpopClient, 16 + protected ClientAssertionManager $clientAssertion, 14 17 ) {} 15 18 16 19 /** 17 - * Refresh access token using refresh token 20 + * Refresh access token using refresh token. 18 21 * NOTE: Refresh tokens are single-use! 19 22 */ 20 23 public function refresh( 21 24 string $refreshToken, 22 25 string $pdsEndpoint, 23 - DPoPKey $dpopKey 26 + DPoPKey $dpopKey, 27 + ?string $handle = null, 28 + AuthType $authType = AuthType::OAuth, 29 + ): AccessToken { 30 + return $authType === AuthType::Legacy 31 + ? $this->refreshLegacy($refreshToken, $pdsEndpoint, $handle) 32 + : $this->refreshOAuth($refreshToken, $pdsEndpoint, $dpopKey, $handle); 33 + } 34 + 35 + /** 36 + * Refresh OAuth session using /oauth/token endpoint with DPoP. 37 + */ 38 + protected function refreshOAuth( 39 + string $refreshToken, 40 + string $pdsEndpoint, 41 + DPoPKey $dpopKey, 42 + ?string $handle, 24 43 ): AccessToken { 25 44 $tokenUrl = $pdsEndpoint.'/oauth/token'; 26 45 27 46 $response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $dpopKey) 28 47 ->asForm() 29 - ->post($tokenUrl, [ 30 - 'grant_type' => 'refresh_token', 31 - 'refresh_token' => $refreshToken, 32 - ]); 48 + ->post($tokenUrl, array_merge( 49 + $this->clientAssertion->getAuthParams($pdsEndpoint), 50 + [ 51 + 'grant_type' => 'refresh_token', 52 + 'refresh_token' => $refreshToken, 53 + ] 54 + )); 55 + 56 + if ($response->failed()) { 57 + throw new AuthenticationException('Token refresh failed: '.$response->body()); 58 + } 59 + 60 + return AccessToken::fromResponse($response->json(), $handle, $pdsEndpoint); 61 + } 62 + 63 + /** 64 + * Refresh legacy session using /xrpc/com.atproto.server.refreshSession endpoint. 65 + */ 66 + protected function refreshLegacy( 67 + string $refreshToken, 68 + string $pdsEndpoint, 69 + ?string $handle, 70 + ): AccessToken { 71 + $response = Http::withHeader('Authorization', 'Bearer '.$refreshToken) 72 + ->withBody('', 'application/json') 73 + ->post($pdsEndpoint.'/xrpc/com.atproto.server.refreshSession'); 33 74 34 75 if ($response->failed()) { 35 76 throw new AuthenticationException('Token refresh failed: '.$response->body()); 36 77 } 37 78 38 - return AccessToken::fromResponse($response->json()); 79 + return AccessToken::fromResponse($response->json(), $handle, $pdsEndpoint); 39 80 } 40 81 }
+197
src/Builders/Concerns/BuildsRichText.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Builders\Concerns; 4 + 5 + use SocialDept\AtpClient\RichText\FacetDetector; 6 + use SocialDept\AtpResolver\Facades\Resolver; 7 + 8 + trait BuildsRichText 9 + { 10 + protected string $text = ''; 11 + 12 + protected array $facets = []; 13 + 14 + /** 15 + * Add plain text 16 + */ 17 + public function text(string $text): self 18 + { 19 + $this->text .= $text; 20 + 21 + return $this; 22 + } 23 + 24 + /** 25 + * Add one or more new lines 26 + */ 27 + public function newLine(int $count = 1): self 28 + { 29 + $this->text .= str_repeat("\n", $count); 30 + 31 + return $this; 32 + } 33 + 34 + /** 35 + * Add mention (@handle) 36 + */ 37 + public function mention(string $handle, ?string $did = null): self 38 + { 39 + $handle = ltrim($handle, '@'); 40 + $start = $this->getBytePosition(); 41 + $this->text .= '@'.$handle; 42 + $end = $this->getBytePosition(); 43 + 44 + if (! $did) { 45 + try { 46 + $did = Resolver::handleToDid($handle); 47 + } catch (\Exception $e) { 48 + return $this; 49 + } 50 + } 51 + 52 + $this->facets[] = [ 53 + 'index' => [ 54 + 'byteStart' => $start, 55 + 'byteEnd' => $end, 56 + ], 57 + 'features' => [ 58 + [ 59 + '$type' => 'app.bsky.richtext.facet#mention', 60 + 'did' => $did, 61 + ], 62 + ], 63 + ]; 64 + 65 + return $this; 66 + } 67 + 68 + /** 69 + * Add link with custom display text 70 + */ 71 + public function link(string $text, string $uri): self 72 + { 73 + $start = $this->getBytePosition(); 74 + $this->text .= $text; 75 + $end = $this->getBytePosition(); 76 + 77 + $this->facets[] = [ 78 + 'index' => [ 79 + 'byteStart' => $start, 80 + 'byteEnd' => $end, 81 + ], 82 + 'features' => [ 83 + [ 84 + '$type' => 'app.bsky.richtext.facet#link', 85 + 'uri' => $uri, 86 + ], 87 + ], 88 + ]; 89 + 90 + return $this; 91 + } 92 + 93 + /** 94 + * Add a URL (displayed as-is) 95 + */ 96 + public function url(string $url): self 97 + { 98 + return $this->link($url, $url); 99 + } 100 + 101 + /** 102 + * Add hashtag 103 + */ 104 + public function tag(string $tag): self 105 + { 106 + $tag = ltrim($tag, '#'); 107 + 108 + $start = $this->getBytePosition(); 109 + $this->text .= '#'.$tag; 110 + $end = $this->getBytePosition(); 111 + 112 + $this->facets[] = [ 113 + 'index' => [ 114 + 'byteStart' => $start, 115 + 'byteEnd' => $end, 116 + ], 117 + 'features' => [ 118 + [ 119 + '$type' => 'app.bsky.richtext.facet#tag', 120 + 'tag' => $tag, 121 + ], 122 + ], 123 + ]; 124 + 125 + return $this; 126 + } 127 + 128 + /** 129 + * Auto-detect and add facets from plain text 130 + */ 131 + public function autoDetect(string $text): self 132 + { 133 + $start = $this->getBytePosition(); 134 + $this->text .= $text; 135 + 136 + $detected = FacetDetector::detect($text); 137 + 138 + foreach ($detected as $facet) { 139 + $facet['index']['byteStart'] += $start; 140 + $facet['index']['byteEnd'] += $start; 141 + $this->facets[] = $facet; 142 + } 143 + 144 + return $this; 145 + } 146 + 147 + /** 148 + * Get current byte position (UTF-8 byte offset) 149 + */ 150 + protected function getBytePosition(): int 151 + { 152 + return strlen($this->text); 153 + } 154 + 155 + /** 156 + * Get the text content 157 + */ 158 + public function getText(): string 159 + { 160 + return $this->text; 161 + } 162 + 163 + /** 164 + * Get the facets 165 + */ 166 + public function getFacets(): array 167 + { 168 + return $this->facets; 169 + } 170 + 171 + /** 172 + * Get text and facets as array 173 + */ 174 + protected function getTextAndFacets(): array 175 + { 176 + return [ 177 + 'text' => $this->text, 178 + 'facets' => $this->facets, 179 + ]; 180 + } 181 + 182 + /** 183 + * Get grapheme count (closest to what AT Protocol uses for limits) 184 + */ 185 + public function getGraphemeCount(): int 186 + { 187 + return grapheme_strlen($this->text); 188 + } 189 + 190 + /** 191 + * Check if text exceeds AT Protocol post limit (300 graphemes) 192 + */ 193 + public function exceedsLimit(int $limit = 300): bool 194 + { 195 + return $this->getGraphemeCount() > $limit; 196 + } 197 + }
+93
src/Builders/Embeds/ImagesBuilder.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Builders\Embeds; 4 + 5 + class ImagesBuilder 6 + { 7 + protected array $images = []; 8 + 9 + /** 10 + * Create a new images builder instance 11 + */ 12 + public static function make(): self 13 + { 14 + return new self; 15 + } 16 + 17 + /** 18 + * Add an image to the embed 19 + * 20 + * @param mixed $blob BlobReference or blob array 21 + * @param string $alt Alt text for the image 22 + * @param array|null $aspectRatio [width, height] aspect ratio 23 + */ 24 + public function add(mixed $blob, string $alt, ?array $aspectRatio = null): self 25 + { 26 + $image = [ 27 + 'image' => $this->normalizeBlob($blob), 28 + 'alt' => $alt, 29 + ]; 30 + 31 + if ($aspectRatio !== null) { 32 + $image['aspectRatio'] = [ 33 + 'width' => $aspectRatio[0], 34 + 'height' => $aspectRatio[1], 35 + ]; 36 + } 37 + 38 + $this->images[] = $image; 39 + 40 + return $this; 41 + } 42 + 43 + /** 44 + * Get all images 45 + */ 46 + public function getImages(): array 47 + { 48 + return $this->images; 49 + } 50 + 51 + /** 52 + * Check if builder has images 53 + */ 54 + public function hasImages(): bool 55 + { 56 + return ! empty($this->images); 57 + } 58 + 59 + /** 60 + * Get the count of images 61 + */ 62 + public function count(): int 63 + { 64 + return count($this->images); 65 + } 66 + 67 + /** 68 + * Convert to embed array format 69 + */ 70 + public function toArray(): array 71 + { 72 + return [ 73 + '$type' => 'app.bsky.embed.images', 74 + 'images' => $this->images, 75 + ]; 76 + } 77 + 78 + /** 79 + * Normalize blob to array format 80 + */ 81 + protected function normalizeBlob(mixed $blob): array 82 + { 83 + if (is_array($blob)) { 84 + return $blob; 85 + } 86 + 87 + if (method_exists($blob, 'toArray')) { 88 + return $blob->toArray(); 89 + } 90 + 91 + return (array) $blob; 92 + } 93 + }
+257
src/Builders/PostBuilder.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Builders; 4 + 5 + use Closure; 6 + use DateTimeInterface; 7 + use SocialDept\AtpClient\Builders\Concerns\BuildsRichText; 8 + use SocialDept\AtpClient\Builders\Embeds\ImagesBuilder; 9 + use SocialDept\AtpClient\Client\Records\PostRecordClient; 10 + use SocialDept\AtpClient\Contracts\Recordable; 11 + use SocialDept\AtpClient\Data\StrongRef; 12 + use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 13 + 14 + class PostBuilder implements Recordable 15 + { 16 + use BuildsRichText; 17 + 18 + protected ?array $embed = null; 19 + 20 + protected ?array $reply = null; 21 + 22 + protected ?array $langs = null; 23 + 24 + protected ?DateTimeInterface $createdAt = null; 25 + 26 + protected ?PostRecordClient $client = null; 27 + 28 + /** 29 + * Create a new post builder instance 30 + */ 31 + public static function make(): self 32 + { 33 + return new self; 34 + } 35 + 36 + /** 37 + * Add images embed 38 + * 39 + * @param Closure|array $images Closure receiving ImagesBuilder, or array of image data 40 + */ 41 + public function images(Closure|array $images): self 42 + { 43 + if ($images instanceof Closure) { 44 + $builder = ImagesBuilder::make(); 45 + $images($builder); 46 + $this->embed = $builder->toArray(); 47 + } else { 48 + $this->embed = [ 49 + '$type' => 'app.bsky.embed.images', 50 + 'images' => array_map(fn ($img) => $this->normalizeImageData($img), $images), 51 + ]; 52 + } 53 + 54 + return $this; 55 + } 56 + 57 + /** 58 + * Add external link embed (link card) 59 + * 60 + * @param string $uri URL of the external content 61 + * @param string $title Title of the link card 62 + * @param string $description Description text 63 + * @param mixed|null $thumb Optional thumbnail blob 64 + */ 65 + public function external(string $uri, string $title, string $description, mixed $thumb = null): self 66 + { 67 + $external = [ 68 + 'uri' => $uri, 69 + 'title' => $title, 70 + 'description' => $description, 71 + ]; 72 + 73 + if ($thumb !== null) { 74 + $external['thumb'] = $this->normalizeBlob($thumb); 75 + } 76 + 77 + $this->embed = [ 78 + '$type' => 'app.bsky.embed.external', 79 + 'external' => $external, 80 + ]; 81 + 82 + return $this; 83 + } 84 + 85 + /** 86 + * Add video embed 87 + * 88 + * @param mixed $blob Video blob reference 89 + * @param string|null $alt Alt text for the video 90 + * @param array|null $captions Optional captions array 91 + */ 92 + public function video(mixed $blob, ?string $alt = null, ?array $captions = null): self 93 + { 94 + $video = [ 95 + '$type' => 'app.bsky.embed.video', 96 + 'video' => $this->normalizeBlob($blob), 97 + ]; 98 + 99 + if ($alt !== null) { 100 + $video['alt'] = $alt; 101 + } 102 + 103 + if ($captions !== null) { 104 + $video['captions'] = $captions; 105 + } 106 + 107 + $this->embed = $video; 108 + 109 + return $this; 110 + } 111 + 112 + /** 113 + * Add quote embed (embed another post) 114 + */ 115 + public function quote(StrongRef $post): self 116 + { 117 + $this->embed = [ 118 + '$type' => 'app.bsky.embed.record', 119 + 'record' => $post->toArray(), 120 + ]; 121 + 122 + return $this; 123 + } 124 + 125 + /** 126 + * Set as a reply to another post 127 + * 128 + * @param StrongRef $parent The post being replied to 129 + * @param StrongRef|null $root The root post of the thread (defaults to parent if not provided) 130 + */ 131 + public function replyTo(StrongRef $parent, ?StrongRef $root = null): self 132 + { 133 + $this->reply = [ 134 + 'parent' => $parent->toArray(), 135 + 'root' => ($root ?? $parent)->toArray(), 136 + ]; 137 + 138 + return $this; 139 + } 140 + 141 + /** 142 + * Set the post languages 143 + * 144 + * @param array $langs Array of BCP-47 language codes 145 + */ 146 + public function langs(array $langs): self 147 + { 148 + $this->langs = $langs; 149 + 150 + return $this; 151 + } 152 + 153 + /** 154 + * Set the creation timestamp 155 + */ 156 + public function createdAt(DateTimeInterface $date): self 157 + { 158 + $this->createdAt = $date; 159 + 160 + return $this; 161 + } 162 + 163 + /** 164 + * Bind to a PostRecordClient for creating the post 165 + */ 166 + public function for(PostRecordClient $client): self 167 + { 168 + $this->client = $client; 169 + 170 + return $this; 171 + } 172 + 173 + /** 174 + * Create the post (requires client binding via for() or build()) 175 + * 176 + * @throws \RuntimeException If no client is bound 177 + */ 178 + public function create(): StrongRef 179 + { 180 + if ($this->client === null) { 181 + throw new \RuntimeException( 182 + 'No client bound. Use ->for($client) or create via $client->bsky->post->build()' 183 + ); 184 + } 185 + 186 + return $this->client->create($this); 187 + } 188 + 189 + /** 190 + * Convert to array for XRPC (implements Recordable) 191 + */ 192 + public function toArray(): array 193 + { 194 + $record = $this->getTextAndFacets(); 195 + 196 + if ($this->embed !== null) { 197 + $record['embed'] = $this->embed; 198 + } 199 + 200 + if ($this->reply !== null) { 201 + $record['reply'] = $this->reply; 202 + } 203 + 204 + if ($this->langs !== null) { 205 + $record['langs'] = $this->langs; 206 + } 207 + 208 + $record['createdAt'] = ($this->createdAt ?? now())->format('c'); 209 + $record['$type'] = $this->getType(); 210 + 211 + return $record; 212 + } 213 + 214 + /** 215 + * Get the record type (implements Recordable) 216 + */ 217 + public function getType(): string 218 + { 219 + return BskyFeed::Post->value; 220 + } 221 + 222 + /** 223 + * Normalize image data from array format 224 + */ 225 + protected function normalizeImageData(array $data): array 226 + { 227 + $image = [ 228 + 'image' => $this->normalizeBlob($data['blob'] ?? $data['image']), 229 + 'alt' => $data['alt'] ?? '', 230 + ]; 231 + 232 + if (isset($data['aspectRatio'])) { 233 + $ratio = $data['aspectRatio']; 234 + $image['aspectRatio'] = is_array($ratio) && isset($ratio['width']) 235 + ? $ratio 236 + : ['width' => $ratio[0], 'height' => $ratio[1]]; 237 + } 238 + 239 + return $image; 240 + } 241 + 242 + /** 243 + * Normalize blob to array format 244 + */ 245 + protected function normalizeBlob(mixed $blob): array 246 + { 247 + if (is_array($blob)) { 248 + return $blob; 249 + } 250 + 251 + if (method_exists($blob, 'toArray')) { 252 + return $blob->toArray(); 253 + } 254 + 255 + return (array) $blob; 256 + } 257 + }
+18 -1
src/Client/AtprotoClient.php
··· 4 4 5 5 use SocialDept\AtpClient\AtpClient; 6 6 use SocialDept\AtpClient\Client\Requests\Atproto; 7 + use SocialDept\AtpClient\Concerns\HasDomainExtensions; 7 8 8 9 class AtprotoClient 9 10 { 11 + use HasDomainExtensions; 10 12 /** 11 13 * The parent AtpClient instance 12 14 */ 13 - public AtpClient $atp; 15 + protected AtpClient $atp; 14 16 15 17 /** 16 18 * Repository operations (com.atproto.repo.*) ··· 39 41 $this->server = new Atproto\ServerRequestClient($this); 40 42 $this->identity = new Atproto\IdentityRequestClient($this); 41 43 $this->sync = new Atproto\SyncRequestClient($this); 44 + } 45 + 46 + protected function getDomainName(): string 47 + { 48 + return 'atproto'; 49 + } 50 + 51 + protected function getRootClientClass(): string 52 + { 53 + return AtpClient::class; 54 + } 55 + 56 + public function root(): AtpClient 57 + { 58 + return $this->atp; 42 59 } 43 60 }
+31 -1
src/Client/BskyClient.php
··· 8 8 use SocialDept\AtpClient\Client\Records\PostRecordClient; 9 9 use SocialDept\AtpClient\Client\Records\ProfileRecordClient; 10 10 use SocialDept\AtpClient\Client\Requests\Bsky; 11 + use SocialDept\AtpClient\Concerns\HasDomainExtensions; 11 12 12 13 class BskyClient 13 14 { 15 + use HasDomainExtensions; 16 + 14 17 /** 15 18 * The parent AtpClient instance 16 19 */ 17 - public AtpClient $atp; 20 + protected AtpClient $atp; 18 21 19 22 /** 20 23 * Feed operations (app.bsky.feed.*) ··· 27 30 public Bsky\ActorRequestClient $actor; 28 31 29 32 /** 33 + * Graph operations (app.bsky.graph.*) 34 + */ 35 + public Bsky\GraphRequestClient $graph; 36 + 37 + /** 38 + * Labeler operations (app.bsky.labeler.*) 39 + */ 40 + public Bsky\LabelerRequestClient $labeler; 41 + 42 + /** 30 43 * Post record client 31 44 */ 32 45 public PostRecordClient $post; ··· 51 64 $this->atp = $parent; 52 65 $this->feed = new Bsky\FeedRequestClient($this); 53 66 $this->actor = new Bsky\ActorRequestClient($this); 67 + $this->graph = new Bsky\GraphRequestClient($this); 68 + $this->labeler = new Bsky\LabelerRequestClient($this); 54 69 $this->post = new PostRecordClient($this); 55 70 $this->profile = new ProfileRecordClient($this); 56 71 $this->like = new LikeRecordClient($this); 57 72 $this->follow = new FollowRecordClient($this); 73 + } 74 + 75 + protected function getDomainName(): string 76 + { 77 + return 'bsky'; 78 + } 79 + 80 + protected function getRootClientClass(): string 81 + { 82 + return AtpClient::class; 83 + } 84 + 85 + public function root(): AtpClient 86 + { 87 + return $this->atp; 58 88 } 59 89 }
+18 -1
src/Client/ChatClient.php
··· 4 4 5 5 use SocialDept\AtpClient\AtpClient; 6 6 use SocialDept\AtpClient\Client\Requests\Chat; 7 + use SocialDept\AtpClient\Concerns\HasDomainExtensions; 7 8 8 9 class ChatClient 9 10 { 11 + use HasDomainExtensions; 10 12 /** 11 13 * The parent AtpClient instance 12 14 */ 13 - public AtpClient $atp; 15 + protected AtpClient $atp; 14 16 15 17 /** 16 18 * Conversation operations (chat.bsky.convo.*) ··· 27 29 $this->atp = $parent; 28 30 $this->convo = new Chat\ConvoRequestClient($this); 29 31 $this->actor = new Chat\ActorRequestClient($this); 32 + } 33 + 34 + protected function getDomainName(): string 35 + { 36 + return 'chat'; 37 + } 38 + 39 + protected function getRootClientClass(): string 40 + { 41 + return AtpClient::class; 42 + } 43 + 44 + public function root(): AtpClient 45 + { 46 + return $this->atp; 30 47 } 31 48 }
+104 -6
src/Client/Client.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client; 4 4 5 + use BackedEnum; 6 + use Illuminate\Support\Facades\Http; 5 7 use SocialDept\AtpClient\AtpClient; 8 + use SocialDept\AtpClient\Exceptions\AtpResponseException; 6 9 use SocialDept\AtpClient\Http\DPoPClient; 7 10 use SocialDept\AtpClient\Http\HasHttp; 11 + use SocialDept\AtpClient\Http\Response; 12 + use SocialDept\AtpClient\Session\Session; 8 13 use SocialDept\AtpClient\Session\SessionManager; 9 14 10 15 class Client 11 16 { 12 - use HasHttp; 17 + use HasHttp { 18 + call as authenticatedCall; 19 + postBlob as authenticatedPostBlob; 20 + } 13 21 14 22 /** 15 23 * The parent AtpClient instance we belong to 16 24 */ 17 - public AtpClient $atp; 25 + protected AtpClient $atp; 26 + 27 + /** 28 + * Service URL for public mode 29 + */ 30 + protected ?string $serviceUrl; 18 31 19 32 public function __construct( 20 33 AtpClient $parent, 21 - SessionManager $sessions, 22 - string $identifier, 34 + ?SessionManager $sessions = null, 35 + ?string $did = null, 36 + ?string $serviceUrl = null, 23 37 ) { 24 38 $this->atp = $parent; 25 39 $this->sessions = $sessions; 26 - $this->identifier = $identifier; 27 - $this->dpopClient = app(DPoPClient::class); 40 + $this->did = $did; 41 + $this->serviceUrl = $serviceUrl; 42 + 43 + if (! $this->isPublicMode()) { 44 + $this->dpopClient = app(DPoPClient::class); 45 + } 46 + } 47 + 48 + /** 49 + * Check if client is in public mode (no authentication). 50 + */ 51 + public function isPublicMode(): bool 52 + { 53 + return $this->sessions === null || $this->did === null; 54 + } 55 + 56 + /** 57 + * Get the current session. 58 + */ 59 + public function session(): Session 60 + { 61 + return $this->sessions->session($this->did); 62 + } 63 + 64 + /** 65 + * Get the service URL. 66 + */ 67 + public function serviceUrl(): string 68 + { 69 + return $this->serviceUrl; 70 + } 71 + 72 + /** 73 + * Make XRPC call - routes to public or authenticated based on mode. 74 + */ 75 + protected function call( 76 + string|BackedEnum $endpoint, 77 + string $method, 78 + ?array $params = null, 79 + ?array $body = null 80 + ): Response { 81 + if ($this->isPublicMode()) { 82 + return $this->publicCall($endpoint, $method, $params, $body); 83 + } 84 + 85 + return $this->authenticatedCall($endpoint, $method, $params, $body); 86 + } 87 + 88 + /** 89 + * Make public XRPC call (no authentication). 90 + */ 91 + protected function publicCall( 92 + string|BackedEnum $endpoint, 93 + string $method, 94 + ?array $params = null, 95 + ?array $body = null 96 + ): Response { 97 + $endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint; 98 + $url = rtrim($this->serviceUrl, '/') . '/xrpc/' . $endpoint; 99 + $params = array_filter($params ?? [], fn ($v) => ! is_null($v)); 100 + 101 + $response = match ($method) { 102 + 'GET' => Http::get($url, $params), 103 + 'POST' => Http::post($url, $body ?? $params), 104 + 'DELETE' => Http::delete($url, $params), 105 + default => throw new \InvalidArgumentException("Unsupported method: {$method}"), 106 + }; 107 + 108 + if ($response->failed() || isset($response->json()['error'])) { 109 + throw AtpResponseException::fromResponse($response, $endpoint); 110 + } 111 + 112 + return new Response($response); 113 + } 114 + 115 + /** 116 + * Make POST request with raw binary body (for blob uploads). 117 + * Only works in authenticated mode. 118 + */ 119 + public function postBlob(string|BackedEnum $endpoint, string $data, string $mimeType): Response 120 + { 121 + if ($this->isPublicMode()) { 122 + throw new \RuntimeException('Blob uploads require authentication.'); 123 + } 124 + 125 + return $this->authenticatedPostBlob($endpoint, $data, $mimeType); 28 126 } 29 127 }
+18 -1
src/Client/OzoneClient.php
··· 4 4 5 5 use SocialDept\AtpClient\AtpClient; 6 6 use SocialDept\AtpClient\Client\Requests\Ozone; 7 + use SocialDept\AtpClient\Concerns\HasDomainExtensions; 7 8 8 9 class OzoneClient 9 10 { 11 + use HasDomainExtensions; 10 12 /** 11 13 * The parent AtpClient instance 12 14 */ 13 - public AtpClient $atp; 15 + protected AtpClient $atp; 14 16 15 17 /** 16 18 * Moderation operations (tools.ozone.moderation.*) ··· 33 35 $this->moderation = new Ozone\ModerationRequestClient($this); 34 36 $this->server = new Ozone\ServerRequestClient($this); 35 37 $this->team = new Ozone\TeamRequestClient($this); 38 + } 39 + 40 + protected function getDomainName(): string 41 + { 42 + return 'ozone'; 43 + } 44 + 45 + protected function getRootClientClass(): string 46 + { 47 + return AtpClient::class; 48 + } 49 + 50 + public function root(): AtpClient 51 + { 52 + return $this->atp; 36 53 } 37 54 }
+33 -30
src/Client/Records/FollowRecordClient.php
··· 3 3 namespace SocialDept\AtpClient\Client\Records; 4 4 5 5 use DateTimeInterface; 6 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 7 use SocialDept\AtpClient\Client\Requests\Request; 7 - use SocialDept\AtpClient\Data\StrongRef; 8 + use SocialDept\AtpClient\Data\Record; 9 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse; 10 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse; 11 + use SocialDept\AtpClient\Enums\Nsid\BskyGraph; 12 + use SocialDept\AtpClient\Enums\Scope; 8 13 9 14 class FollowRecordClient extends Request 10 15 { 11 16 /** 12 17 * Follow a user 18 + * 19 + * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.graph.follow?action=create) 13 20 */ 21 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 22 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=create')] 14 23 public function create( 15 24 string $subject, 16 25 ?DateTimeInterface $createdAt = null 17 - ): StrongRef { 26 + ): CreateRecordResponse { 18 27 $record = [ 19 - '$type' => 'app.bsky.graph.follow', 28 + '$type' => BskyGraph::Follow->value, 20 29 'subject' => $subject, // DID 21 30 'createdAt' => ($createdAt ?? now())->format('c'), 22 31 ]; 23 32 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 - ] 33 + return $this->atp->atproto->repo->createRecord( 34 + collection: BskyGraph::Follow, 35 + record: $record 31 36 ); 32 - 33 - return StrongRef::fromResponse($response->json()); 34 37 } 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 */ 39 - public function delete(string $rkey): void 44 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 45 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=delete')] 46 + public function delete(string $rkey): DeleteRecordResponse 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 + return $this->atp->atproto->repo->deleteRecord( 49 + collection: BskyGraph::Follow, 50 + rkey: $rkey 48 51 ); 49 52 } 50 53 51 54 /** 52 55 * Get a follow record 56 + * 57 + * @requires transition:generic (rpc:com.atproto.repo.getRecord) 53 58 */ 54 - public function get(string $rkey, ?string $cid = null): array 59 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 60 + public function get(string $rkey, ?string $cid = null): Record 55 61 { 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 - ]) 62 + $response = $this->atp->atproto->repo->getRecord( 63 + repo: $this->atp->client->session()->did(), 64 + collection: BskyGraph::Follow, 65 + rkey: $rkey, 66 + cid: $cid 64 67 ); 65 68 66 - return $response->json('value'); 69 + return Record::fromArrayRaw($response->toArray()); 67 70 } 68 71 }
+33 -29
src/Client/Records/LikeRecordClient.php
··· 3 3 namespace SocialDept\AtpClient\Client\Records; 4 4 5 5 use DateTimeInterface; 6 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 7 use SocialDept\AtpClient\Client\Requests\Request; 8 + use SocialDept\AtpClient\Data\Record; 9 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse; 10 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse; 7 11 use SocialDept\AtpClient\Data\StrongRef; 12 + use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 13 + use SocialDept\AtpClient\Enums\Scope; 8 14 9 15 class LikeRecordClient extends Request 10 16 { 11 17 /** 12 18 * Like a post 19 + * 20 + * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.like?action=create) 13 21 */ 22 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 23 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=create')] 14 24 public function create( 15 25 StrongRef $subject, 16 26 ?DateTimeInterface $createdAt = null 17 - ): StrongRef { 27 + ): CreateRecordResponse { 18 28 $record = [ 19 - '$type' => 'app.bsky.feed.like', 29 + '$type' => BskyFeed::Like->value, 20 30 'subject' => $subject->toArray(), 21 31 'createdAt' => ($createdAt ?? now())->format('c'), 22 32 ]; 23 33 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 - ] 34 + return $this->atp->atproto->repo->createRecord( 35 + collection: BskyFeed::Like, 36 + record: $record 31 37 ); 32 - 33 - return StrongRef::fromResponse($response->json()); 34 38 } 35 39 36 40 /** 37 41 * Unlike a post (delete like record) 42 + * 43 + * @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.feed.like?action=delete) 38 44 */ 39 - public function delete(string $rkey): void 45 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 46 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=delete')] 47 + public function delete(string $rkey): DeleteRecordResponse 40 48 { 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 - ] 49 + return $this->atp->atproto->repo->deleteRecord( 50 + collection: BskyFeed::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 */ 54 - public function get(string $rkey, ?string $cid = null): array 60 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 61 + public function get(string $rkey, ?string $cid = null): Record 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: BskyFeed::Like, 66 + rkey: $rkey, 67 + cid: $cid 64 68 ); 65 69 66 - return $response->json('value'); 70 + return Record::fromArrayRaw($response->toArray()); 67 71 } 68 72 }
+66 -163
src/Client/Records/PostRecordClient.php
··· 3 3 namespace SocialDept\AtpClient\Client\Records; 4 4 5 5 use DateTimeInterface; 6 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 + use SocialDept\AtpClient\Builders\PostBuilder; 6 8 use SocialDept\AtpClient\Client\Requests\Request; 7 9 use SocialDept\AtpClient\Contracts\Recordable; 10 + use SocialDept\AtpClient\Data\Record; 11 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse; 12 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse; 13 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse; 8 14 use SocialDept\AtpClient\Data\StrongRef; 9 - use SocialDept\AtpClient\Http\Response; 15 + use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 16 + use SocialDept\AtpClient\Enums\Scope; 10 17 use SocialDept\AtpClient\RichText\TextBuilder; 18 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView; 11 19 12 20 class PostRecordClient extends Request 13 21 { 14 22 /** 23 + * Create a new post builder bound to this client 24 + */ 25 + public function build(): PostBuilder 26 + { 27 + return PostBuilder::make()->for($this); 28 + } 29 + 30 + /** 15 31 * Create a post 32 + * 33 + * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) 16 34 */ 35 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 36 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')] 17 37 public function create( 18 38 string|array|Recordable $content, 19 39 ?array $facets = null, ··· 21 41 ?array $reply = null, 22 42 ?array $langs = null, 23 43 ?DateTimeInterface $createdAt = null 24 - ): StrongRef { 44 + ): CreateRecordResponse { 25 45 // Handle different input types 26 46 if (is_string($content)) { 27 47 $record = [ ··· 34 54 $record = $content; 35 55 } 36 56 37 - // Add optional fields 38 - if ($embed) { 39 - $record['embed'] = $embed; 40 - } 41 - if ($reply) { 42 - $record['reply'] = $reply; 57 + // Add optional fields (only for non-Recordable inputs) 58 + if (! ($content instanceof Recordable)) { 59 + if ($embed) { 60 + $record['embed'] = $embed; 61 + } 62 + if ($reply) { 63 + $record['reply'] = $reply; 64 + } 65 + if ($langs) { 66 + $record['langs'] = $langs; 67 + } 43 68 } 44 - if ($langs) { 45 - $record['langs'] = $langs; 46 - } 69 + 47 70 if (! isset($record['createdAt'])) { 48 71 $record['createdAt'] = ($createdAt ?? now())->format('c'); 49 72 } 50 73 51 74 // Ensure $type is set 52 75 if (! isset($record['$type'])) { 53 - $record['$type'] = 'app.bsky.feed.post'; 76 + $record['$type'] = BskyFeed::Post->value; 54 77 } 55 78 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 - ] 79 + return $this->atp->atproto->repo->createRecord( 80 + collection: BskyFeed::Post, 81 + record: $record 64 82 ); 65 - 66 - return StrongRef::fromResponse($response->json()); 67 83 } 68 84 69 85 /** 70 86 * Update a post 87 + * 88 + * @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.feed.post?action=update) 71 89 */ 72 - public function update(string $rkey, array $record): StrongRef 90 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 91 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=update')] 92 + public function update(string $rkey, array $record): PutRecordResponse 73 93 { 74 94 // Ensure $type is set 75 95 if (! isset($record['$type'])) { 76 - $record['$type'] = 'app.bsky.feed.post'; 96 + $record['$type'] = BskyFeed::Post->value; 77 97 } 78 98 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 - ] 99 + return $this->atp->atproto->repo->putRecord( 100 + collection: BskyFeed::Post, 101 + rkey: $rkey, 102 + record: $record 87 103 ); 88 - 89 - return StrongRef::fromResponse($response->json()); 90 104 } 91 105 92 106 /** 93 107 * Delete a post 108 + * 109 + * @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.feed.post?action=delete) 94 110 */ 95 - public function delete(string $rkey): void 111 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 112 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=delete')] 113 + public function delete(string $rkey): DeleteRecordResponse 96 114 { 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 - ] 115 + return $this->atp->atproto->repo->deleteRecord( 116 + collection: BskyFeed::Post, 117 + rkey: $rkey 104 118 ); 105 119 } 106 120 107 121 /** 108 122 * Get a post 123 + * 124 + * @requires transition:generic (rpc:com.atproto.repo.getRecord) 109 125 */ 110 - public function get(string $rkey, ?string $cid = null): array 126 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 127 + public function get(string $rkey, ?string $cid = null): Record 111 128 { 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 - ]) 129 + $response = $this->atp->atproto->repo->getRecord( 130 + repo: $this->atp->client->session()->did(), 131 + collection: BskyFeed::Post, 132 + rkey: $rkey, 133 + cid: $cid 120 134 ); 121 135 122 - return $response->json('value'); 123 - } 124 - 125 - /** 126 - * Create a reply to another post 127 - */ 128 - public function reply( 129 - StrongRef $parent, 130 - StrongRef $root, 131 - string|array|Recordable $content, 132 - ?array $facets = null, 133 - ?array $embed = null, 134 - ?array $langs = null, 135 - ?DateTimeInterface $createdAt = null 136 - ): StrongRef { 137 - $reply = [ 138 - 'parent' => $parent->toArray(), 139 - 'root' => $root->toArray(), 140 - ]; 141 - 142 - return $this->create( 143 - content: $content, 144 - facets: $facets, 145 - embed: $embed, 146 - reply: $reply, 147 - langs: $langs, 148 - createdAt: $createdAt 149 - ); 150 - } 151 - 152 - /** 153 - * Create a quote post (post with embedded post) 154 - */ 155 - public function quote( 156 - StrongRef $quotedPost, 157 - string|array|Recordable $content, 158 - ?array $facets = null, 159 - ?array $langs = null, 160 - ?DateTimeInterface $createdAt = null 161 - ): StrongRef { 162 - $embed = [ 163 - '$type' => 'app.bsky.embed.record', 164 - 'record' => $quotedPost->toArray(), 165 - ]; 166 - 167 - return $this->create( 168 - content: $content, 169 - facets: $facets, 170 - embed: $embed, 171 - langs: $langs, 172 - createdAt: $createdAt 173 - ); 174 - } 175 - 176 - /** 177 - * Create a post with images 178 - */ 179 - public function withImages( 180 - string|array|Recordable $content, 181 - array $images, 182 - ?array $facets = null, 183 - ?array $langs = null, 184 - ?DateTimeInterface $createdAt = null 185 - ): StrongRef { 186 - $embed = [ 187 - '$type' => 'app.bsky.embed.images', 188 - 'images' => $images, 189 - ]; 190 - 191 - return $this->create( 192 - content: $content, 193 - facets: $facets, 194 - embed: $embed, 195 - langs: $langs, 196 - createdAt: $createdAt 197 - ); 136 + return Record::fromArrayRaw($response->toArray()); 198 137 } 199 138 200 - /** 201 - * Create a post with external link embed 202 - */ 203 - public function withLink( 204 - string|array|Recordable $content, 205 - string $uri, 206 - string $title, 207 - string $description, 208 - ?string $thumbBlob = null, 209 - ?array $facets = null, 210 - ?array $langs = null, 211 - ?DateTimeInterface $createdAt = null 212 - ): StrongRef { 213 - $external = [ 214 - 'uri' => $uri, 215 - 'title' => $title, 216 - 'description' => $description, 217 - ]; 218 - 219 - if ($thumbBlob) { 220 - $external['thumb'] = $thumbBlob; 221 - } 222 - 223 - $embed = [ 224 - '$type' => 'app.bsky.embed.external', 225 - 'external' => $external, 226 - ]; 227 - 228 - return $this->create( 229 - content: $content, 230 - facets: $facets, 231 - embed: $embed, 232 - langs: $langs, 233 - createdAt: $createdAt 234 - ); 235 - } 236 139 }
+46 -28
src/Client/Records/ProfileRecordClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Records; 4 4 5 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Data\StrongRef; 7 + use SocialDept\AtpClient\Data\Record; 8 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse; 9 + use SocialDept\AtpClient\Enums\Nsid\BskyActor; 10 + use SocialDept\AtpClient\Enums\Scope; 7 11 8 12 class ProfileRecordClient extends Request 9 13 { 10 14 /** 11 15 * Update profile 16 + * 17 + * @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update) 12 18 */ 13 - public function update(array $profile): StrongRef 19 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 20 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 21 + public function update(array $profile): PutRecordResponse 14 22 { 15 23 // Ensure $type is set 16 24 if (! isset($profile['$type'])) { 17 - $profile['$type'] = 'app.bsky.actor.profile'; 25 + $profile['$type'] = BskyActor::Profile->value; 18 26 } 19 27 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 - ] 28 + return $this->atp->atproto->repo->putRecord( 29 + collection: BskyActor::Profile, 30 + rkey: 'self', // Profile records always use 'self' as rkey 31 + record: $profile 28 32 ); 29 - 30 - return StrongRef::fromResponse($response->json()); 31 33 } 32 34 33 35 /** 34 36 * Get current profile 37 + * 38 + * @requires transition:generic (rpc:com.atproto.repo.getRecord) 35 39 */ 36 - public function get(): array 40 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 41 + public function get(): Record 37 42 { 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 - ] 43 + $response = $this->atp->atproto->repo->getRecord( 44 + repo: $this->atp->client->session()->did(), 45 + collection: BskyActor::Profile, 46 + rkey: 'self' 45 47 ); 46 48 47 - return $response->json('value'); 49 + return Record::fromArrayRaw($response->toArray()); 48 50 } 49 51 50 52 /** 51 53 * Update display name 54 + * 55 + * @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update) 52 56 */ 53 - public function updateDisplayName(string $displayName): StrongRef 57 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 58 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 59 + public function updateDisplayName(string $displayName): PutRecordResponse 54 60 { 55 61 $profile = $this->getOrCreateProfile(); 56 62 $profile['displayName'] = $displayName; ··· 60 66 61 67 /** 62 68 * Update description/bio 69 + * 70 + * @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update) 63 71 */ 64 - public function updateDescription(string $description): StrongRef 72 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 73 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 74 + public function updateDescription(string $description): PutRecordResponse 65 75 { 66 76 $profile = $this->getOrCreateProfile(); 67 77 $profile['description'] = $description; ··· 71 81 72 82 /** 73 83 * Update avatar 84 + * 85 + * @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update) 74 86 */ 75 - public function updateAvatar(array $avatarBlob): StrongRef 87 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 88 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 89 + public function updateAvatar(array $avatarBlob): PutRecordResponse 76 90 { 77 91 $profile = $this->getOrCreateProfile(); 78 92 $profile['avatar'] = $avatarBlob; ··· 82 96 83 97 /** 84 98 * Update banner 99 + * 100 + * @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update) 85 101 */ 86 - public function updateBanner(array $bannerBlob): StrongRef 102 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 103 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 104 + public function updateBanner(array $bannerBlob): PutRecordResponse 87 105 { 88 106 $profile = $this->getOrCreateProfile(); 89 107 $profile['banner'] = $bannerBlob; ··· 97 115 protected function getOrCreateProfile(): array 98 116 { 99 117 try { 100 - return $this->get(); 118 + return $this->get()->value; 101 119 } catch (\Exception $e) { 102 120 // Profile doesn't exist, return empty structure 103 121 return [ 104 - '$type' => 'app.bsky.actor.profile', 122 + '$type' => BskyActor::Profile->value, 105 123 ]; 106 124 } 107 125 }
+20 -7
src/Client/Requests/Atproto/IdentityRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Atproto; 4 4 5 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 7 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Http\Response; 8 + use SocialDept\AtpClient\Data\Responses\Atproto\Identity\ResolveHandleResponse; 9 + use SocialDept\AtpClient\Data\Responses\EmptyResponse; 10 + use SocialDept\AtpClient\Enums\Nsid\AtprotoIdentity; 11 + use SocialDept\AtpClient\Enums\Scope; 7 12 8 13 class IdentityRequestClient extends Request 9 14 { ··· 12 17 * 13 18 * @see https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle 14 19 */ 15 - public function resolveHandle(string $handle): Response 20 + #[PublicEndpoint] 21 + public function resolveHandle(string $handle): ResolveHandleResponse 16 22 { 17 - return $this->atp->client->get( 18 - endpoint: 'com.atproto.identity.resolveHandle', 23 + $response = $this->atp->client->get( 24 + endpoint: AtprotoIdentity::ResolveHandle, 19 25 params: compact('handle') 20 26 ); 27 + 28 + return ResolveHandleResponse::fromArray($response->json()); 21 29 } 22 30 23 31 /** 24 32 * Update handle 33 + * 34 + * @requires atproto (identity:handle) 25 35 * 26 36 * @see https://docs.bsky.app/docs/api/com-atproto-identity-update-handle 27 37 */ 28 - public function updateHandle(string $handle): Response 38 + #[ScopedEndpoint(Scope::Atproto, granular: 'identity:handle')] 39 + public function updateHandle(string $handle): EmptyResponse 29 40 { 30 - return $this->atp->client->post( 31 - endpoint: 'com.atproto.identity.updateHandle', 41 + $this->atp->client->post( 42 + endpoint: AtprotoIdentity::UpdateHandle, 32 43 body: compact('handle') 33 44 ); 45 + 46 + return new EmptyResponse; 34 47 } 35 48 }
+102 -30
src/Client/Requests/Atproto/RepoRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Atproto; 4 4 5 + use BackedEnum; 5 6 use Illuminate\Http\UploadedFile; 6 7 use InvalidArgumentException; 8 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 9 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 10 + use SocialDept\AtpClient\Auth\ScopeChecker; 7 11 use SocialDept\AtpClient\Client\Requests\Request; 8 - use SocialDept\AtpClient\Http\Response; 12 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse; 13 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse; 14 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DescribeRepoResponse; 15 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse; 16 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\ListRecordsResponse; 17 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse; 18 + use SocialDept\AtpClient\Enums\Nsid\AtprotoRepo; 19 + use SocialDept\AtpClient\Enums\Scope; 20 + use SocialDept\AtpSchema\Data\BlobReference; 9 21 use SplFileInfo; 10 22 use Throwable; 11 23 ··· 14 26 /** 15 27 * Create a record 16 28 * 29 + * @requires transition:generic OR repo:[collection]?action=create 30 + * 17 31 * @see https://docs.bsky.app/docs/api/com-atproto-repo-create-record 18 32 */ 33 + #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Create records in repository')] 19 34 public function createRecord( 20 - string $repo, 21 - string $collection, 35 + string|BackedEnum $collection, 22 36 array $record, 23 37 ?string $rkey = null, 24 38 bool $validate = true, 25 39 ?string $swapCommit = null 26 - ): Response { 27 - return $this->atp->client->post( 28 - endpoint: 'com.atproto.repo.createRecord', 40 + ): CreateRecordResponse { 41 + $repo = $this->atp->client->session()->did(); 42 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 43 + $this->checkCollectionScope($collection, 'create'); 44 + 45 + $response = $this->atp->client->post( 46 + endpoint: AtprotoRepo::CreateRecord, 29 47 body: array_filter( 30 48 compact('repo', 'collection', 'record', 'rkey', 'validate', 'swapCommit'), 31 49 fn ($v) => ! is_null($v) 32 50 ) 33 51 ); 52 + 53 + return CreateRecordResponse::fromArray($response->json()); 34 54 } 35 55 36 56 /** 37 57 * Delete a record 38 58 * 59 + * @requires transition:generic OR repo:[collection]?action=delete 60 + * 39 61 * @see https://docs.bsky.app/docs/api/com-atproto-repo-delete-record 40 62 */ 63 + #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Delete records from repository')] 41 64 public function deleteRecord( 42 - string $repo, 43 - string $collection, 65 + string|BackedEnum $collection, 44 66 string $rkey, 45 67 ?string $swapRecord = null, 46 68 ?string $swapCommit = null 47 - ): Response { 48 - return $this->atp->client->post( 49 - endpoint: 'com.atproto.repo.deleteRecord', 69 + ): DeleteRecordResponse { 70 + $repo = $this->atp->client->session()->did(); 71 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 72 + $this->checkCollectionScope($collection, 'delete'); 73 + 74 + $response = $this->atp->client->post( 75 + endpoint: AtprotoRepo::DeleteRecord, 50 76 body: array_filter( 51 77 compact('repo', 'collection', 'rkey', 'swapRecord', 'swapCommit'), 52 78 fn ($v) => ! is_null($v) 53 79 ) 54 80 ); 81 + 82 + return DeleteRecordResponse::fromArray($response->json()); 55 83 } 56 84 57 85 /** 58 86 * Put (upsert) a record 59 87 * 88 + * @requires transition:generic OR repo:[collection]?action=update 89 + * 60 90 * @see https://docs.bsky.app/docs/api/com-atproto-repo-put-record 61 91 */ 92 + #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Update records in repository')] 62 93 public function putRecord( 63 - string $repo, 64 - string $collection, 94 + string|BackedEnum $collection, 65 95 string $rkey, 66 96 array $record, 67 97 bool $validate = true, 68 98 ?string $swapRecord = null, 69 99 ?string $swapCommit = null 70 - ): Response { 71 - return $this->atp->client->post( 72 - endpoint: 'com.atproto.repo.putRecord', 100 + ): PutRecordResponse { 101 + $repo = $this->atp->client->session()->did(); 102 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 103 + $this->checkCollectionScope($collection, 'update'); 104 + 105 + $response = $this->atp->client->post( 106 + endpoint: AtprotoRepo::PutRecord, 73 107 body: array_filter( 74 108 compact('repo', 'collection', 'rkey', 'record', 'validate', 'swapRecord', 'swapCommit'), 75 109 fn ($v) => ! is_null($v) 76 110 ) 77 111 ); 112 + 113 + return PutRecordResponse::fromArray($response->json()); 78 114 } 79 115 80 116 /** ··· 82 118 * 83 119 * @see https://docs.bsky.app/docs/api/com-atproto-repo-get-record 84 120 */ 121 + #[PublicEndpoint] 85 122 public function getRecord( 86 123 string $repo, 87 - string $collection, 124 + string|BackedEnum $collection, 88 125 string $rkey, 89 126 ?string $cid = null 90 - ): Response { 91 - return $this->atp->client->get( 92 - endpoint: 'com.atproto.repo.getRecord', 127 + ): GetRecordResponse { 128 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 129 + $response = $this->atp->client->get( 130 + endpoint: AtprotoRepo::GetRecord, 93 131 params: compact('repo', 'collection', 'rkey', 'cid') 94 132 ); 133 + 134 + return GetRecordResponse::fromArray($response->json()); 95 135 } 96 136 97 137 /** ··· 99 139 * 100 140 * @see https://docs.bsky.app/docs/api/com-atproto-repo-list-records 101 141 */ 142 + #[PublicEndpoint] 102 143 public function listRecords( 103 144 string $repo, 104 - string $collection, 145 + string|BackedEnum $collection, 105 146 int $limit = 50, 106 147 ?string $cursor = null, 107 148 bool $reverse = false 108 - ): Response { 109 - return $this->atp->client->get( 110 - endpoint: 'com.atproto.repo.listRecords', 149 + ): ListRecordsResponse { 150 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 151 + $response = $this->atp->client->get( 152 + endpoint: AtprotoRepo::ListRecords, 111 153 params: compact('repo', 'collection', 'limit', 'cursor', 'reverse') 112 154 ); 155 + 156 + return ListRecordsResponse::fromArray($response->json()); 113 157 } 114 158 115 159 /** ··· 117 161 * 118 162 * The blob will be deleted if it is not referenced within a time window. 119 163 * 164 + * @requires transition:generic (blob:*\/*\) 165 + * 120 166 * @param UploadedFile|SplFileInfo|string $file The file to upload 121 167 * @param string|null $mimeType MIME type (required for string input, auto-detected for file objects) 122 168 * ··· 124 170 * 125 171 * @see https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob 126 172 */ 127 - public function uploadBlob(UploadedFile|SplFileInfo|string $file, ?string $mimeType = null): Response 173 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'blob:*/*')] 174 + public function uploadBlob(UploadedFile|SplFileInfo|string $file, ?string $mimeType = null): BlobReference 128 175 { 129 176 // Handle different input types 130 177 if ($file instanceof UploadedFile) { ··· 138 185 $data = $file; 139 186 } 140 187 141 - return $this->atp->client->postBlob( 142 - endpoint: 'com.atproto.repo.uploadBlob', 188 + $response = $this->atp->client->postBlob( 189 + endpoint: AtprotoRepo::UploadBlob, 143 190 data: $data, 144 191 mimeType: $mimeType 145 192 ); 193 + 194 + return BlobReference::fromArray($response->json()['blob']); 146 195 } 147 196 148 197 /** ··· 150 199 * 151 200 * @see https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo 152 201 */ 153 - public function describeRepo(string $repo): Response 202 + #[PublicEndpoint] 203 + public function describeRepo(string $repo): DescribeRepoResponse 154 204 { 155 - return $this->atp->client->get( 156 - endpoint: 'com.atproto.repo.describeRepo', 205 + $response = $this->atp->client->get( 206 + endpoint: AtprotoRepo::DescribeRepo, 157 207 params: compact('repo') 158 208 ); 209 + 210 + return DescribeRepoResponse::fromArray($response->json()); 211 + } 212 + 213 + /** 214 + * Check if the session has repo access for a specific collection and action. 215 + * 216 + * This check is in addition to the transition:generic scope check. 217 + * Users need either transition:generic OR the specific repo scope. 218 + */ 219 + protected function checkCollectionScope(string $collection, string $action): void 220 + { 221 + $session = $this->atp->client->session(); 222 + $checker = app(ScopeChecker::class); 223 + 224 + // If user has transition:generic, they have broad access 225 + if ($checker->hasScope($session, Scope::TransitionGeneric)) { 226 + return; 227 + } 228 + 229 + // Otherwise, check for specific repo scope 230 + $checker->checkRepoScopeOrFail($session, $collection, $action); 159 231 } 160 232 }
+20 -7
src/Client/Requests/Atproto/ServerRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Atproto; 4 4 5 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 7 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Http\Response; 8 + use SocialDept\AtpClient\Data\Responses\Atproto\Server\DescribeServerResponse; 9 + use SocialDept\AtpClient\Data\Responses\Atproto\Server\GetSessionResponse; 10 + use SocialDept\AtpClient\Enums\Nsid\AtprotoServer; 11 + use SocialDept\AtpClient\Enums\Scope; 7 12 8 13 class ServerRequestClient extends Request 9 14 { 10 15 /** 11 16 * Get current session 12 17 * 18 + * @requires atproto (rpc:com.atproto.server.getSession) 19 + * 13 20 * @see https://docs.bsky.app/docs/api/com-atproto-server-get-session 14 21 */ 15 - public function getSession(): Response 22 + #[ScopedEndpoint(Scope::Atproto, granular: 'rpc:com.atproto.server.getSession')] 23 + public function getSession(): GetSessionResponse 16 24 { 17 - return $this->atp->client->get( 18 - endpoint: 'com.atproto.server.getSession' 25 + $response = $this->atp->client->get( 26 + endpoint: AtprotoServer::GetSession 19 27 ); 28 + 29 + return GetSessionResponse::fromArray($response->json()); 20 30 } 21 31 22 32 /** ··· 24 34 * 25 35 * @see https://docs.bsky.app/docs/api/com-atproto-server-describe-server 26 36 */ 27 - public function describeServer(): Response 37 + #[PublicEndpoint] 38 + public function describeServer(): DescribeServerResponse 28 39 { 29 - return $this->atp->client->get( 30 - endpoint: 'com.atproto.server.describeServer' 40 + $response = $this->atp->client->get( 41 + endpoint: AtprotoServer::DescribeServer 31 42 ); 43 + 44 + return DescribeServerResponse::fromArray($response->json()); 32 45 } 33 46 }
+64 -17
src/Client/Requests/Atproto/SyncRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Atproto; 4 4 5 + use BackedEnum; 6 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 5 7 use SocialDept\AtpClient\Client\Requests\Request; 8 + use SocialDept\AtpClient\Data\Responses\Atproto\Sync\GetRepoStatusResponse; 9 + use SocialDept\AtpClient\Data\Responses\Atproto\Sync\ListBlobsResponse; 10 + use SocialDept\AtpClient\Data\Responses\Atproto\Sync\ListReposByCollectionResponse; 11 + use SocialDept\AtpClient\Data\Responses\Atproto\Sync\ListReposResponse; 12 + use SocialDept\AtpClient\Enums\Nsid\AtprotoSync; 6 13 use SocialDept\AtpClient\Http\Response; 14 + use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta; 7 15 8 16 class SyncRequestClient extends Request 9 17 { ··· 12 20 * 13 21 * @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blob 14 22 */ 23 + #[PublicEndpoint] 15 24 public function getBlob(string $did, string $cid): Response 16 25 { 17 26 return $this->atp->client->get( 18 - endpoint: 'com.atproto.sync.getBlob', 27 + endpoint: AtprotoSync::GetBlob, 19 28 params: compact('did', 'cid') 20 29 ); 21 30 } ··· 25 34 * 26 35 * @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo 27 36 */ 37 + #[PublicEndpoint] 28 38 public function getRepo(string $did, ?string $since = null): Response 29 39 { 30 40 return $this->atp->client->get( 31 - endpoint: 'com.atproto.sync.getRepo', 41 + endpoint: AtprotoSync::GetRepo, 32 42 params: compact('did', 'since') 33 43 ); 34 44 } ··· 38 48 * 39 49 * @see https://docs.bsky.app/docs/api/com-atproto-sync-list-repos 40 50 */ 41 - public function listRepos(int $limit = 500, ?string $cursor = null): Response 51 + #[PublicEndpoint] 52 + public function listRepos(int $limit = 500, ?string $cursor = null): ListReposResponse 42 53 { 43 - return $this->atp->client->get( 44 - endpoint: 'com.atproto.sync.listRepos', 54 + $response = $this->atp->client->get( 55 + endpoint: AtprotoSync::ListRepos, 45 56 params: compact('limit', 'cursor') 46 57 ); 58 + 59 + return ListReposResponse::fromArray($response->json()); 60 + } 61 + 62 + /** 63 + * Enumerates all the DIDs with records in a specific collection 64 + * 65 + * @see https://docs.bsky.app/docs/api/com-atproto-sync-list-repos-by-collection 66 + */ 67 + #[PublicEndpoint] 68 + public function listReposByCollection( 69 + string|BackedEnum $collection, 70 + int $limit = 500, 71 + ?string $cursor = null 72 + ): ListReposByCollectionResponse { 73 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 74 + 75 + $response = $this->atp->client->get( 76 + endpoint: AtprotoSync::ListReposByCollection, 77 + params: compact('collection', 'limit', 'cursor') 78 + ); 79 + 80 + return ListReposByCollectionResponse::fromArray($response->json()); 47 81 } 48 82 49 83 /** ··· 51 85 * 52 86 * @see https://docs.bsky.app/docs/api/com-atproto-sync-get-latest-commit 53 87 */ 54 - public function getLatestCommit(string $did): Response 88 + #[PublicEndpoint] 89 + public function getLatestCommit(string $did): CommitMeta 55 90 { 56 - return $this->atp->client->get( 57 - endpoint: 'com.atproto.sync.getLatestCommit', 91 + $response = $this->atp->client->get( 92 + endpoint: AtprotoSync::GetLatestCommit, 58 93 params: compact('did') 59 94 ); 95 + 96 + return CommitMeta::fromArray($response->json()); 60 97 } 61 98 62 99 /** ··· 64 101 * 65 102 * @see https://docs.bsky.app/docs/api/com-atproto-sync-get-record 66 103 */ 67 - public function getRecord(string $did, string $collection, string $rkey): Response 104 + #[PublicEndpoint] 105 + public function getRecord(string $did, string|BackedEnum $collection, string $rkey): Response 68 106 { 107 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 108 + 69 109 return $this->atp->client->get( 70 - endpoint: 'com.atproto.sync.getRecord', 110 + endpoint: AtprotoSync::GetRecord, 71 111 params: compact('did', 'collection', 'rkey') 72 112 ); 73 113 } ··· 77 117 * 78 118 * @see https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs 79 119 */ 120 + #[PublicEndpoint] 80 121 public function listBlobs( 81 122 string $did, 82 123 ?string $since = null, 83 124 int $limit = 500, 84 125 ?string $cursor = null 85 - ): Response { 86 - return $this->atp->client->get( 87 - endpoint: 'com.atproto.sync.listBlobs', 126 + ): ListBlobsResponse { 127 + $response = $this->atp->client->get( 128 + endpoint: AtprotoSync::ListBlobs, 88 129 params: compact('did', 'since', 'limit', 'cursor') 89 130 ); 131 + 132 + return ListBlobsResponse::fromArray($response->json()); 90 133 } 91 134 92 135 /** ··· 94 137 * 95 138 * @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blocks 96 139 */ 140 + #[PublicEndpoint] 97 141 public function getBlocks(string $did, array $cids): Response 98 142 { 99 143 return $this->atp->client->get( 100 - endpoint: 'com.atproto.sync.getBlocks', 144 + endpoint: AtprotoSync::GetBlocks, 101 145 params: compact('did', 'cids') 102 146 ); 103 147 } ··· 107 151 * 108 152 * @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status 109 153 */ 110 - public function getRepoStatus(string $did): Response 154 + #[PublicEndpoint] 155 + public function getRepoStatus(string $did): GetRepoStatusResponse 111 156 { 112 - return $this->atp->client->get( 113 - endpoint: 'com.atproto.sync.getRepoStatus', 157 + $response = $this->atp->client->get( 158 + endpoint: AtprotoSync::GetRepoStatus, 114 159 params: compact('did') 115 160 ); 161 + 162 + return GetRepoStatusResponse::fromArray($response->json()); 116 163 } 117 164 }
+77 -4
src/Client/Requests/Bsky/ActorRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Bsky; 4 4 5 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Http\Response; 7 + use SocialDept\AtpClient\Data\Responses\Bsky\Actor\GetProfilesResponse; 8 + use SocialDept\AtpClient\Data\Responses\Bsky\Actor\GetSuggestionsResponse; 9 + use SocialDept\AtpClient\Data\Responses\Bsky\Actor\SearchActorsResponse; 10 + use SocialDept\AtpClient\Data\Responses\Bsky\Actor\SearchActorsTypeaheadResponse; 11 + use SocialDept\AtpClient\Enums\Nsid\BskyActor; 12 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileViewDetailed; 7 13 8 14 class ActorRequestClient extends Request 9 15 { ··· 12 18 * 13 19 * @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profile 14 20 */ 15 - public function getProfile(string $actor): Response 21 + #[PublicEndpoint] 22 + public function getProfile(string $actor): ProfileViewDetailed 16 23 { 17 - return $this->atp->client->get( 18 - endpoint: 'app.bsky.actor.getProfile', 24 + $response = $this->atp->client->get( 25 + endpoint: BskyActor::GetProfile, 19 26 params: compact('actor') 20 27 ); 28 + 29 + return ProfileViewDetailed::fromArray($response->toArray()); 30 + } 31 + 32 + /** 33 + * Get multiple actor profiles 34 + * 35 + * @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profiles 36 + */ 37 + #[PublicEndpoint] 38 + public function getProfiles(array $actors): GetProfilesResponse 39 + { 40 + $response = $this->atp->client->get( 41 + endpoint: BskyActor::GetProfiles, 42 + params: compact('actors') 43 + ); 44 + 45 + return GetProfilesResponse::fromArray($response->json()); 46 + } 47 + 48 + /** 49 + * Get suggestions for actors to follow 50 + * 51 + * @see https://docs.bsky.app/docs/api/app-bsky-actor-get-suggestions 52 + */ 53 + #[PublicEndpoint] 54 + public function getSuggestions(int $limit = 50, ?string $cursor = null): GetSuggestionsResponse 55 + { 56 + $response = $this->atp->client->get( 57 + endpoint: BskyActor::GetSuggestions, 58 + params: compact('limit', 'cursor') 59 + ); 60 + 61 + return GetSuggestionsResponse::fromArray($response->json()); 62 + } 63 + 64 + /** 65 + * Search for actors 66 + * 67 + * @see https://docs.bsky.app/docs/api/app-bsky-actor-search-actors 68 + */ 69 + #[PublicEndpoint] 70 + public function searchActors(string $q, int $limit = 25, ?string $cursor = null): SearchActorsResponse 71 + { 72 + $response = $this->atp->client->get( 73 + endpoint: BskyActor::SearchActors, 74 + params: compact('q', 'limit', 'cursor') 75 + ); 76 + 77 + return SearchActorsResponse::fromArray($response->json()); 78 + } 79 + 80 + /** 81 + * Search for actors matching a prefix (typeahead/autocomplete) 82 + * 83 + * @see https://docs.bsky.app/docs/api/app-bsky-actor-search-actors-typeahead 84 + */ 85 + #[PublicEndpoint] 86 + public function searchActorsTypeahead(string $q, int $limit = 10): SearchActorsTypeaheadResponse 87 + { 88 + $response = $this->atp->client->get( 89 + endpoint: BskyActor::SearchActorsTypeahead, 90 + params: compact('q', 'limit') 91 + ); 92 + 93 + return SearchActorsTypeaheadResponse::fromArray($response->json()); 21 94 } 22 95 }
+220 -33
src/Client/Requests/Bsky/FeedRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Bsky; 4 4 5 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 7 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Http\Response; 8 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\DescribeFeedGeneratorResponse; 9 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetActorFeedsResponse; 10 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetActorLikesResponse; 11 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetAuthorFeedResponse; 12 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetFeedGeneratorResponse; 13 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetFeedGeneratorsResponse; 14 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetFeedResponse; 15 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetLikesResponse; 16 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetPostsResponse; 17 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetPostThreadResponse; 18 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetQuotesResponse; 19 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetRepostedByResponse; 20 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetSuggestedFeedsResponse; 21 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetTimelineResponse; 22 + use SocialDept\AtpClient\Data\Responses\Bsky\Feed\SearchPostsResponse; 23 + use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 24 + use SocialDept\AtpClient\Enums\Scope; 7 25 8 26 class FeedRequestClient extends Request 9 27 { 10 28 /** 11 - * Get timeline feed 29 + * Describe feed generator 30 + * 31 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-describe-feed-generator 32 + */ 33 + #[PublicEndpoint] 34 + public function describeFeedGenerator(): DescribeFeedGeneratorResponse 35 + { 36 + $response = $this->atp->client->get( 37 + endpoint: BskyFeed::DescribeFeedGenerator 38 + ); 39 + 40 + return DescribeFeedGeneratorResponse::fromArray($response->json()); 41 + } 42 + 43 + /** 44 + * Get timeline feed (requires authentication) 12 45 * 13 46 * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-timeline 14 47 */ 15 - public function getTimeline(int $limit = 50, ?string $cursor = null): Response 48 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')] 49 + public function getTimeline(int $limit = 50, ?string $cursor = null): GetTimelineResponse 16 50 { 17 - return $this->atp->client->get( 18 - endpoint: 'app.bsky.feed.getTimeline', 51 + $response = $this->atp->client->get( 52 + endpoint: BskyFeed::GetTimeline, 19 53 params: compact('limit', 'cursor') 20 54 ); 55 + 56 + return GetTimelineResponse::fromArray($response->json()); 21 57 } 22 58 23 59 /** ··· 25 61 * 26 62 * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed 27 63 */ 64 + #[PublicEndpoint] 28 65 public function getAuthorFeed( 29 66 string $actor, 30 67 int $limit = 50, 31 - ?string $cursor = null 32 - ): Response { 33 - return $this->atp->client->get( 34 - endpoint: 'app.bsky.feed.getAuthorFeed', 68 + ?string $cursor = null, 69 + ?string $filter = null 70 + ): GetAuthorFeedResponse { 71 + $response = $this->atp->client->get( 72 + endpoint: BskyFeed::GetAuthorFeed, 73 + params: compact('actor', 'limit', 'cursor', 'filter') 74 + ); 75 + 76 + return GetAuthorFeedResponse::fromArray($response->json()); 77 + } 78 + 79 + /** 80 + * Get feeds created by an actor 81 + * 82 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-feeds 83 + */ 84 + #[PublicEndpoint] 85 + public function getActorFeeds(string $actor, int $limit = 50, ?string $cursor = null): GetActorFeedsResponse 86 + { 87 + $response = $this->atp->client->get( 88 + endpoint: BskyFeed::GetActorFeeds, 35 89 params: compact('actor', 'limit', 'cursor') 36 90 ); 91 + 92 + return GetActorFeedsResponse::fromArray($response->json()); 93 + } 94 + 95 + /** 96 + * Get posts liked by an actor 97 + * 98 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-likes 99 + */ 100 + #[PublicEndpoint] 101 + public function getActorLikes(string $actor, int $limit = 50, ?string $cursor = null): GetActorLikesResponse 102 + { 103 + $response = $this->atp->client->get( 104 + endpoint: BskyFeed::GetActorLikes, 105 + params: compact('actor', 'limit', 'cursor') 106 + ); 107 + 108 + return GetActorLikesResponse::fromArray($response->json()); 109 + } 110 + 111 + /** 112 + * Get a feed 113 + * 114 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-feed 115 + */ 116 + #[PublicEndpoint] 117 + public function getFeed(string $feed, int $limit = 50, ?string $cursor = null): GetFeedResponse 118 + { 119 + $response = $this->atp->client->get( 120 + endpoint: BskyFeed::GetFeed, 121 + params: compact('feed', 'limit', 'cursor') 122 + ); 123 + 124 + return GetFeedResponse::fromArray($response->json()); 125 + } 126 + 127 + /** 128 + * Get a feed generator 129 + * 130 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-feed-generator 131 + */ 132 + #[PublicEndpoint] 133 + public function getFeedGenerator(string $feed): GetFeedGeneratorResponse 134 + { 135 + $response = $this->atp->client->get( 136 + endpoint: BskyFeed::GetFeedGenerator, 137 + params: compact('feed') 138 + ); 139 + 140 + return GetFeedGeneratorResponse::fromArray($response->json()); 141 + } 142 + 143 + /** 144 + * Get multiple feed generators 145 + * 146 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-feed-generators 147 + */ 148 + #[PublicEndpoint] 149 + public function getFeedGenerators(array $feeds): GetFeedGeneratorsResponse 150 + { 151 + $response = $this->atp->client->get( 152 + endpoint: BskyFeed::GetFeedGenerators, 153 + params: compact('feeds') 154 + ); 155 + 156 + return GetFeedGeneratorsResponse::fromArray($response->json()); 37 157 } 38 158 39 159 /** ··· 41 161 * 42 162 * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread 43 163 */ 44 - public function getPostThread(string $uri, int $depth = 6): Response 164 + #[PublicEndpoint] 165 + public function getPostThread(string $uri, int $depth = 6, int $parentHeight = 80): GetPostThreadResponse 45 166 { 46 - return $this->atp->client->get( 47 - endpoint: 'app.bsky.feed.getPostThread', 48 - params: compact('uri', 'depth') 167 + $response = $this->atp->client->get( 168 + endpoint: BskyFeed::GetPostThread, 169 + params: compact('uri', 'depth', 'parentHeight') 49 170 ); 171 + 172 + return GetPostThreadResponse::fromArray($response->json()); 50 173 } 51 174 52 175 /** 53 - * Search posts 176 + * Get multiple posts by URI 54 177 * 55 - * @see https://docs.bsky.app/docs/api/app-bsky-feed-search-posts 178 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-posts 56 179 */ 57 - public function searchPosts( 58 - string $q, 59 - int $limit = 25, 60 - ?string $cursor = null 61 - ): Response { 62 - return $this->atp->client->get( 63 - endpoint: 'app.bsky.feed.searchPosts', 64 - params: compact('q', 'limit', 'cursor') 180 + #[PublicEndpoint] 181 + public function getPosts(array $uris): GetPostsResponse 182 + { 183 + $response = $this->atp->client->get( 184 + endpoint: BskyFeed::GetPosts, 185 + params: compact('uris') 65 186 ); 187 + 188 + return GetPostsResponse::fromArray($response->json()); 66 189 } 67 190 68 191 /** ··· 70 193 * 71 194 * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-likes 72 195 */ 196 + #[PublicEndpoint] 73 197 public function getLikes( 74 198 string $uri, 75 199 int $limit = 50, 76 - ?string $cursor = null 77 - ): Response { 78 - return $this->atp->client->get( 79 - endpoint: 'app.bsky.feed.getLikes', 80 - params: compact('uri', 'limit', 'cursor') 200 + ?string $cursor = null, 201 + ?string $cid = null 202 + ): GetLikesResponse { 203 + $response = $this->atp->client->get( 204 + endpoint: BskyFeed::GetLikes, 205 + params: compact('uri', 'limit', 'cursor', 'cid') 81 206 ); 207 + 208 + return GetLikesResponse::fromArray($response->json()); 209 + } 210 + 211 + /** 212 + * Get quotes of a post 213 + * 214 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-quotes 215 + */ 216 + #[PublicEndpoint] 217 + public function getQuotes( 218 + string $uri, 219 + int $limit = 50, 220 + ?string $cursor = null, 221 + ?string $cid = null 222 + ): GetQuotesResponse { 223 + $response = $this->atp->client->get( 224 + endpoint: BskyFeed::GetQuotes, 225 + params: compact('uri', 'limit', 'cursor', 'cid') 226 + ); 227 + 228 + return GetQuotesResponse::fromArray($response->json()); 82 229 } 83 230 84 231 /** ··· 86 233 * 87 234 * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-reposted-by 88 235 */ 236 + #[PublicEndpoint] 89 237 public function getRepostedBy( 90 238 string $uri, 91 239 int $limit = 50, 92 - ?string $cursor = null 93 - ): Response { 94 - return $this->atp->client->get( 95 - endpoint: 'app.bsky.feed.getRepostedBy', 96 - params: compact('uri', 'limit', 'cursor') 240 + ?string $cursor = null, 241 + ?string $cid = null 242 + ): GetRepostedByResponse { 243 + $response = $this->atp->client->get( 244 + endpoint: BskyFeed::GetRepostedBy, 245 + params: compact('uri', 'limit', 'cursor', 'cid') 246 + ); 247 + 248 + return GetRepostedByResponse::fromArray($response->json()); 249 + } 250 + 251 + /** 252 + * Get suggested feeds 253 + * 254 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-get-suggested-feeds 255 + */ 256 + #[PublicEndpoint] 257 + public function getSuggestedFeeds(int $limit = 50, ?string $cursor = null): GetSuggestedFeedsResponse 258 + { 259 + $response = $this->atp->client->get( 260 + endpoint: BskyFeed::GetSuggestedFeeds, 261 + params: compact('limit', 'cursor') 262 + ); 263 + 264 + return GetSuggestedFeedsResponse::fromArray($response->json()); 265 + } 266 + 267 + /** 268 + * Search posts 269 + * 270 + * @see https://docs.bsky.app/docs/api/app-bsky-feed-search-posts 271 + */ 272 + #[PublicEndpoint] 273 + public function searchPosts( 274 + string $q, 275 + int $limit = 25, 276 + ?string $cursor = null, 277 + ?string $sort = null 278 + ): SearchPostsResponse { 279 + $response = $this->atp->client->get( 280 + endpoint: BskyFeed::SearchPosts, 281 + params: compact('q', 'limit', 'cursor', 'sort') 97 282 ); 283 + 284 + return SearchPostsResponse::fromArray($response->json()); 98 285 } 99 286 }
+163
src/Client/Requests/Bsky/GraphRequestClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client\Requests\Bsky; 4 + 5 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 + use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetFollowersResponse; 8 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetFollowsResponse; 9 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetKnownFollowersResponse; 10 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetListResponse; 11 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetListsResponse; 12 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetRelationshipsResponse; 13 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetStarterPacksResponse; 14 + use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetSuggestedFollowsByActorResponse; 15 + use SocialDept\AtpClient\Enums\Nsid\BskyGraph; 16 + use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\StarterPackView; 17 + 18 + class GraphRequestClient extends Request 19 + { 20 + /** 21 + * Get followers of an actor 22 + * 23 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-followers 24 + */ 25 + #[PublicEndpoint] 26 + public function getFollowers(string $actor, int $limit = 50, ?string $cursor = null): GetFollowersResponse 27 + { 28 + $response = $this->atp->client->get( 29 + endpoint: BskyGraph::GetFollowers, 30 + params: compact('actor', 'limit', 'cursor') 31 + ); 32 + 33 + return GetFollowersResponse::fromArray($response->json()); 34 + } 35 + 36 + /** 37 + * Get accounts that an actor follows 38 + * 39 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-follows 40 + */ 41 + #[PublicEndpoint] 42 + public function getFollows(string $actor, int $limit = 50, ?string $cursor = null): GetFollowsResponse 43 + { 44 + $response = $this->atp->client->get( 45 + endpoint: BskyGraph::GetFollows, 46 + params: compact('actor', 'limit', 'cursor') 47 + ); 48 + 49 + return GetFollowsResponse::fromArray($response->json()); 50 + } 51 + 52 + /** 53 + * Get followers of an actor that you also follow 54 + * 55 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-known-followers 56 + */ 57 + #[PublicEndpoint] 58 + public function getKnownFollowers(string $actor, int $limit = 50, ?string $cursor = null): GetKnownFollowersResponse 59 + { 60 + $response = $this->atp->client->get( 61 + endpoint: BskyGraph::GetKnownFollowers, 62 + params: compact('actor', 'limit', 'cursor') 63 + ); 64 + 65 + return GetKnownFollowersResponse::fromArray($response->json()); 66 + } 67 + 68 + /** 69 + * Get a list by URI 70 + * 71 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-list 72 + */ 73 + #[PublicEndpoint] 74 + public function getList(string $list, int $limit = 50, ?string $cursor = null): GetListResponse 75 + { 76 + $response = $this->atp->client->get( 77 + endpoint: BskyGraph::GetList, 78 + params: compact('list', 'limit', 'cursor') 79 + ); 80 + 81 + return GetListResponse::fromArray($response->json()); 82 + } 83 + 84 + /** 85 + * Get lists created by an actor 86 + * 87 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-lists 88 + */ 89 + #[PublicEndpoint] 90 + public function getLists(string $actor, int $limit = 50, ?string $cursor = null): GetListsResponse 91 + { 92 + $response = $this->atp->client->get( 93 + endpoint: BskyGraph::GetLists, 94 + params: compact('actor', 'limit', 'cursor') 95 + ); 96 + 97 + return GetListsResponse::fromArray($response->json()); 98 + } 99 + 100 + /** 101 + * Get relationships between actors 102 + * 103 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-relationships 104 + */ 105 + #[PublicEndpoint] 106 + public function getRelationships(string $actor, array $others = []): GetRelationshipsResponse 107 + { 108 + $response = $this->atp->client->get( 109 + endpoint: BskyGraph::GetRelationships, 110 + params: compact('actor', 'others') 111 + ); 112 + 113 + return GetRelationshipsResponse::fromArray($response->json()); 114 + } 115 + 116 + /** 117 + * Get a starter pack by URI 118 + * 119 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-starter-pack 120 + */ 121 + #[PublicEndpoint] 122 + public function getStarterPack(string $starterPack): StarterPackView 123 + { 124 + $response = $this->atp->client->get( 125 + endpoint: BskyGraph::GetStarterPack, 126 + params: compact('starterPack') 127 + ); 128 + 129 + return StarterPackView::fromArray($response->json()['starterPack']); 130 + } 131 + 132 + /** 133 + * Get multiple starter packs 134 + * 135 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-starter-packs 136 + */ 137 + #[PublicEndpoint] 138 + public function getStarterPacks(array $uris): GetStarterPacksResponse 139 + { 140 + $response = $this->atp->client->get( 141 + endpoint: BskyGraph::GetStarterPacks, 142 + params: compact('uris') 143 + ); 144 + 145 + return GetStarterPacksResponse::fromArray($response->json()); 146 + } 147 + 148 + /** 149 + * Get suggested follows based on an actor 150 + * 151 + * @see https://docs.bsky.app/docs/api/app-bsky-graph-get-suggested-follows-by-actor 152 + */ 153 + #[PublicEndpoint] 154 + public function getSuggestedFollowsByActor(string $actor): GetSuggestedFollowsByActorResponse 155 + { 156 + $response = $this->atp->client->get( 157 + endpoint: BskyGraph::GetSuggestedFollowsByActor, 158 + params: compact('actor') 159 + ); 160 + 161 + return GetSuggestedFollowsByActorResponse::fromArray($response->json()); 162 + } 163 + }
+27
src/Client/Requests/Bsky/LabelerRequestClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client\Requests\Bsky; 4 + 5 + use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 + use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\Bsky\Labeler\GetServicesResponse; 8 + use SocialDept\AtpClient\Enums\Nsid\BskyLabeler; 9 + 10 + class LabelerRequestClient extends Request 11 + { 12 + /** 13 + * Get labeler services 14 + * 15 + * @see https://docs.bsky.app/docs/api/app-bsky-labeler-get-services 16 + */ 17 + #[PublicEndpoint] 18 + public function getServices(array $dids, bool $detailed = false): GetServicesResponse 19 + { 20 + $response = $this->atp->client->get( 21 + endpoint: BskyLabeler::GetServices, 22 + params: compact('dids', 'detailed') 23 + ); 24 + 25 + return GetServicesResponse::fromArray($response->json(), $detailed); 26 + } 27 + }
+21 -6
src/Client/Requests/Chat/ActorRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Chat; 4 4 5 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\EmptyResponse; 8 + use SocialDept\AtpClient\Enums\Nsid\ChatActor; 9 + use SocialDept\AtpClient\Enums\Scope; 6 10 use SocialDept\AtpClient\Http\Response; 7 11 8 12 class ActorRequestClient extends Request ··· 10 14 /** 11 15 * Get actor metadata 12 16 * 17 + * @requires transition:chat.bsky (rpc:chat.bsky.actor.getActorMetadata) 18 + * 13 19 * @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data 14 20 */ 21 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.getActorMetadata')] 15 22 public function getActorMetadata(): Response 16 23 { 17 24 return $this->atp->client->get( 18 - endpoint: 'chat.bsky.actor.getActorMetadata' 25 + endpoint: ChatActor::GetActorMetadata 19 26 ); 20 27 } 21 28 22 29 /** 23 - * Export account data 30 + * Export account data (returns JSONL stream) 31 + * 32 + * @requires transition:chat.bsky (rpc:chat.bsky.actor.exportAccountData) 24 33 * 25 34 * @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data 26 35 */ 36 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.exportAccountData')] 27 37 public function exportAccountData(): Response 28 38 { 29 39 return $this->atp->client->get( 30 - endpoint: 'chat.bsky.actor.exportAccountData' 40 + endpoint: ChatActor::ExportAccountData 31 41 ); 32 42 } 33 43 34 44 /** 35 45 * Delete account 46 + * 47 + * @requires transition:chat.bsky (rpc:chat.bsky.actor.deleteAccount) 36 48 * 37 49 * @see https://docs.bsky.app/docs/api/chat-bsky-actor-delete-account 38 50 */ 39 - public function deleteAccount(): Response 51 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.deleteAccount')] 52 + public function deleteAccount(): EmptyResponse 40 53 { 41 - return $this->atp->client->post( 42 - endpoint: 'chat.bsky.actor.deleteAccount' 54 + $this->atp->client->post( 55 + endpoint: ChatActor::DeleteAccount 43 56 ); 57 + 58 + return new EmptyResponse; 44 59 } 45 60 }
+107 -37
src/Client/Requests/Chat/ConvoRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Chat; 4 4 5 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Http\Response; 7 + use SocialDept\AtpClient\Data\Responses\Chat\Convo\GetLogResponse; 8 + use SocialDept\AtpClient\Data\Responses\Chat\Convo\GetMessagesResponse; 9 + use SocialDept\AtpClient\Data\Responses\Chat\Convo\LeaveConvoResponse; 10 + use SocialDept\AtpClient\Data\Responses\Chat\Convo\ListConvosResponse; 11 + use SocialDept\AtpClient\Data\Responses\Chat\Convo\SendMessageBatchResponse; 12 + use SocialDept\AtpClient\Enums\Nsid\ChatConvo; 13 + use SocialDept\AtpClient\Enums\Scope; 14 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\ConvoView; 15 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\DeletedMessageView; 16 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\MessageView; 7 17 8 18 class ConvoRequestClient extends Request 9 19 { 10 20 /** 11 21 * Get conversation 22 + * 23 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.getConvo) 12 24 * 13 25 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo 14 26 */ 15 - public function getConvo(string $convoId): Response 27 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getConvo')] 28 + public function getConvo(string $convoId): ConvoView 16 29 { 17 - return $this->atp->client->get( 18 - endpoint: 'chat.bsky.convo.getConvo', 30 + $response = $this->atp->client->get( 31 + endpoint: ChatConvo::GetConvo, 19 32 params: compact('convoId') 20 33 ); 34 + 35 + return ConvoView::fromArray($response->json()['convo']); 21 36 } 22 37 23 38 /** 24 39 * Get conversation for members 25 40 * 41 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.getConvoForMembers) 42 + * 26 43 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo-for-members 27 44 */ 28 - public function getConvoForMembers(array $members): Response 45 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getConvoForMembers')] 46 + public function getConvoForMembers(array $members): ConvoView 29 47 { 30 - return $this->atp->client->get( 31 - endpoint: 'chat.bsky.convo.getConvoForMembers', 48 + $response = $this->atp->client->get( 49 + endpoint: ChatConvo::GetConvoForMembers, 32 50 params: compact('members') 33 51 ); 52 + 53 + return ConvoView::fromArray($response->json()['convo']); 34 54 } 35 55 36 56 /** 37 57 * List conversations 58 + * 59 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.listConvos) 38 60 * 39 61 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-list-convos 40 62 */ 41 - public function listConvos(int $limit = 50, ?string $cursor = null): Response 63 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.listConvos')] 64 + public function listConvos(int $limit = 50, ?string $cursor = null): ListConvosResponse 42 65 { 43 - return $this->atp->client->get( 44 - endpoint: 'chat.bsky.convo.listConvos', 66 + $response = $this->atp->client->get( 67 + endpoint: ChatConvo::ListConvos, 45 68 params: compact('limit', 'cursor') 46 69 ); 70 + 71 + return ListConvosResponse::fromArray($response->json()); 47 72 } 48 73 49 74 /** 50 75 * Get messages 51 76 * 77 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.getMessages) 78 + * 52 79 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-messages 53 80 */ 81 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getMessages')] 54 82 public function getMessages( 55 83 string $convoId, 56 84 int $limit = 50, 57 85 ?string $cursor = null 58 - ): Response { 59 - return $this->atp->client->get( 60 - endpoint: 'chat.bsky.convo.getMessages', 86 + ): GetMessagesResponse { 87 + $response = $this->atp->client->get( 88 + endpoint: ChatConvo::GetMessages, 61 89 params: compact('convoId', 'limit', 'cursor') 62 90 ); 91 + 92 + return GetMessagesResponse::fromArray($response->json()); 63 93 } 64 94 65 95 /** 66 96 * Send message 67 97 * 98 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.sendMessage) 99 + * 68 100 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message 69 101 */ 70 - public function sendMessage(string $convoId, array $message): Response 102 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.sendMessage')] 103 + public function sendMessage(string $convoId, array $message): MessageView 71 104 { 72 - return $this->atp->client->post( 73 - endpoint: 'chat.bsky.convo.sendMessage', 105 + $response = $this->atp->client->post( 106 + endpoint: ChatConvo::SendMessage, 74 107 body: compact('convoId', 'message') 75 108 ); 109 + 110 + return MessageView::fromArray($response->json()); 76 111 } 77 112 78 113 /** 79 114 * Send message batch 80 115 * 116 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.sendMessageBatch) 117 + * 81 118 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message-batch 82 119 */ 83 - public function sendMessageBatch(array $items): Response 120 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.sendMessageBatch')] 121 + public function sendMessageBatch(array $items): SendMessageBatchResponse 84 122 { 85 - return $this->atp->client->post( 86 - endpoint: 'chat.bsky.convo.sendMessageBatch', 123 + $response = $this->atp->client->post( 124 + endpoint: ChatConvo::SendMessageBatch, 87 125 body: compact('items') 88 126 ); 127 + 128 + return SendMessageBatchResponse::fromArray($response->json()); 89 129 } 90 130 91 131 /** 92 132 * Delete message for self 93 133 * 134 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.deleteMessageForSelf) 135 + * 94 136 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-delete-message-for-self 95 137 */ 96 - public function deleteMessageForSelf(string $convoId, string $messageId): Response 138 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.deleteMessageForSelf')] 139 + public function deleteMessageForSelf(string $convoId, string $messageId): DeletedMessageView 97 140 { 98 - return $this->atp->client->post( 99 - endpoint: 'chat.bsky.convo.deleteMessageForSelf', 141 + $response = $this->atp->client->post( 142 + endpoint: ChatConvo::DeleteMessageForSelf, 100 143 body: compact('convoId', 'messageId') 101 144 ); 145 + 146 + return DeletedMessageView::fromArray($response->json()); 102 147 } 103 148 104 149 /** 105 150 * Update read status 106 151 * 152 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.updateRead) 153 + * 107 154 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-update-read 108 155 */ 109 - public function updateRead(string $convoId, ?string $messageId = null): Response 156 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.updateRead')] 157 + public function updateRead(string $convoId, ?string $messageId = null): ConvoView 110 158 { 111 - return $this->atp->client->post( 112 - endpoint: 'chat.bsky.convo.updateRead', 159 + $response = $this->atp->client->post( 160 + endpoint: ChatConvo::UpdateRead, 113 161 body: compact('convoId', 'messageId') 114 162 ); 163 + 164 + return ConvoView::fromArray($response->json()['convo']); 115 165 } 116 166 117 167 /** 118 168 * Mute conversation 169 + * 170 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.muteConvo) 119 171 * 120 172 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-mute-convo 121 173 */ 122 - public function muteConvo(string $convoId): Response 174 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.muteConvo')] 175 + public function muteConvo(string $convoId): ConvoView 123 176 { 124 - return $this->atp->client->post( 125 - endpoint: 'chat.bsky.convo.muteConvo', 177 + $response = $this->atp->client->post( 178 + endpoint: ChatConvo::MuteConvo, 126 179 body: compact('convoId') 127 180 ); 181 + 182 + return ConvoView::fromArray($response->json()['convo']); 128 183 } 129 184 130 185 /** 131 186 * Unmute conversation 187 + * 188 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.unmuteConvo) 132 189 * 133 190 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-unmute-convo 134 191 */ 135 - public function unmuteConvo(string $convoId): Response 192 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.unmuteConvo')] 193 + public function unmuteConvo(string $convoId): ConvoView 136 194 { 137 - return $this->atp->client->post( 138 - endpoint: 'chat.bsky.convo.unmuteConvo', 195 + $response = $this->atp->client->post( 196 + endpoint: ChatConvo::UnmuteConvo, 139 197 body: compact('convoId') 140 198 ); 199 + 200 + return ConvoView::fromArray($response->json()['convo']); 141 201 } 142 202 143 203 /** 144 204 * Leave conversation 145 205 * 206 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.leaveConvo) 207 + * 146 208 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-leave-convo 147 209 */ 148 - public function leaveConvo(string $convoId): Response 210 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.leaveConvo')] 211 + public function leaveConvo(string $convoId): LeaveConvoResponse 149 212 { 150 - return $this->atp->client->post( 151 - endpoint: 'chat.bsky.convo.leaveConvo', 213 + $response = $this->atp->client->post( 214 + endpoint: ChatConvo::LeaveConvo, 152 215 body: compact('convoId') 153 216 ); 217 + 218 + return LeaveConvoResponse::fromArray($response->json()); 154 219 } 155 220 156 221 /** 157 222 * Get log 223 + * 224 + * @requires transition:chat.bsky (rpc:chat.bsky.convo.getLog) 158 225 * 159 226 * @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-log 160 227 */ 161 - public function getLog(?string $cursor = null): Response 228 + #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getLog')] 229 + public function getLog(?string $cursor = null): GetLogResponse 162 230 { 163 - return $this->atp->client->get( 164 - endpoint: 'chat.bsky.convo.getLog', 231 + $response = $this->atp->client->get( 232 + endpoint: ChatConvo::GetLog, 165 233 params: compact('cursor') 166 234 ); 235 + 236 + return GetLogResponse::fromArray($response->json()); 167 237 } 168 238 }
+70 -22
src/Client/Requests/Ozone/ModerationRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Ozone; 4 4 5 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\Ozone\Moderation\QueryEventsResponse; 8 + use SocialDept\AtpClient\Data\Responses\Ozone\Moderation\QueryStatusesResponse; 9 + use SocialDept\AtpClient\Data\Responses\Ozone\Moderation\SearchReposResponse; 10 + use SocialDept\AtpClient\Enums\Nsid\OzoneModeration; 11 + use SocialDept\AtpClient\Enums\Scope; 6 12 use SocialDept\AtpClient\Http\Response; 13 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\ModEventView; 14 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\ModEventViewDetail; 15 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\RecordViewDetail; 16 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\RepoViewDetail; 7 17 8 18 class ModerationRequestClient extends Request 9 19 { 10 20 /** 11 21 * Get moderation event 22 + * 23 + * @requires transition:generic (rpc:tools.ozone.moderation.getEvent) 12 24 * 13 25 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-event 14 26 */ 15 - public function getModerationEvent(int $id): Response 27 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getEvent')] 28 + public function getModerationEvent(int $id): ModEventViewDetail 16 29 { 17 - return $this->atp->client->get( 18 - endpoint: 'tools.ozone.moderation.getEvent', 30 + $response = $this->atp->client->get( 31 + endpoint: OzoneModeration::GetEvent, 19 32 params: compact('id') 20 33 ); 34 + 35 + return ModEventViewDetail::fromArray($response->json()); 21 36 } 22 37 23 38 /** 24 39 * Get moderation events 40 + * 41 + * @requires transition:generic (rpc:tools.ozone.moderation.getEvents) 25 42 * 26 43 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events 27 44 */ 45 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getEvents')] 28 46 public function getModerationEvents( 29 47 ?string $subject = null, 30 48 ?array $types = null, ··· 33 51 ?string $cursor = null 34 52 ): Response { 35 53 return $this->atp->client->get( 36 - endpoint: 'tools.ozone.moderation.getEvents', 54 + endpoint: OzoneModeration::GetEvents, 37 55 params: array_filter( 38 56 compact('subject', 'types', 'createdBy', 'limit', 'cursor'), 39 57 fn ($v) => ! is_null($v) ··· 44 62 /** 45 63 * Get record 46 64 * 65 + * @requires transition:generic (rpc:tools.ozone.moderation.getRecord) 66 + * 47 67 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-record 48 68 */ 49 - public function getRecord(string $uri, ?string $cid = null): Response 69 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getRecord')] 70 + public function getRecord(string $uri, ?string $cid = null): RecordViewDetail 50 71 { 51 - return $this->atp->client->get( 52 - endpoint: 'tools.ozone.moderation.getRecord', 72 + $response = $this->atp->client->get( 73 + endpoint: OzoneModeration::GetRecord, 53 74 params: compact('uri', 'cid') 54 75 ); 76 + 77 + return RecordViewDetail::fromArray($response->json()); 55 78 } 56 79 57 80 /** 58 81 * Get repo 59 82 * 83 + * @requires transition:generic (rpc:tools.ozone.moderation.getRepo) 84 + * 60 85 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-repo 61 86 */ 62 - public function getRepo(string $did): Response 87 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getRepo')] 88 + public function getRepo(string $did): RepoViewDetail 63 89 { 64 - return $this->atp->client->get( 65 - endpoint: 'tools.ozone.moderation.getRepo', 90 + $response = $this->atp->client->get( 91 + endpoint: OzoneModeration::GetRepo, 66 92 params: compact('did') 67 93 ); 94 + 95 + return RepoViewDetail::fromArray($response->json()); 68 96 } 69 97 70 98 /** 71 99 * Query events 72 100 * 101 + * @requires transition:generic (rpc:tools.ozone.moderation.queryEvents) 102 + * 73 103 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events 74 104 */ 105 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.queryEvents')] 75 106 public function queryEvents( 76 107 ?array $types = null, 77 108 ?string $createdBy = null, ··· 79 110 int $limit = 50, 80 111 ?string $cursor = null, 81 112 bool $sortDirection = false 82 - ): Response { 83 - return $this->atp->client->get( 84 - endpoint: 'tools.ozone.moderation.queryEvents', 113 + ): QueryEventsResponse { 114 + $response = $this->atp->client->get( 115 + endpoint: OzoneModeration::QueryEvents, 85 116 params: array_filter( 86 117 compact('types', 'createdBy', 'subject', 'limit', 'cursor', 'sortDirection'), 87 118 fn ($v) => ! is_null($v) 88 119 ) 89 120 ); 121 + 122 + return QueryEventsResponse::fromArray($response->json()); 90 123 } 91 124 92 125 /** 93 126 * Query statuses 127 + * 128 + * @requires transition:generic (rpc:tools.ozone.moderation.queryStatuses) 94 129 * 95 130 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-statuses 96 131 */ 132 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.queryStatuses')] 97 133 public function queryStatuses( 98 134 ?string $subject = null, 99 135 ?array $tags = null, 100 136 ?string $excludeTags = null, 101 137 int $limit = 50, 102 138 ?string $cursor = null 103 - ): Response { 104 - return $this->atp->client->get( 105 - endpoint: 'tools.ozone.moderation.queryStatuses', 139 + ): QueryStatusesResponse { 140 + $response = $this->atp->client->get( 141 + endpoint: OzoneModeration::QueryStatuses, 106 142 params: array_filter( 107 143 compact('subject', 'tags', 'excludeTags', 'limit', 'cursor'), 108 144 fn ($v) => ! is_null($v) 109 145 ) 110 146 ); 147 + 148 + return QueryStatusesResponse::fromArray($response->json()); 111 149 } 112 150 113 151 /** 114 152 * Search repos 153 + * 154 + * @requires transition:generic (rpc:tools.ozone.moderation.searchRepos) 115 155 * 116 156 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-search-repos 117 157 */ 158 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.searchRepos')] 118 159 public function searchRepos( 119 160 ?string $term = null, 120 161 ?string $invitedBy = null, 121 162 int $limit = 50, 122 163 ?string $cursor = null 123 - ): Response { 124 - return $this->atp->client->get( 125 - endpoint: 'tools.ozone.moderation.searchRepos', 164 + ): SearchReposResponse { 165 + $response = $this->atp->client->get( 166 + endpoint: OzoneModeration::SearchRepos, 126 167 params: array_filter( 127 168 compact('term', 'invitedBy', 'limit', 'cursor'), 128 169 fn ($v) => ! is_null($v) 129 170 ) 130 171 ); 172 + 173 + return SearchReposResponse::fromArray($response->json()); 131 174 } 132 175 133 176 /** 134 177 * Emit moderation event 135 178 * 179 + * @requires transition:generic (rpc:tools.ozone.moderation.emitEvent) 180 + * 136 181 * @see https://docs.bsky.app/docs/api/tools-ozone-moderation-emit-event 137 182 */ 183 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.emitEvent')] 138 184 public function emitEvent( 139 185 array $event, 140 186 string $subject, 141 187 array $subjectBlobCids = [], 142 188 ?string $createdBy = null 143 - ): Response { 144 - return $this->atp->client->post( 145 - endpoint: 'tools.ozone.moderation.emitEvent', 189 + ): ModEventView { 190 + $response = $this->atp->client->post( 191 + endpoint: OzoneModeration::EmitEvent, 146 192 body: compact('event', 'subject', 'subjectBlobCids', 'createdBy') 147 193 ); 194 + 195 + return ModEventView::fromArray($response->json()); 148 196 } 149 197 }
+17 -5
src/Client/Requests/Ozone/ServerRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Ozone; 4 4 5 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\Ozone\Server\GetConfigResponse; 8 + use SocialDept\AtpClient\Enums\Nsid\OzoneServer; 9 + use SocialDept\AtpClient\Enums\Scope; 6 10 use SocialDept\AtpClient\Http\Response; 7 11 8 12 class ServerRequestClient extends Request 9 13 { 10 14 /** 11 - * Get blob 15 + * Get blob (returns binary data) 16 + * 17 + * @requires transition:generic (rpc:tools.ozone.server.getBlob) 12 18 * 13 19 * @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config 14 20 */ 21 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.server.getBlob')] 15 22 public function getBlob(string $did, string $cid): Response 16 23 { 17 24 return $this->atp->client->get( 18 - endpoint: 'tools.ozone.server.getBlob', 25 + endpoint: OzoneServer::GetBlob, 19 26 params: compact('did', 'cid') 20 27 ); 21 28 } ··· 23 30 /** 24 31 * Get config 25 32 * 33 + * @requires transition:generic (rpc:tools.ozone.server.getConfig) 34 + * 26 35 * @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config 27 36 */ 28 - public function getConfig(): Response 37 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.server.getConfig')] 38 + public function getConfig(): GetConfigResponse 29 39 { 30 - return $this->atp->client->get( 31 - endpoint: 'tools.ozone.server.getConfig' 40 + $response = $this->atp->client->get( 41 + endpoint: OzoneServer::GetConfig 32 42 ); 43 + 44 + return GetConfigResponse::fromArray($response->json()); 33 45 } 34 46 }
+46 -16
src/Client/Requests/Ozone/TeamRequestClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client\Requests\Ozone; 4 4 5 + use SocialDept\AtpClient\Attributes\ScopedEndpoint; 5 6 use SocialDept\AtpClient\Client\Requests\Request; 6 - use SocialDept\AtpClient\Http\Response; 7 + use SocialDept\AtpClient\Data\Responses\EmptyResponse; 8 + use SocialDept\AtpClient\Data\Responses\Ozone\Team\ListMembersResponse; 9 + use SocialDept\AtpClient\Data\Responses\Ozone\Team\MemberResponse; 10 + use SocialDept\AtpClient\Enums\Nsid\OzoneTeam; 11 + use SocialDept\AtpClient\Enums\Scope; 7 12 8 13 class TeamRequestClient extends Request 9 14 { 10 15 /** 11 16 * Get team member 12 17 * 18 + * @requires transition:generic (rpc:tools.ozone.team.getMember) 19 + * 13 20 * @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members 14 21 */ 15 - public function getTeamMember(string $did): Response 22 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.getMember')] 23 + public function getTeamMember(string $did): MemberResponse 16 24 { 17 - return $this->atp->client->get( 18 - endpoint: 'tools.ozone.team.getMember', 25 + $response = $this->atp->client->get( 26 + endpoint: OzoneTeam::GetMember, 19 27 params: compact('did') 20 28 ); 29 + 30 + return MemberResponse::fromArray($response->json()); 21 31 } 22 32 23 33 /** 24 34 * List team members 35 + * 36 + * @requires transition:generic (rpc:tools.ozone.team.listMembers) 25 37 * 26 38 * @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members 27 39 */ 28 - public function listTeamMembers(int $limit = 50, ?string $cursor = null): Response 40 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.listMembers')] 41 + public function listTeamMembers(int $limit = 50, ?string $cursor = null): ListMembersResponse 29 42 { 30 - return $this->atp->client->get( 31 - endpoint: 'tools.ozone.team.listMembers', 43 + $response = $this->atp->client->get( 44 + endpoint: OzoneTeam::ListMembers, 32 45 params: compact('limit', 'cursor') 33 46 ); 47 + 48 + return ListMembersResponse::fromArray($response->json()); 34 49 } 35 50 36 51 /** 37 52 * Add team member 53 + * 54 + * @requires transition:generic (rpc:tools.ozone.team.addMember) 38 55 * 39 56 * @see https://docs.bsky.app/docs/api/tools-ozone-team-add-member 40 57 */ 41 - public function addTeamMember(string $did, string $role): Response 58 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.addMember')] 59 + public function addTeamMember(string $did, string $role): MemberResponse 42 60 { 43 - return $this->atp->client->post( 44 - endpoint: 'tools.ozone.team.addMember', 61 + $response = $this->atp->client->post( 62 + endpoint: OzoneTeam::AddMember, 45 63 body: compact('did', 'role') 46 64 ); 65 + 66 + return MemberResponse::fromArray($response->json()); 47 67 } 48 68 49 69 /** 50 70 * Update team member 51 71 * 72 + * @requires transition:generic (rpc:tools.ozone.team.updateMember) 73 + * 52 74 * @see https://docs.bsky.app/docs/api/tools-ozone-team-update-member 53 75 */ 76 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.updateMember')] 54 77 public function updateTeamMember( 55 78 string $did, 56 79 ?bool $disabled = null, 57 80 ?string $role = null 58 - ): Response { 59 - return $this->atp->client->post( 60 - endpoint: 'tools.ozone.team.updateMember', 81 + ): MemberResponse { 82 + $response = $this->atp->client->post( 83 + endpoint: OzoneTeam::UpdateMember, 61 84 body: array_filter( 62 85 compact('did', 'disabled', 'role'), 63 86 fn ($v) => ! is_null($v) 64 87 ) 65 88 ); 89 + 90 + return MemberResponse::fromArray($response->json()); 66 91 } 67 92 68 93 /** 69 94 * Delete team member 70 95 * 96 + * @requires transition:generic (rpc:tools.ozone.team.deleteMember) 97 + * 71 98 * @see https://docs.bsky.app/docs/api/tools-ozone-team-delete-member 72 99 */ 73 - public function deleteTeamMember(string $did): Response 100 + #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.deleteMember')] 101 + public function deleteTeamMember(string $did): EmptyResponse 74 102 { 75 - return $this->atp->client->post( 76 - endpoint: 'tools.ozone.team.deleteMember', 103 + $this->atp->client->post( 104 + endpoint: OzoneTeam::DeleteMember, 77 105 body: compact('did') 78 106 ); 107 + 108 + return new EmptyResponse; 79 109 } 80 110 }
+2 -2
src/Client/Requests/Request.php
··· 9 9 /** 10 10 * The parent AtpClient instance we belong to 11 11 */ 12 - public AtpClient $atp; 12 + protected AtpClient $atp; 13 13 14 14 public function __construct($parent) 15 15 { 16 - $this->atp = $parent->atp; 16 + $this->atp = $parent->root(); 17 17 } 18 18 }
+76
src/Concerns/HasDomainExtensions.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Concerns; 4 + 5 + use BadMethodCallException; 6 + 7 + trait HasDomainExtensions 8 + { 9 + /** 10 + * Resolved domain extension instances. 11 + * 12 + * @var array<string, object> 13 + */ 14 + protected array $resolvedDomainExtensions = []; 15 + 16 + /** 17 + * Get the domain name for this client. 18 + */ 19 + abstract protected function getDomainName(): string; 20 + 21 + /** 22 + * Get the root client class for extension lookup. 23 + */ 24 + abstract protected function getRootClientClass(): string; 25 + 26 + /** 27 + * Get the root client instance. 28 + */ 29 + abstract public function root(): object; 30 + 31 + /** 32 + * Resolve a domain extension instance. 33 + */ 34 + protected function resolveDomainExtension(string $name): object 35 + { 36 + if (! isset($this->resolvedDomainExtensions[$name])) { 37 + $rootClass = $this->getRootClientClass(); 38 + $extensions = $rootClass::getDomainExtensionsFor($this->getDomainName()); 39 + $this->resolvedDomainExtensions[$name] = ($extensions[$name])($this); 40 + } 41 + 42 + return $this->resolvedDomainExtensions[$name]; 43 + } 44 + 45 + /** 46 + * Check if a domain extension exists. 47 + */ 48 + protected function hasDomainExtension(string $name): bool 49 + { 50 + $rootClass = $this->getRootClientClass(); 51 + 52 + return $rootClass::hasDomainExtension($this->getDomainName(), $name); 53 + } 54 + 55 + /** 56 + * Magic getter for domain extension access. 57 + */ 58 + public function __get(string $name): mixed 59 + { 60 + if ($this->hasDomainExtension($name)) { 61 + return $this->resolveDomainExtension($name); 62 + } 63 + 64 + throw new BadMethodCallException( 65 + sprintf('Property [%s] does not exist on [%s].', $name, static::class) 66 + ); 67 + } 68 + 69 + /** 70 + * Magic isset for domain extension checking. 71 + */ 72 + public function __isset(string $name): bool 73 + { 74 + return $this->hasDomainExtension($name); 75 + } 76 + }
+124
src/Concerns/HasExtensions.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Concerns; 4 + 5 + use BadMethodCallException; 6 + use Closure; 7 + 8 + trait HasExtensions 9 + { 10 + /** 11 + * Registered domain client extensions. 12 + * 13 + * @var array<string, Closure> 14 + */ 15 + protected static array $extensions = []; 16 + 17 + /** 18 + * Registered request client extensions for existing domains. 19 + * 20 + * @var array<string, array<string, Closure>> 21 + */ 22 + protected static array $domainExtensions = []; 23 + 24 + /** 25 + * Resolved extension instances (lazy loading). 26 + * 27 + * @var array<string, object> 28 + */ 29 + protected array $resolvedExtensions = []; 30 + 31 + /** 32 + * Register a domain client extension. 33 + */ 34 + public static function extend(string $name, Closure $callback): void 35 + { 36 + static::$extensions[$name] = $callback; 37 + } 38 + 39 + /** 40 + * Register a request client extension for an existing domain. 41 + */ 42 + public static function extendDomain(string $domain, string $name, Closure $callback): void 43 + { 44 + static::$domainExtensions[$domain][$name] = $callback; 45 + } 46 + 47 + /** 48 + * Check if an extension is registered. 49 + */ 50 + public static function hasExtension(string $name): bool 51 + { 52 + return isset(static::$extensions[$name]); 53 + } 54 + 55 + /** 56 + * Check if a domain extension is registered. 57 + */ 58 + public static function hasDomainExtension(string $domain, string $name): bool 59 + { 60 + return isset(static::$domainExtensions[$domain][$name]); 61 + } 62 + 63 + /** 64 + * Get domain extensions for a specific domain. 65 + */ 66 + public static function getDomainExtensionsFor(string $domain): array 67 + { 68 + return static::$domainExtensions[$domain] ?? []; 69 + } 70 + 71 + /** 72 + * Flush all registered extensions (useful for testing). 73 + */ 74 + public static function flushExtensions(): void 75 + { 76 + static::$extensions = []; 77 + static::$domainExtensions = []; 78 + } 79 + 80 + /** 81 + * Resolve an extension instance. 82 + */ 83 + protected function resolveExtension(string $name): object 84 + { 85 + if (! isset($this->resolvedExtensions[$name])) { 86 + $this->resolvedExtensions[$name] = (static::$extensions[$name])($this); 87 + } 88 + 89 + return $this->resolvedExtensions[$name]; 90 + } 91 + 92 + /** 93 + * Magic getter for extension access. 94 + */ 95 + public function __get(string $name): mixed 96 + { 97 + if (static::hasExtension($name)) { 98 + return $this->resolveExtension($name); 99 + } 100 + 101 + throw new BadMethodCallException( 102 + sprintf('Property [%s] does not exist on [%s].', $name, static::class) 103 + ); 104 + } 105 + 106 + /** 107 + * Magic isset for extension checking. 108 + */ 109 + public function __isset(string $name): bool 110 + { 111 + return static::hasExtension($name); 112 + } 113 + 114 + /** 115 + * Get the root client instance. 116 + * 117 + * For root clients, this returns itself. 118 + * Domain clients override this to return their parent. 119 + */ 120 + public function root(): static 121 + { 122 + return $this; 123 + } 124 + }
+123
src/Console/MakeAtpClientCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use Illuminate\Filesystem\Filesystem; 7 + use Illuminate\Support\Str; 8 + 9 + class MakeAtpClientCommand extends Command 10 + { 11 + protected $signature = 'make:atp-client 12 + {name : The name of the client class} 13 + {--force : Overwrite existing file}'; 14 + 15 + protected $description = 'Create a new ATP domain client extension'; 16 + 17 + public function __construct(protected Filesystem $files) 18 + { 19 + parent::__construct(); 20 + } 21 + 22 + public function handle(): int 23 + { 24 + $name = $this->argument('name'); 25 + 26 + if (! Str::endsWith($name, 'Client')) { 27 + $name .= 'Client'; 28 + } 29 + 30 + $path = $this->getPath($name); 31 + 32 + if ($this->files->exists($path) && ! $this->option('force')) { 33 + $this->components->error("Client [{$name}] already exists!"); 34 + 35 + return self::FAILURE; 36 + } 37 + 38 + $this->makeDirectory($path); 39 + 40 + $content = $this->populateStub($this->getStub(), $name); 41 + 42 + $this->files->put($path, $content); 43 + 44 + $this->components->info("Client [{$path}] created successfully."); 45 + 46 + $this->outputRegistrationHint($name); 47 + 48 + return self::SUCCESS; 49 + } 50 + 51 + protected function getPath(string $name): string 52 + { 53 + $basePath = config('client.generators.client_path', 'app/Services/Clients'); 54 + 55 + return base_path($basePath.'/'.$name.'.php'); 56 + } 57 + 58 + protected function makeDirectory(string $path): void 59 + { 60 + if (! $this->files->isDirectory(dirname($path))) { 61 + $this->files->makeDirectory(dirname($path), 0755, true); 62 + } 63 + } 64 + 65 + protected function getNamespace(): string 66 + { 67 + $basePath = config('client.generators.client_path', 'app/Services/Clients'); 68 + 69 + return Str::of($basePath) 70 + ->replace('/', '\\') 71 + ->ucfirst() 72 + ->replace('App', 'App') 73 + ->toString(); 74 + } 75 + 76 + protected function populateStub(string $stub, string $name): string 77 + { 78 + return str_replace( 79 + ['{{ namespace }}', '{{ class }}'], 80 + [$this->getNamespace(), $name], 81 + $stub 82 + ); 83 + } 84 + 85 + protected function outputRegistrationHint(string $name): void 86 + { 87 + $this->newLine(); 88 + $this->components->info('Register the extension in your AppServiceProvider:'); 89 + $this->newLine(); 90 + 91 + $namespace = $this->getNamespace(); 92 + $extensionName = Str::of($name)->before('Client')->camel()->toString(); 93 + 94 + $this->line("use {$namespace}\\{$name};"); 95 + $this->line("use SocialDept\\AtpClient\\AtpClient;"); 96 + $this->newLine(); 97 + $this->line("// In boot() method:"); 98 + $this->line("AtpClient::extend('{$extensionName}', fn(AtpClient \$atp) => new {$name}(\$atp));"); 99 + } 100 + 101 + protected function getStub(): string 102 + { 103 + return <<<'STUB' 104 + <?php 105 + 106 + namespace {{ namespace }}; 107 + 108 + use SocialDept\AtpClient\AtpClient; 109 + 110 + class {{ class }} 111 + { 112 + protected AtpClient $atp; 113 + 114 + public function __construct(AtpClient $parent) 115 + { 116 + $this->atp = $parent; 117 + } 118 + 119 + // 120 + } 121 + STUB; 122 + } 123 + }
+126
src/Console/MakeAtpRequestCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use Illuminate\Filesystem\Filesystem; 7 + use Illuminate\Support\Str; 8 + 9 + class MakeAtpRequestCommand extends Command 10 + { 11 + protected $signature = 'make:atp-request 12 + {name : The name of the request client class} 13 + {--domain=bsky : The domain to extend (bsky, atproto, chat, ozone)} 14 + {--force : Overwrite existing file}'; 15 + 16 + protected $description = 'Create a new ATP request client extension for an existing domain'; 17 + 18 + protected array $validDomains = ['bsky', 'atproto', 'chat', 'ozone']; 19 + 20 + public function __construct(protected Filesystem $files) 21 + { 22 + parent::__construct(); 23 + } 24 + 25 + public function handle(): int 26 + { 27 + $name = $this->argument('name'); 28 + $domain = $this->option('domain'); 29 + 30 + if (! in_array($domain, $this->validDomains)) { 31 + $this->components->error("Invalid domain [{$domain}]. Valid domains: ".implode(', ', $this->validDomains)); 32 + 33 + return self::FAILURE; 34 + } 35 + 36 + if (! Str::endsWith($name, 'Client')) { 37 + $name .= 'Client'; 38 + } 39 + 40 + $path = $this->getPath($name); 41 + 42 + if ($this->files->exists($path) && ! $this->option('force')) { 43 + $this->components->error("Request client [{$name}] already exists!"); 44 + 45 + return self::FAILURE; 46 + } 47 + 48 + $this->makeDirectory($path); 49 + 50 + $content = $this->populateStub($this->getStub(), $name); 51 + 52 + $this->files->put($path, $content); 53 + 54 + $this->components->info("Request client [{$path}] created successfully."); 55 + 56 + $this->outputRegistrationHint($name, $domain); 57 + 58 + return self::SUCCESS; 59 + } 60 + 61 + protected function getPath(string $name): string 62 + { 63 + $basePath = config('client.generators.request_path', 'app/Services/Clients/Requests'); 64 + 65 + return base_path($basePath.'/'.$name.'.php'); 66 + } 67 + 68 + protected function makeDirectory(string $path): void 69 + { 70 + if (! $this->files->isDirectory(dirname($path))) { 71 + $this->files->makeDirectory(dirname($path), 0755, true); 72 + } 73 + } 74 + 75 + protected function getNamespace(): string 76 + { 77 + $basePath = config('client.generators.request_path', 'app/Services/Clients/Requests'); 78 + 79 + return Str::of($basePath) 80 + ->replace('/', '\\') 81 + ->ucfirst() 82 + ->replace('App', 'App') 83 + ->toString(); 84 + } 85 + 86 + protected function populateStub(string $stub, string $name): string 87 + { 88 + return str_replace( 89 + ['{{ namespace }}', '{{ class }}'], 90 + [$this->getNamespace(), $name], 91 + $stub 92 + ); 93 + } 94 + 95 + protected function outputRegistrationHint(string $name, string $domain): void 96 + { 97 + $this->newLine(); 98 + $this->components->info('Register the extension in your AppServiceProvider:'); 99 + $this->newLine(); 100 + 101 + $namespace = $this->getNamespace(); 102 + $extensionName = Str::of($name)->before('Client')->camel()->toString(); 103 + 104 + $this->line("use {$namespace}\\{$name};"); 105 + $this->line("use SocialDept\\AtpClient\\AtpClient;"); 106 + $this->newLine(); 107 + $this->line("// In boot() method:"); 108 + $this->line("AtpClient::extendDomain('{$domain}', '{$extensionName}', fn(\$domain) => new {$name}(\$domain));"); 109 + } 110 + 111 + protected function getStub(): string 112 + { 113 + return <<<'STUB' 114 + <?php 115 + 116 + namespace {{ namespace }}; 117 + 118 + use SocialDept\AtpClient\Client\Requests\Request; 119 + 120 + class {{ class }} extends Request 121 + { 122 + // 123 + } 124 + STUB; 125 + } 126 + }
+5 -5
src/Contracts/CredentialProvider.php
··· 8 8 interface CredentialProvider 9 9 { 10 10 /** 11 - * Get credentials for the given identifier 11 + * Get credentials for the given DID 12 12 */ 13 - public function getCredentials(string $identifier): ?Credentials; 13 + public function getCredentials(string $did): ?Credentials; 14 14 15 15 /** 16 16 * Store new credentials (initial OAuth or app password login) 17 17 */ 18 - public function storeCredentials(string $identifier, AccessToken $token): void; 18 + public function storeCredentials(string $did, AccessToken $token): void; 19 19 20 20 /** 21 21 * Update credentials after token refresh 22 22 * CRITICAL: Refresh tokens are single-use! 23 23 */ 24 - public function updateCredentials(string $identifier, AccessToken $token): void; 24 + public function updateCredentials(string $did, AccessToken $token): void; 25 25 26 26 /** 27 27 * Remove credentials 28 28 */ 29 - public function removeCredentials(string $identifier): void; 29 + public function removeCredentials(string $did): void; 30 30 }
+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 + }
+56 -3
src/Data/AccessToken.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Data; 4 4 5 + use Carbon\Carbon; 6 + use SocialDept\AtpClient\Enums\AuthType; 7 + 5 8 class AccessToken 6 9 { 7 10 public function __construct( ··· 10 13 public readonly string $did, 11 14 public readonly \DateTimeInterface $expiresAt, 12 15 public readonly ?string $handle = null, 16 + public readonly ?string $issuer = null, 17 + public readonly array $scope = [], 18 + public readonly AuthType $authType = AuthType::OAuth, 13 19 ) {} 14 20 15 - public static function fromResponse(array $data): self 21 + /** 22 + * Create from API response. 23 + * 24 + * Handles both legacy createSession format (accessJwt, refreshJwt, did) 25 + * and OAuth token format (access_token, refresh_token, sub). 26 + */ 27 + public static function fromResponse(array $data, ?string $handle = null, ?string $issuer = null): self 16 28 { 29 + // OAuth token endpoint format 30 + if (isset($data['access_token'])) { 31 + return new self( 32 + accessJwt: $data['access_token'], 33 + refreshJwt: $data['refresh_token'] ?? '', 34 + did: $data['sub'] ?? '', 35 + expiresAt: now()->addSeconds($data['expires_in'] ?? 300), 36 + handle: $handle, 37 + issuer: $issuer, 38 + scope: isset($data['scope']) ? explode(' ', $data['scope']) : [], 39 + authType: AuthType::OAuth, 40 + ); 41 + } 42 + 43 + // Legacy createSession format (app passwords have full access) 44 + // Parse expiry from JWT since createSession doesn't return expiresIn 45 + $expiresAt = self::parseJwtExpiry($data['accessJwt']) ?? now()->addHour(); 46 + 17 47 return new self( 18 48 accessJwt: $data['accessJwt'], 19 49 refreshJwt: $data['refreshJwt'], 20 50 did: $data['did'], 21 - expiresAt: now()->addSeconds($data['expiresIn'] ?? 300), 22 - handle: $data['handle'] ?? null, 51 + expiresAt: $expiresAt, 52 + handle: $data['handle'] ?? $handle, 53 + issuer: $issuer, 54 + scope: ['atproto', 'transition:generic', 'transition:email'], 55 + authType: AuthType::Legacy, 23 56 ); 57 + } 58 + 59 + /** 60 + * Parse the expiry timestamp from a JWT's payload. 61 + */ 62 + protected static function parseJwtExpiry(string $jwt): ?\DateTimeInterface 63 + { 64 + $parts = explode('.', $jwt); 65 + 66 + if (count($parts) !== 3) { 67 + return null; 68 + } 69 + 70 + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); 71 + 72 + if (! isset($payload['exp'])) { 73 + return null; 74 + } 75 + 76 + return Carbon::createFromTimestamp($payload['exp']); 24 77 } 25 78 }
+2
src/Data/AuthorizationRequest.php
··· 10 10 public readonly string $codeVerifier, 11 11 public readonly DPoPKey $dpopKey, 12 12 public readonly string $requestUri, 13 + public readonly string $pdsEndpoint, 14 + public readonly ?string $handle = null, 13 15 ) {} 14 16 }
+6 -1
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( 8 - public readonly string $identifier, 9 10 public readonly string $did, 10 11 public readonly string $accessToken, 11 12 public readonly string $refreshToken, 12 13 public readonly \DateTimeInterface $expiresAt, 14 + public readonly ?string $handle = null, 15 + public readonly ?string $issuer = null, 16 + public readonly array $scope = [], 17 + public readonly AuthType $authType = AuthType::OAuth, 13 18 ) {} 14 19 15 20 public function isExpired(): bool
+30 -6
src/Data/DPoPKey.php
··· 4 4 5 5 use phpseclib3\Crypt\Common\PrivateKey; 6 6 use phpseclib3\Crypt\Common\PublicKey; 7 + use phpseclib3\Crypt\PublicKeyLoader; 7 8 8 9 class DPoPKey 9 10 { 11 + protected string $privateKeyPem; 12 + 13 + protected string $publicKeyPem; 14 + 10 15 public function __construct( 11 - public readonly PrivateKey $privateKey, 12 - public readonly PublicKey $publicKey, 16 + PrivateKey|string $privateKey, 17 + PublicKey|string $publicKey, 13 18 public readonly string $keyId, 14 - ) {} 19 + ) { 20 + // Store as PEM strings for serialization 21 + $this->privateKeyPem = $privateKey instanceof PrivateKey 22 + ? $privateKey->toString('PKCS8') 23 + : $privateKey; 24 + 25 + $this->publicKeyPem = $publicKey instanceof PublicKey 26 + ? $publicKey->toString('PKCS8') 27 + : $publicKey; 28 + } 29 + 30 + public function getPrivateKey(): PrivateKey 31 + { 32 + return PublicKeyLoader::load($this->privateKeyPem); 33 + } 34 + 35 + public function getPublicKey(): PublicKey 36 + { 37 + return PublicKeyLoader::load($this->publicKeyPem); 38 + } 15 39 16 40 public function getPublicJwk(): array 17 41 { 18 - $jwks = json_decode($this->publicKey->toString('JWK'), true); 42 + $jwks = json_decode($this->getPublicKey()->toString('JWK'), true); 19 43 20 44 // phpseclib returns JWKS format {"keys":[...]}, extract the first key 21 45 $jwk = $jwks['keys'][0] ?? $jwks; ··· 32 56 33 57 public function getPrivateJwk(): array 34 58 { 35 - $jwks = json_decode($this->privateKey->toString('JWK'), true); 59 + $jwks = json_decode($this->getPrivateKey()->toString('JWK'), true); 36 60 37 61 // phpseclib returns JWKS format {"keys":[...]}, extract the first key 38 62 $jwk = $jwks['keys'][0] ?? $jwks; ··· 49 73 50 74 public function toPEM(): string 51 75 { 52 - return $this->privateKey->toString('PKCS8'); 76 + return $this->privateKeyPem; 53 77 } 54 78 }
+61
src/Data/Record.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * Generic wrapper for AT Protocol records. 9 + * 10 + * @template T 11 + * @implements Arrayable<string, mixed> 12 + */ 13 + class Record implements Arrayable 14 + { 15 + /** 16 + * @param T $value 17 + */ 18 + public function __construct( 19 + public readonly string $uri, 20 + public readonly string $cid, 21 + public readonly mixed $value, 22 + ) {} 23 + 24 + /** 25 + * @template U 26 + * @param array $data 27 + * @param callable(array): U $transformer 28 + * @return self<U> 29 + */ 30 + public static function fromArray(array $data, callable $transformer): self 31 + { 32 + return new self( 33 + uri: $data['uri'], 34 + cid: $data['cid'], 35 + value: $transformer($data['value']), 36 + ); 37 + } 38 + 39 + /** 40 + * Create without transforming value. 41 + */ 42 + public static function fromArrayRaw(array $data): self 43 + { 44 + return new self( 45 + uri: $data['uri'], 46 + cid: $data['cid'], 47 + value: $data['value'], 48 + ); 49 + } 50 + 51 + public function toArray(): array 52 + { 53 + return [ 54 + 'uri' => $this->uri, 55 + 'cid' => $this->cid, 56 + 'value' => $this->value instanceof Arrayable 57 + ? $this->value->toArray() 58 + : $this->value, 59 + ]; 60 + } 61 + }
+60
src/Data/RecordCollection.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * Collection wrapper for paginated AT Protocol records. 10 + * 11 + * @template T 12 + * @implements Arrayable<string, mixed> 13 + */ 14 + class RecordCollection implements Arrayable 15 + { 16 + /** 17 + * @param Collection<int, Record<T>> $records 18 + */ 19 + public function __construct( 20 + public readonly Collection $records, 21 + public readonly ?string $cursor = null, 22 + ) {} 23 + 24 + /** 25 + * @template U 26 + * @param array $data 27 + * @param callable(array): U $transformer 28 + * @return self<U> 29 + */ 30 + public static function fromArray(array $data, callable $transformer): self 31 + { 32 + return new self( 33 + records: collect($data['records'] ?? [])->map( 34 + fn (array $record) => Record::fromArray($record, $transformer) 35 + ), 36 + cursor: $data['cursor'] ?? null, 37 + ); 38 + } 39 + 40 + /** 41 + * Create without transforming values. 42 + */ 43 + public static function fromArrayRaw(array $data): self 44 + { 45 + return new self( 46 + records: collect($data['records'] ?? [])->map( 47 + fn (array $record) => Record::fromArrayRaw($record) 48 + ), 49 + cursor: $data['cursor'] ?? null, 50 + ); 51 + } 52 + 53 + public function toArray(): array 54 + { 55 + return [ 56 + 'records' => $this->records->map(fn (Record $r) => $r->toArray())->all(), 57 + 'cursor' => $this->cursor, 58 + ]; 59 + } 60 + }
+29
src/Data/Responses/Atproto/Identity/ResolveHandleResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Identity; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, string> 9 + */ 10 + class ResolveHandleResponse implements Arrayable 11 + { 12 + public function __construct( 13 + public readonly string $did, 14 + ) {} 15 + 16 + public static function fromArray(array $data): self 17 + { 18 + return new self( 19 + did: $data['did'], 20 + ); 21 + } 22 + 23 + public function toArray(): array 24 + { 25 + return [ 26 + 'did' => $this->did, 27 + ]; 28 + } 29 + }
+39
src/Data/Responses/Atproto/Repo/CreateRecordResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class CreateRecordResponse implements Arrayable 12 + { 13 + public function __construct( 14 + public readonly string $uri, 15 + public readonly string $cid, 16 + public readonly ?CommitMeta $commit = null, 17 + public readonly ?string $validationStatus = null, 18 + ) {} 19 + 20 + public static function fromArray(array $data): self 21 + { 22 + return new self( 23 + uri: $data['uri'], 24 + cid: $data['cid'], 25 + commit: isset($data['commit']) ? CommitMeta::fromArray($data['commit']) : null, 26 + validationStatus: $data['validationStatus'] ?? null, 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'uri' => $this->uri, 34 + 'cid' => $this->cid, 35 + 'commit' => $this->commit?->toArray(), 36 + 'validationStatus' => $this->validationStatus, 37 + ]; 38 + } 39 + }
+30
src/Data/Responses/Atproto/Repo/DeleteRecordResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class DeleteRecordResponse implements Arrayable 12 + { 13 + public function __construct( 14 + public readonly ?CommitMeta $commit = null, 15 + ) {} 16 + 17 + public static function fromArray(array $data): self 18 + { 19 + return new self( 20 + commit: isset($data['commit']) ? CommitMeta::fromArray($data['commit']) : null, 21 + ); 22 + } 23 + 24 + public function toArray(): array 25 + { 26 + return [ 27 + 'commit' => $this->commit?->toArray(), 28 + ]; 29 + } 30 + }
+44
src/Data/Responses/Atproto/Repo/DescribeRepoResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class DescribeRepoResponse implements Arrayable 11 + { 12 + /** 13 + * @param array<string> $collections 14 + */ 15 + public function __construct( 16 + public readonly string $handle, 17 + public readonly string $did, 18 + public readonly mixed $didDoc, 19 + public readonly array $collections, 20 + public readonly bool $handleIsCorrect, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + handle: $data['handle'], 27 + did: $data['did'], 28 + didDoc: $data['didDoc'], 29 + collections: $data['collections'] ?? [], 30 + handleIsCorrect: $data['handleIsCorrect'], 31 + ); 32 + } 33 + 34 + public function toArray(): array 35 + { 36 + return [ 37 + 'handle' => $this->handle, 38 + 'did' => $this->did, 39 + 'didDoc' => $this->didDoc, 40 + 'collections' => $this->collections, 41 + 'handleIsCorrect' => $this->handleIsCorrect, 42 + ]; 43 + } 44 + }
+35
src/Data/Responses/Atproto/Repo/GetRecordResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class GetRecordResponse implements Arrayable 11 + { 12 + public function __construct( 13 + public readonly string $uri, 14 + public readonly mixed $value, 15 + public readonly ?string $cid = null, 16 + ) {} 17 + 18 + public static function fromArray(array $data): self 19 + { 20 + return new self( 21 + uri: $data['uri'], 22 + value: $data['value'], 23 + cid: $data['cid'] ?? null, 24 + ); 25 + } 26 + 27 + public function toArray(): array 28 + { 29 + return [ 30 + 'uri' => $this->uri, 31 + 'value' => $this->value, 32 + 'cid' => $this->cid, 33 + ]; 34 + } 35 + }
+36
src/Data/Responses/Atproto/Repo/ListRecordsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class ListRecordsResponse implements Arrayable 12 + { 13 + /** 14 + * @param Collection<int, array{uri: string, cid: string, value: mixed}> $records 15 + */ 16 + public function __construct( 17 + public readonly Collection $records, 18 + public readonly ?string $cursor = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + records: collect($data['records'] ?? []), 25 + cursor: $data['cursor'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'records' => $this->records->all(), 33 + 'cursor' => $this->cursor, 34 + ]; 35 + } 36 + }
+39
src/Data/Responses/Atproto/Repo/PutRecordResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class PutRecordResponse implements Arrayable 12 + { 13 + public function __construct( 14 + public readonly string $uri, 15 + public readonly string $cid, 16 + public readonly ?CommitMeta $commit = null, 17 + public readonly ?string $validationStatus = null, 18 + ) {} 19 + 20 + public static function fromArray(array $data): self 21 + { 22 + return new self( 23 + uri: $data['uri'], 24 + cid: $data['cid'], 25 + commit: isset($data['commit']) ? CommitMeta::fromArray($data['commit']) : null, 26 + validationStatus: $data['validationStatus'] ?? null, 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'uri' => $this->uri, 34 + 'cid' => $this->cid, 35 + 'commit' => $this->commit?->toArray(), 36 + 'validationStatus' => $this->validationStatus, 37 + ]; 38 + } 39 + }
+47
src/Data/Responses/Atproto/Server/DescribeServerResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Server; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class DescribeServerResponse implements Arrayable 11 + { 12 + /** 13 + * @param array<string> $availableUserDomains 14 + */ 15 + public function __construct( 16 + public readonly string $did, 17 + public readonly array $availableUserDomains, 18 + public readonly ?bool $inviteCodeRequired = null, 19 + public readonly ?bool $phoneVerificationRequired = null, 20 + public readonly ?array $links = null, 21 + public readonly ?array $contact = null, 22 + ) {} 23 + 24 + public static function fromArray(array $data): self 25 + { 26 + return new self( 27 + did: $data['did'], 28 + availableUserDomains: $data['availableUserDomains'] ?? [], 29 + inviteCodeRequired: $data['inviteCodeRequired'] ?? null, 30 + phoneVerificationRequired: $data['phoneVerificationRequired'] ?? null, 31 + links: $data['links'] ?? null, 32 + contact: $data['contact'] ?? null, 33 + ); 34 + } 35 + 36 + public function toArray(): array 37 + { 38 + return [ 39 + 'did' => $this->did, 40 + 'availableUserDomains' => $this->availableUserDomains, 41 + 'inviteCodeRequired' => $this->inviteCodeRequired, 42 + 'phoneVerificationRequired' => $this->phoneVerificationRequired, 43 + 'links' => $this->links, 44 + 'contact' => $this->contact, 45 + ]; 46 + } 47 + }
+50
src/Data/Responses/Atproto/Server/GetSessionResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Server; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class GetSessionResponse implements Arrayable 11 + { 12 + public function __construct( 13 + public readonly string $handle, 14 + public readonly string $did, 15 + public readonly ?string $email = null, 16 + public readonly ?bool $emailConfirmed = null, 17 + public readonly ?bool $emailAuthFactor = null, 18 + public readonly mixed $didDoc = null, 19 + public readonly ?bool $active = null, 20 + public readonly ?string $status = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + handle: $data['handle'], 27 + did: $data['did'], 28 + email: $data['email'] ?? null, 29 + emailConfirmed: $data['emailConfirmed'] ?? null, 30 + emailAuthFactor: $data['emailAuthFactor'] ?? null, 31 + didDoc: $data['didDoc'] ?? null, 32 + active: $data['active'] ?? null, 33 + status: $data['status'] ?? null, 34 + ); 35 + } 36 + 37 + public function toArray(): array 38 + { 39 + return [ 40 + 'handle' => $this->handle, 41 + 'did' => $this->did, 42 + 'email' => $this->email, 43 + 'emailConfirmed' => $this->emailConfirmed, 44 + 'emailAuthFactor' => $this->emailAuthFactor, 45 + 'didDoc' => $this->didDoc, 46 + 'active' => $this->active, 47 + 'status' => $this->status, 48 + ]; 49 + } 50 + }
+38
src/Data/Responses/Atproto/Sync/GetRepoStatusResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class GetRepoStatusResponse implements Arrayable 11 + { 12 + public function __construct( 13 + public readonly string $did, 14 + public readonly bool $active, 15 + public readonly ?string $status = null, 16 + public readonly ?string $rev = null, 17 + ) {} 18 + 19 + public static function fromArray(array $data): self 20 + { 21 + return new self( 22 + did: $data['did'], 23 + active: $data['active'], 24 + status: $data['status'] ?? null, 25 + rev: $data['rev'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'did' => $this->did, 33 + 'active' => $this->active, 34 + 'status' => $this->status, 35 + 'rev' => $this->rev, 36 + ]; 37 + } 38 + }
+35
src/Data/Responses/Atproto/Sync/ListBlobsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class ListBlobsResponse implements Arrayable 11 + { 12 + /** 13 + * @param array<string> $cids 14 + */ 15 + public function __construct( 16 + public readonly array $cids, 17 + public readonly ?string $cursor = null, 18 + ) {} 19 + 20 + public static function fromArray(array $data): self 21 + { 22 + return new self( 23 + cids: $data['cids'] ?? [], 24 + cursor: $data['cursor'] ?? null, 25 + ); 26 + } 27 + 28 + public function toArray(): array 29 + { 30 + return [ 31 + 'cids' => $this->cids, 32 + 'cursor' => $this->cursor, 33 + ]; 34 + } 35 + }
+36
src/Data/Responses/Atproto/Sync/ListReposByCollectionResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class ListReposByCollectionResponse implements Arrayable 12 + { 13 + /** 14 + * @param Collection<int, array{did: string, rev: string}> $repos 15 + */ 16 + public function __construct( 17 + public readonly Collection $repos, 18 + public readonly ?string $cursor = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + repos: collect($data['repos'] ?? []), 25 + cursor: $data['cursor'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'repos' => $this->repos->all(), 33 + 'cursor' => $this->cursor, 34 + ]; 35 + } 36 + }
+36
src/Data/Responses/Atproto/Sync/ListReposResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class ListReposResponse implements Arrayable 12 + { 13 + /** 14 + * @param Collection<int, array{did: string, head: string, rev: string, active?: bool, status?: string}> $repos 15 + */ 16 + public function __construct( 17 + public readonly Collection $repos, 18 + public readonly ?string $cursor = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + repos: collect($data['repos'] ?? []), 25 + cursor: $data['cursor'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'repos' => $this->repos->all(), 33 + 'cursor' => $this->cursor, 34 + ]; 35 + } 36 + }
+36
src/Data/Responses/Bsky/Actor/GetProfilesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileViewDetailed; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetProfilesResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileViewDetailed> $profiles 16 + */ 17 + public function __construct( 18 + public readonly Collection $profiles, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + profiles: collect($data['profiles'] ?? [])->map( 25 + fn (array $profile) => ProfileViewDetailed::fromArray($profile) 26 + ), 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'profiles' => $this->profiles->map(fn (ProfileViewDetailed $p) => $p->toArray())->all(), 34 + ]; 35 + } 36 + }
+39
src/Data/Responses/Bsky/Actor/GetSuggestionsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetSuggestionsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $actors 16 + */ 17 + public function __construct( 18 + public readonly Collection $actors, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + actors: collect($data['actors'] ?? [])->map( 26 + fn (array $actor) => ProfileView::fromArray($actor) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'actors' => $this->actors->map(fn (ProfileView $a) => $a->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Bsky/Actor/SearchActorsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class SearchActorsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $actors 16 + */ 17 + public function __construct( 18 + public readonly Collection $actors, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + actors: collect($data['actors'] ?? [])->map( 26 + fn (array $actor) => ProfileView::fromArray($actor) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'actors' => $this->actors->map(fn (ProfileView $a) => $a->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+36
src/Data/Responses/Bsky/Actor/SearchActorsTypeaheadResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileViewBasic; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class SearchActorsTypeaheadResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileViewBasic> $actors 16 + */ 17 + public function __construct( 18 + public readonly Collection $actors, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + actors: collect($data['actors'] ?? [])->map( 25 + fn (array $actor) => ProfileViewBasic::fromArray($actor) 26 + ), 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'actors' => $this->actors->map(fn (ProfileViewBasic $a) => $a->toArray())->all(), 34 + ]; 35 + } 36 + }
+38
src/Data/Responses/Bsky/Feed/DescribeFeedGeneratorResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class DescribeFeedGeneratorResponse implements Arrayable 11 + { 12 + /** 13 + * @param array<array{uri: string}> $feeds 14 + */ 15 + public function __construct( 16 + public readonly string $did, 17 + public readonly array $feeds, 18 + public readonly ?array $links = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + did: $data['did'], 25 + feeds: $data['feeds'] ?? [], 26 + links: $data['links'] ?? null, 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'did' => $this->did, 34 + 'feeds' => $this->feeds, 35 + 'links' => $this->links, 36 + ]; 37 + } 38 + }
+39
src/Data/Responses/Bsky/Feed/GetActorFeedsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetActorFeedsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, GeneratorView> $feeds 16 + */ 17 + public function __construct( 18 + public readonly Collection $feeds, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + feeds: collect($data['feeds'] ?? [])->map( 26 + fn (array $feed) => GeneratorView::fromArray($feed) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'feeds' => $this->feeds->map(fn (GeneratorView $f) => $f->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Bsky/Feed/GetActorLikesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetActorLikesResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, FeedViewPost> $feed 16 + */ 17 + public function __construct( 18 + public readonly Collection $feed, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + feed: collect($data['feed'] ?? [])->map( 26 + fn (array $post) => FeedViewPost::fromArray($post) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Bsky/Feed/GetAuthorFeedResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetAuthorFeedResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, FeedViewPost> $feed 16 + */ 17 + public function __construct( 18 + public readonly Collection $feed, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + feed: collect($data['feed'] ?? [])->map( 26 + fn (array $post) => FeedViewPost::fromArray($post) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+36
src/Data/Responses/Bsky/Feed/GetFeedGeneratorResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class GetFeedGeneratorResponse implements Arrayable 12 + { 13 + public function __construct( 14 + public readonly GeneratorView $view, 15 + public readonly bool $isOnline, 16 + public readonly bool $isValid, 17 + ) {} 18 + 19 + public static function fromArray(array $data): self 20 + { 21 + return new self( 22 + view: GeneratorView::fromArray($data['view']), 23 + isOnline: $data['isOnline'], 24 + isValid: $data['isValid'], 25 + ); 26 + } 27 + 28 + public function toArray(): array 29 + { 30 + return [ 31 + 'view' => $this->view->toArray(), 32 + 'isOnline' => $this->isOnline, 33 + 'isValid' => $this->isValid, 34 + ]; 35 + } 36 + }
+36
src/Data/Responses/Bsky/Feed/GetFeedGeneratorsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetFeedGeneratorsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, GeneratorView> $feeds 16 + */ 17 + public function __construct( 18 + public readonly Collection $feeds, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + feeds: collect($data['feeds'] ?? [])->map( 25 + fn (array $feed) => GeneratorView::fromArray($feed) 26 + ), 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'feeds' => $this->feeds->map(fn (GeneratorView $f) => $f->toArray())->all(), 34 + ]; 35 + } 36 + }
+39
src/Data/Responses/Bsky/Feed/GetFeedResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetFeedResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, FeedViewPost> $feed 16 + */ 17 + public function __construct( 18 + public readonly Collection $feed, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + feed: collect($data['feed'] ?? [])->map( 26 + fn (array $post) => FeedViewPost::fromArray($post) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+45
src/Data/Responses/Bsky/Feed/GetLikesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\GetLikes\Like; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetLikesResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, Like> $likes 16 + */ 17 + public function __construct( 18 + public readonly string $uri, 19 + public readonly Collection $likes, 20 + public readonly ?string $cid = null, 21 + public readonly ?string $cursor = null, 22 + ) {} 23 + 24 + public static function fromArray(array $data): self 25 + { 26 + return new self( 27 + uri: $data['uri'], 28 + likes: collect($data['likes'] ?? [])->map( 29 + fn (array $like) => Like::fromArray($like) 30 + ), 31 + cid: $data['cid'] ?? null, 32 + cursor: $data['cursor'] ?? null, 33 + ); 34 + } 35 + 36 + public function toArray(): array 37 + { 38 + return [ 39 + 'uri' => $this->uri, 40 + 'likes' => $this->likes->map(fn (Like $l) => $l->toArray())->all(), 41 + 'cid' => $this->cid, 42 + 'cursor' => $this->cursor, 43 + ]; 44 + } 45 + }
+33
src/Data/Responses/Bsky/Feed/GetPostThreadResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\ThreadViewPost; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class GetPostThreadResponse implements Arrayable 12 + { 13 + public function __construct( 14 + public readonly ThreadViewPost $thread, 15 + public readonly mixed $threadgate = null, 16 + ) {} 17 + 18 + public static function fromArray(array $data): self 19 + { 20 + return new self( 21 + thread: ThreadViewPost::fromArray($data['thread']), 22 + threadgate: $data['threadgate'] ?? null, 23 + ); 24 + } 25 + 26 + public function toArray(): array 27 + { 28 + return [ 29 + 'thread' => $this->thread->toArray(), 30 + 'threadgate' => $this->threadgate, 31 + ]; 32 + } 33 + }
+36
src/Data/Responses/Bsky/Feed/GetPostsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetPostsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, PostView> $posts 16 + */ 17 + public function __construct( 18 + public readonly Collection $posts, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + posts: collect($data['posts'] ?? [])->map( 25 + fn (array $post) => PostView::fromArray($post) 26 + ), 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'posts' => $this->posts->map(fn (PostView $p) => $p->toArray())->all(), 34 + ]; 35 + } 36 + }
+45
src/Data/Responses/Bsky/Feed/GetQuotesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetQuotesResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, PostView> $posts 16 + */ 17 + public function __construct( 18 + public readonly string $uri, 19 + public readonly Collection $posts, 20 + public readonly ?string $cid = null, 21 + public readonly ?string $cursor = null, 22 + ) {} 23 + 24 + public static function fromArray(array $data): self 25 + { 26 + return new self( 27 + uri: $data['uri'], 28 + posts: collect($data['posts'] ?? [])->map( 29 + fn (array $post) => PostView::fromArray($post) 30 + ), 31 + cid: $data['cid'] ?? null, 32 + cursor: $data['cursor'] ?? null, 33 + ); 34 + } 35 + 36 + public function toArray(): array 37 + { 38 + return [ 39 + 'uri' => $this->uri, 40 + 'posts' => $this->posts->map(fn (PostView $p) => $p->toArray())->all(), 41 + 'cid' => $this->cid, 42 + 'cursor' => $this->cursor, 43 + ]; 44 + } 45 + }
+45
src/Data/Responses/Bsky/Feed/GetRepostedByResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetRepostedByResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $repostedBy 16 + */ 17 + public function __construct( 18 + public readonly string $uri, 19 + public readonly Collection $repostedBy, 20 + public readonly ?string $cid = null, 21 + public readonly ?string $cursor = null, 22 + ) {} 23 + 24 + public static function fromArray(array $data): self 25 + { 26 + return new self( 27 + uri: $data['uri'], 28 + repostedBy: collect($data['repostedBy'] ?? [])->map( 29 + fn (array $profile) => ProfileView::fromArray($profile) 30 + ), 31 + cid: $data['cid'] ?? null, 32 + cursor: $data['cursor'] ?? null, 33 + ); 34 + } 35 + 36 + public function toArray(): array 37 + { 38 + return [ 39 + 'uri' => $this->uri, 40 + 'repostedBy' => $this->repostedBy->map(fn (ProfileView $p) => $p->toArray())->all(), 41 + 'cid' => $this->cid, 42 + 'cursor' => $this->cursor, 43 + ]; 44 + } 45 + }
+39
src/Data/Responses/Bsky/Feed/GetSuggestedFeedsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetSuggestedFeedsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, GeneratorView> $feeds 16 + */ 17 + public function __construct( 18 + public readonly Collection $feeds, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + feeds: collect($data['feeds'] ?? [])->map( 26 + fn (array $feed) => GeneratorView::fromArray($feed) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'feeds' => $this->feeds->map(fn (GeneratorView $f) => $f->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Bsky/Feed/GetTimelineResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetTimelineResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, FeedViewPost> $feed 16 + */ 17 + public function __construct( 18 + public readonly Collection $feed, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + feed: collect($data['feed'] ?? [])->map( 26 + fn (array $post) => FeedViewPost::fromArray($post) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+42
src/Data/Responses/Bsky/Feed/SearchPostsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class SearchPostsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, PostView> $posts 16 + */ 17 + public function __construct( 18 + public readonly Collection $posts, 19 + public readonly ?string $cursor = null, 20 + public readonly ?int $hitsTotal = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + posts: collect($data['posts'] ?? [])->map( 27 + fn (array $post) => PostView::fromArray($post) 28 + ), 29 + cursor: $data['cursor'] ?? null, 30 + hitsTotal: $data['hitsTotal'] ?? null, 31 + ); 32 + } 33 + 34 + public function toArray(): array 35 + { 36 + return [ 37 + 'posts' => $this->posts->map(fn (PostView $p) => $p->toArray())->all(), 38 + 'cursor' => $this->cursor, 39 + 'hitsTotal' => $this->hitsTotal, 40 + ]; 41 + } 42 + }
+42
src/Data/Responses/Bsky/Graph/GetFollowersResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetFollowersResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $followers 16 + */ 17 + public function __construct( 18 + public readonly ProfileView $subject, 19 + public readonly Collection $followers, 20 + public readonly ?string $cursor = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + subject: ProfileView::fromArray($data['subject']), 27 + followers: collect($data['followers'] ?? [])->map( 28 + fn (array $profile) => ProfileView::fromArray($profile) 29 + ), 30 + cursor: $data['cursor'] ?? null, 31 + ); 32 + } 33 + 34 + public function toArray(): array 35 + { 36 + return [ 37 + 'subject' => $this->subject->toArray(), 38 + 'followers' => $this->followers->map(fn (ProfileView $p) => $p->toArray())->all(), 39 + 'cursor' => $this->cursor, 40 + ]; 41 + } 42 + }
+42
src/Data/Responses/Bsky/Graph/GetFollowsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetFollowsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $follows 16 + */ 17 + public function __construct( 18 + public readonly ProfileView $subject, 19 + public readonly Collection $follows, 20 + public readonly ?string $cursor = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + subject: ProfileView::fromArray($data['subject']), 27 + follows: collect($data['follows'] ?? [])->map( 28 + fn (array $profile) => ProfileView::fromArray($profile) 29 + ), 30 + cursor: $data['cursor'] ?? null, 31 + ); 32 + } 33 + 34 + public function toArray(): array 35 + { 36 + return [ 37 + 'subject' => $this->subject->toArray(), 38 + 'follows' => $this->follows->map(fn (ProfileView $p) => $p->toArray())->all(), 39 + 'cursor' => $this->cursor, 40 + ]; 41 + } 42 + }
+42
src/Data/Responses/Bsky/Graph/GetKnownFollowersResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetKnownFollowersResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $followers 16 + */ 17 + public function __construct( 18 + public readonly ProfileView $subject, 19 + public readonly Collection $followers, 20 + public readonly ?string $cursor = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + subject: ProfileView::fromArray($data['subject']), 27 + followers: collect($data['followers'] ?? [])->map( 28 + fn (array $profile) => ProfileView::fromArray($profile) 29 + ), 30 + cursor: $data['cursor'] ?? null, 31 + ); 32 + } 33 + 34 + public function toArray(): array 35 + { 36 + return [ 37 + 'subject' => $this->subject->toArray(), 38 + 'followers' => $this->followers->map(fn (ProfileView $p) => $p->toArray())->all(), 39 + 'cursor' => $this->cursor, 40 + ]; 41 + } 42 + }
+43
src/Data/Responses/Bsky/Graph/GetListResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\ListItemView; 8 + use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\ListView; 9 + 10 + /** 11 + * @implements Arrayable<string, mixed> 12 + */ 13 + class GetListResponse implements Arrayable 14 + { 15 + /** 16 + * @param Collection<int, ListItemView> $items 17 + */ 18 + public function __construct( 19 + public readonly ListView $list, 20 + public readonly Collection $items, 21 + public readonly ?string $cursor = null, 22 + ) {} 23 + 24 + public static function fromArray(array $data): self 25 + { 26 + return new self( 27 + list: ListView::fromArray($data['list']), 28 + items: collect($data['items'] ?? [])->map( 29 + fn (array $item) => ListItemView::fromArray($item) 30 + ), 31 + cursor: $data['cursor'] ?? null, 32 + ); 33 + } 34 + 35 + public function toArray(): array 36 + { 37 + return [ 38 + 'list' => $this->list->toArray(), 39 + 'items' => $this->items->map(fn (ListItemView $i) => $i->toArray())->all(), 40 + 'cursor' => $this->cursor, 41 + ]; 42 + } 43 + }
+39
src/Data/Responses/Bsky/Graph/GetListsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\ListView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetListsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ListView> $lists 16 + */ 17 + public function __construct( 18 + public readonly Collection $lists, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + lists: collect($data['lists'] ?? [])->map( 26 + fn (array $list) => ListView::fromArray($list) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'lists' => $this->lists->map(fn (ListView $l) => $l->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+36
src/Data/Responses/Bsky/Graph/GetRelationshipsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class GetRelationshipsResponse implements Arrayable 12 + { 13 + /** 14 + * @param Collection<int, mixed> $relationships Collection of Relationship or NotFoundActor objects 15 + */ 16 + public function __construct( 17 + public readonly Collection $relationships, 18 + public readonly ?string $actor = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + relationships: collect($data['relationships'] ?? []), 25 + actor: $data['actor'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'relationships' => $this->relationships->all(), 33 + 'actor' => $this->actor, 34 + ]; 35 + } 36 + }
+36
src/Data/Responses/Bsky/Graph/GetStarterPacksResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\StarterPackViewBasic; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetStarterPacksResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, StarterPackViewBasic> $starterPacks 16 + */ 17 + public function __construct( 18 + public readonly Collection $starterPacks, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + starterPacks: collect($data['starterPacks'] ?? [])->map( 25 + fn (array $pack) => StarterPackViewBasic::fromArray($pack) 26 + ), 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'starterPacks' => $this->starterPacks->map(fn (StarterPackViewBasic $p) => $p->toArray())->all(), 34 + ]; 35 + } 36 + }
+39
src/Data/Responses/Bsky/Graph/GetSuggestedFollowsByActorResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetSuggestedFollowsByActorResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ProfileView> $suggestions 16 + */ 17 + public function __construct( 18 + public readonly Collection $suggestions, 19 + public readonly ?bool $isFallback = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + suggestions: collect($data['suggestions'] ?? [])->map( 26 + fn (array $profile) => ProfileView::fromArray($profile) 27 + ), 28 + isFallback: $data['isFallback'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'suggestions' => $this->suggestions->map(fn (ProfileView $p) => $p->toArray())->all(), 36 + 'isFallback' => $this->isFallback, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Bsky/Labeler/GetServicesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Bsky\Labeler; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\App\Bsky\Labeler\Defs\LabelerView; 8 + use SocialDept\AtpSchema\Generated\App\Bsky\Labeler\Defs\LabelerViewDetailed; 9 + 10 + /** 11 + * @implements Arrayable<string, mixed> 12 + */ 13 + class GetServicesResponse implements Arrayable 14 + { 15 + /** 16 + * @param Collection<int, LabelerView|LabelerViewDetailed> $views 17 + */ 18 + public function __construct( 19 + public readonly Collection $views, 20 + ) {} 21 + 22 + public static function fromArray(array $data, bool $detailed = false): self 23 + { 24 + return new self( 25 + views: collect($data['views'] ?? [])->map( 26 + fn (array $view) => $detailed 27 + ? LabelerViewDetailed::fromArray($view) 28 + : LabelerView::fromArray($view) 29 + ), 30 + ); 31 + } 32 + 33 + public function toArray(): array 34 + { 35 + return [ 36 + 'views' => $this->views->map(fn (LabelerView|LabelerViewDetailed $v) => $v->toArray())->all(), 37 + ]; 38 + } 39 + }
+36
src/Data/Responses/Chat/Convo/GetLogResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Chat\Convo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class GetLogResponse implements Arrayable 12 + { 13 + /** 14 + * @param Collection<int, mixed> $logs Collection of log event objects (LogBeginConvo, LogCreateMessage, etc.) 15 + */ 16 + public function __construct( 17 + public readonly Collection $logs, 18 + public readonly ?string $cursor = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + logs: collect($data['logs'] ?? []), 25 + cursor: $data['cursor'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'logs' => $this->logs->all(), 33 + 'cursor' => $this->cursor, 34 + ]; 35 + } 36 + }
+46
src/Data/Responses/Chat/Convo/GetMessagesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Chat\Convo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\DeletedMessageView; 8 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\MessageView; 9 + 10 + /** 11 + * @implements Arrayable<string, mixed> 12 + */ 13 + class GetMessagesResponse implements Arrayable 14 + { 15 + /** 16 + * @param Collection<int, MessageView|DeletedMessageView> $messages 17 + */ 18 + public function __construct( 19 + public readonly Collection $messages, 20 + public readonly ?string $cursor = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + messages: collect($data['messages'] ?? [])->map( 27 + function (array $message) { 28 + if (isset($message['$type']) && $message['$type'] === 'chat.bsky.convo.defs#deletedMessageView') { 29 + return DeletedMessageView::fromArray($message); 30 + } 31 + 32 + return MessageView::fromArray($message); 33 + } 34 + ), 35 + cursor: $data['cursor'] ?? null, 36 + ); 37 + } 38 + 39 + public function toArray(): array 40 + { 41 + return [ 42 + 'messages' => $this->messages->map(fn (MessageView|DeletedMessageView $m) => $m->toArray())->all(), 43 + 'cursor' => $this->cursor, 44 + ]; 45 + } 46 + }
+32
src/Data/Responses/Chat/Convo/LeaveConvoResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Chat\Convo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class LeaveConvoResponse implements Arrayable 11 + { 12 + public function __construct( 13 + public readonly string $convoId, 14 + public readonly string $rev, 15 + ) {} 16 + 17 + public static function fromArray(array $data): self 18 + { 19 + return new self( 20 + convoId: $data['convoId'], 21 + rev: $data['rev'], 22 + ); 23 + } 24 + 25 + public function toArray(): array 26 + { 27 + return [ 28 + 'convoId' => $this->convoId, 29 + 'rev' => $this->rev, 30 + ]; 31 + } 32 + }
+39
src/Data/Responses/Chat/Convo/ListConvosResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Chat\Convo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\ConvoView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class ListConvosResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ConvoView> $convos 16 + */ 17 + public function __construct( 18 + public readonly Collection $convos, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + convos: collect($data['convos'] ?? [])->map( 26 + fn (array $convo) => ConvoView::fromArray($convo) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'convos' => $this->convos->map(fn (ConvoView $c) => $c->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+36
src/Data/Responses/Chat/Convo/SendMessageBatchResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Chat\Convo; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\MessageView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class SendMessageBatchResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, MessageView> $items 16 + */ 17 + public function __construct( 18 + public readonly Collection $items, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + items: collect($data['items'] ?? [])->map( 25 + fn (array $item) => MessageView::fromArray($item) 26 + ), 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return [ 33 + 'items' => $this->items->map(fn (MessageView $m) => $m->toArray())->all(), 34 + ]; 35 + } 36 + }
+25
src/Data/Responses/EmptyResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * Response class for endpoints that return empty objects. 9 + * 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class EmptyResponse implements Arrayable 13 + { 14 + public function __construct() {} 15 + 16 + public static function fromArray(array $data): self 17 + { 18 + return new self; 19 + } 20 + 21 + public function toArray(): array 22 + { 23 + return []; 24 + } 25 + }
+39
src/Data/Responses/Ozone/Moderation/QueryEventsResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Ozone\Moderation; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\ModEventView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class QueryEventsResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, ModEventView> $events 16 + */ 17 + public function __construct( 18 + public readonly Collection $events, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + events: collect($data['events'] ?? [])->map( 26 + fn (array $event) => ModEventView::fromArray($event) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'events' => $this->events->map(fn (ModEventView $e) => $e->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Ozone/Moderation/QueryStatusesResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Ozone\Moderation; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\SubjectStatusView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class QueryStatusesResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, SubjectStatusView> $subjectStatuses 16 + */ 17 + public function __construct( 18 + public readonly Collection $subjectStatuses, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + subjectStatuses: collect($data['subjectStatuses'] ?? [])->map( 26 + fn (array $status) => SubjectStatusView::fromArray($status) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'subjectStatuses' => $this->subjectStatuses->map(fn (SubjectStatusView $s) => $s->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+39
src/Data/Responses/Ozone/Moderation/SearchReposResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Ozone\Moderation; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\RepoView; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class SearchReposResponse implements Arrayable 13 + { 14 + /** 15 + * @param Collection<int, RepoView> $repos 16 + */ 17 + public function __construct( 18 + public readonly Collection $repos, 19 + public readonly ?string $cursor = null, 20 + ) {} 21 + 22 + public static function fromArray(array $data): self 23 + { 24 + return new self( 25 + repos: collect($data['repos'] ?? [])->map( 26 + fn (array $repo) => RepoView::fromArray($repo) 27 + ), 28 + cursor: $data['cursor'] ?? null, 29 + ); 30 + } 31 + 32 + public function toArray(): array 33 + { 34 + return [ 35 + 'repos' => $this->repos->map(fn (RepoView $r) => $r->toArray())->all(), 36 + 'cursor' => $this->cursor, 37 + ]; 38 + } 39 + }
+46
src/Data/Responses/Ozone/Server/GetConfigResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Ozone\Server; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Server\GetConfig\ServiceConfig; 7 + use SocialDept\AtpSchema\Generated\Tools\Ozone\Server\GetConfig\ViewerConfig; 8 + 9 + /** 10 + * @implements Arrayable<string, mixed> 11 + */ 12 + class GetConfigResponse implements Arrayable 13 + { 14 + public function __construct( 15 + public readonly ?ServiceConfig $appview = null, 16 + public readonly ?ServiceConfig $pds = null, 17 + public readonly ?ServiceConfig $blobDivert = null, 18 + public readonly ?ServiceConfig $chat = null, 19 + public readonly ?ViewerConfig $viewer = null, 20 + public readonly ?string $verifierDid = null, 21 + ) {} 22 + 23 + public static function fromArray(array $data): self 24 + { 25 + return new self( 26 + appview: isset($data['appview']) ? ServiceConfig::fromArray($data['appview']) : null, 27 + pds: isset($data['pds']) ? ServiceConfig::fromArray($data['pds']) : null, 28 + blobDivert: isset($data['blobDivert']) ? ServiceConfig::fromArray($data['blobDivert']) : null, 29 + chat: isset($data['chat']) ? ServiceConfig::fromArray($data['chat']) : null, 30 + viewer: isset($data['viewer']) ? ViewerConfig::fromArray($data['viewer']) : null, 31 + verifierDid: $data['verifierDid'] ?? null, 32 + ); 33 + } 34 + 35 + public function toArray(): array 36 + { 37 + return [ 38 + 'appview' => $this->appview?->toArray(), 39 + 'pds' => $this->pds?->toArray(), 40 + 'blobDivert' => $this->blobDivert?->toArray(), 41 + 'chat' => $this->chat?->toArray(), 42 + 'viewer' => $this->viewer?->toArray(), 43 + 'verifierDid' => $this->verifierDid, 44 + ]; 45 + } 46 + }
+36
src/Data/Responses/Ozone/Team/ListMembersResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Ozone\Team; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Support\Collection; 7 + 8 + /** 9 + * @implements Arrayable<string, mixed> 10 + */ 11 + class ListMembersResponse implements Arrayable 12 + { 13 + /** 14 + * @param Collection<int, array<string, mixed>> $members Collection of team member objects 15 + */ 16 + public function __construct( 17 + public readonly Collection $members, 18 + public readonly ?string $cursor = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + members: collect($data['members'] ?? []), 25 + cursor: $data['cursor'] ?? null, 26 + ); 27 + } 28 + 29 + public function toArray(): array 30 + { 31 + return [ 32 + 'members' => $this->members->all(), 33 + 'cursor' => $this->cursor, 34 + ]; 35 + } 36 + }
+44
src/Data/Responses/Ozone/Team/MemberResponse.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data\Responses\Ozone\Team; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, mixed> 9 + */ 10 + class MemberResponse implements Arrayable 11 + { 12 + public function __construct( 13 + public readonly string $did, 14 + public readonly bool $disabled, 15 + public readonly ?string $role = null, 16 + public readonly ?string $createdAt = null, 17 + public readonly ?string $updatedAt = null, 18 + public readonly ?string $lastUpdatedBy = null, 19 + ) {} 20 + 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + did: $data['did'], 25 + disabled: $data['disabled'] ?? false, 26 + role: $data['role'] ?? null, 27 + createdAt: $data['createdAt'] ?? null, 28 + updatedAt: $data['updatedAt'] ?? null, 29 + lastUpdatedBy: $data['lastUpdatedBy'] ?? null, 30 + ); 31 + } 32 + 33 + public function toArray(): array 34 + { 35 + return array_filter([ 36 + 'did' => $this->did, 37 + 'disabled' => $this->disabled, 38 + 'role' => $this->role, 39 + 'createdAt' => $this->createdAt, 40 + 'updatedAt' => $this->updatedAt, 41 + 'lastUpdatedBy' => $this->lastUpdatedBy, 42 + ], fn ($v) => $v !== null); 43 + } 44 + }
+6 -1
src/Data/StrongRef.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Data; 4 4 5 - class StrongRef 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, string> 9 + */ 10 + class StrongRef implements Arrayable 6 11 { 7 12 public function __construct( 8 13 public readonly string $uri,
+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 + }
+12
src/Enums/Nsid/AtprotoIdentity.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum AtprotoIdentity: string 8 + { 9 + use HasScopeHelpers; 10 + case ResolveHandle = 'com.atproto.identity.resolveHandle'; 11 + case UpdateHandle = 'com.atproto.identity.updateHandle'; 12 + }
+17
src/Enums/Nsid/AtprotoRepo.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum AtprotoRepo: string 8 + { 9 + use HasScopeHelpers; 10 + case CreateRecord = 'com.atproto.repo.createRecord'; 11 + case DeleteRecord = 'com.atproto.repo.deleteRecord'; 12 + case PutRecord = 'com.atproto.repo.putRecord'; 13 + case GetRecord = 'com.atproto.repo.getRecord'; 14 + case ListRecords = 'com.atproto.repo.listRecords'; 15 + case UploadBlob = 'com.atproto.repo.uploadBlob'; 16 + case DescribeRepo = 'com.atproto.repo.describeRepo'; 17 + }
+14
src/Enums/Nsid/AtprotoServer.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum AtprotoServer: string 8 + { 9 + use HasScopeHelpers; 10 + case CreateSession = 'com.atproto.server.createSession'; 11 + case RefreshSession = 'com.atproto.server.refreshSession'; 12 + case GetSession = 'com.atproto.server.getSession'; 13 + case DescribeServer = 'com.atproto.server.describeServer'; 14 + }
+19
src/Enums/Nsid/AtprotoSync.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum AtprotoSync: string 8 + { 9 + use HasScopeHelpers; 10 + case GetBlob = 'com.atproto.sync.getBlob'; 11 + case GetRepo = 'com.atproto.sync.getRepo'; 12 + case ListRepos = 'com.atproto.sync.listRepos'; 13 + case ListReposByCollection = 'com.atproto.sync.listReposByCollection'; 14 + case GetLatestCommit = 'com.atproto.sync.getLatestCommit'; 15 + case GetRecord = 'com.atproto.sync.getRecord'; 16 + case ListBlobs = 'com.atproto.sync.listBlobs'; 17 + case GetBlocks = 'com.atproto.sync.getBlocks'; 18 + case GetRepoStatus = 'com.atproto.sync.getRepoStatus'; 19 + }
+18
src/Enums/Nsid/BskyActor.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum BskyActor: string 8 + { 9 + use HasScopeHelpers; 10 + case GetProfile = 'app.bsky.actor.getProfile'; 11 + case GetProfiles = 'app.bsky.actor.getProfiles'; 12 + case GetSuggestions = 'app.bsky.actor.getSuggestions'; 13 + case SearchActors = 'app.bsky.actor.searchActors'; 14 + case SearchActorsTypeahead = 'app.bsky.actor.searchActorsTypeahead'; 15 + 16 + // Record type 17 + case Profile = 'app.bsky.actor.profile'; 18 + }
+29
src/Enums/Nsid/BskyFeed.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum BskyFeed: string 8 + { 9 + use HasScopeHelpers; 10 + case DescribeFeedGenerator = 'app.bsky.feed.describeFeedGenerator'; 11 + case GetAuthorFeed = 'app.bsky.feed.getAuthorFeed'; 12 + case GetActorFeeds = 'app.bsky.feed.getActorFeeds'; 13 + case GetActorLikes = 'app.bsky.feed.getActorLikes'; 14 + case GetFeed = 'app.bsky.feed.getFeed'; 15 + case GetFeedGenerator = 'app.bsky.feed.getFeedGenerator'; 16 + case GetFeedGenerators = 'app.bsky.feed.getFeedGenerators'; 17 + case GetLikes = 'app.bsky.feed.getLikes'; 18 + case GetPostThread = 'app.bsky.feed.getPostThread'; 19 + case GetPosts = 'app.bsky.feed.getPosts'; 20 + case GetQuotes = 'app.bsky.feed.getQuotes'; 21 + case GetRepostedBy = 'app.bsky.feed.getRepostedBy'; 22 + case GetSuggestedFeeds = 'app.bsky.feed.getSuggestedFeeds'; 23 + case GetTimeline = 'app.bsky.feed.getTimeline'; 24 + case SearchPosts = 'app.bsky.feed.searchPosts'; 25 + 26 + // Record types 27 + case Post = 'app.bsky.feed.post'; 28 + case Like = 'app.bsky.feed.like'; 29 + }
+22
src/Enums/Nsid/BskyGraph.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum BskyGraph: string 8 + { 9 + use HasScopeHelpers; 10 + case GetFollowers = 'app.bsky.graph.getFollowers'; 11 + case GetFollows = 'app.bsky.graph.getFollows'; 12 + case GetKnownFollowers = 'app.bsky.graph.getKnownFollowers'; 13 + case GetList = 'app.bsky.graph.getList'; 14 + case GetLists = 'app.bsky.graph.getLists'; 15 + case GetRelationships = 'app.bsky.graph.getRelationships'; 16 + case GetStarterPack = 'app.bsky.graph.getStarterPack'; 17 + case GetStarterPacks = 'app.bsky.graph.getStarterPacks'; 18 + case GetSuggestedFollowsByActor = 'app.bsky.graph.getSuggestedFollowsByActor'; 19 + 20 + // Record type 21 + case Follow = 'app.bsky.graph.follow'; 22 + }
+11
src/Enums/Nsid/BskyLabeler.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum BskyLabeler: string 8 + { 9 + use HasScopeHelpers; 10 + case GetServices = 'app.bsky.labeler.getServices'; 11 + }
+13
src/Enums/Nsid/ChatActor.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum ChatActor: string 8 + { 9 + use HasScopeHelpers; 10 + case GetActorMetadata = 'chat.bsky.actor.getActorMetadata'; 11 + case ExportAccountData = 'chat.bsky.actor.exportAccountData'; 12 + case DeleteAccount = 'chat.bsky.actor.deleteAccount'; 13 + }
+22
src/Enums/Nsid/ChatConvo.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum ChatConvo: string 8 + { 9 + use HasScopeHelpers; 10 + case GetConvo = 'chat.bsky.convo.getConvo'; 11 + case GetConvoForMembers = 'chat.bsky.convo.getConvoForMembers'; 12 + case ListConvos = 'chat.bsky.convo.listConvos'; 13 + case GetMessages = 'chat.bsky.convo.getMessages'; 14 + case SendMessage = 'chat.bsky.convo.sendMessage'; 15 + case SendMessageBatch = 'chat.bsky.convo.sendMessageBatch'; 16 + case DeleteMessageForSelf = 'chat.bsky.convo.deleteMessageForSelf'; 17 + case UpdateRead = 'chat.bsky.convo.updateRead'; 18 + case MuteConvo = 'chat.bsky.convo.muteConvo'; 19 + case UnmuteConvo = 'chat.bsky.convo.unmuteConvo'; 20 + case LeaveConvo = 'chat.bsky.convo.leaveConvo'; 21 + case GetLog = 'chat.bsky.convo.getLog'; 22 + }
+34
src/Enums/Nsid/Concerns/HasScopeHelpers.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid\Concerns; 4 + 5 + trait HasScopeHelpers 6 + { 7 + /** 8 + * Get the RPC scope format for this NSID. 9 + * 10 + * @example BskyActor::GetProfile->rpc() // "rpc:app.bsky.actor.getProfile" 11 + */ 12 + public function rpc(): string 13 + { 14 + return 'rpc:' . $this->value; 15 + } 16 + 17 + /** 18 + * Get the repo scope format for this NSID. 19 + * 20 + * @example BskyGraph::Follow->repo(['create']) // "repo:app.bsky.graph.follow?action=create" 21 + * @example BskyFeed::Post->repo(['create', 'delete']) // "repo:app.bsky.feed.post?action=create&action=delete" 22 + * @example BskyFeed::Post->repo() // "repo:app.bsky.feed.post" 23 + */ 24 + public function repo(array $actions = []): string 25 + { 26 + $scope = 'repo:' . $this->value; 27 + 28 + if (! empty($actions)) { 29 + $scope .= '?' . implode('&', array_map(fn ($action) => "action={$action}", $actions)); 30 + } 31 + 32 + return $scope; 33 + } 34 + }
+18
src/Enums/Nsid/OzoneModeration.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum OzoneModeration: string 8 + { 9 + use HasScopeHelpers; 10 + case GetEvent = 'tools.ozone.moderation.getEvent'; 11 + case GetEvents = 'tools.ozone.moderation.getEvents'; 12 + case GetRecord = 'tools.ozone.moderation.getRecord'; 13 + case GetRepo = 'tools.ozone.moderation.getRepo'; 14 + case QueryEvents = 'tools.ozone.moderation.queryEvents'; 15 + case QueryStatuses = 'tools.ozone.moderation.queryStatuses'; 16 + case SearchRepos = 'tools.ozone.moderation.searchRepos'; 17 + case EmitEvent = 'tools.ozone.moderation.emitEvent'; 18 + }
+12
src/Enums/Nsid/OzoneServer.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum OzoneServer: string 8 + { 9 + use HasScopeHelpers; 10 + case GetBlob = 'tools.ozone.server.getBlob'; 11 + case GetConfig = 'tools.ozone.server.getConfig'; 12 + }
+15
src/Enums/Nsid/OzoneTeam.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum OzoneTeam: string 8 + { 9 + use HasScopeHelpers; 10 + case GetMember = 'tools.ozone.team.getMember'; 11 + case ListMembers = 'tools.ozone.team.listMembers'; 12 + case AddMember = 'tools.ozone.team.addMember'; 13 + case UpdateMember = 'tools.ozone.team.updateMember'; 14 + case DeleteMember = 'tools.ozone.team.deleteMember'; 15 + }
+74
src/Enums/Scope.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + use BackedEnum; 6 + 7 + enum Scope: string 8 + { 9 + // Transition scopes (current) 10 + case Atproto = 'atproto'; 11 + case TransitionGeneric = 'transition:generic'; 12 + case TransitionEmail = 'transition:email'; 13 + case TransitionChat = 'transition:chat.bsky'; 14 + 15 + /** 16 + * Build a repo scope string for record operations. 17 + * 18 + * @param string|BackedEnum $collection The collection NSID (e.g., 'app.bsky.feed.post') 19 + * @param array|null $actions The action (create, update, delete) 20 + * 21 + * @return string 22 + */ 23 + public static function repo(string|BackedEnum $collection, ?array $actions = []): string 24 + { 25 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 26 + $scope = "repo:{$collection}"; 27 + 28 + if (!empty($actions)) { 29 + $scope .= '?' . implode('&', array_map(fn ($action) => "action={$action}", $actions)); 30 + } 31 + 32 + return $scope; 33 + } 34 + 35 + /** 36 + * Build an RPC scope string for endpoint access. 37 + * 38 + * @param string $lxm The lexicon method ID (e.g., 'app.bsky.feed.getTimeline') 39 + */ 40 + public static function rpc(string $lxm): string 41 + { 42 + return "rpc:{$lxm}"; 43 + } 44 + 45 + /** 46 + * Build a blob scope string for uploads. 47 + * 48 + * @param string|null $mimeType The mime type pattern (e.g., 'image/*', '*\/*') 49 + */ 50 + public static function blob(?string $mimeType = null): string 51 + { 52 + return 'blob:'.($mimeType ?? '*/*'); 53 + } 54 + 55 + /** 56 + * Build an account scope string. 57 + * 58 + * @param string $attr The account attribute (e.g., 'email', 'status') 59 + */ 60 + public static function account(string $attr): string 61 + { 62 + return "account:{$attr}"; 63 + } 64 + 65 + /** 66 + * Build an identity scope string. 67 + * 68 + * @param string $attr The identity attribute (e.g., 'handle') 69 + */ 70 + public static function identity(string $attr): string 71 + { 72 + return "identity:{$attr}"; 73 + } 74 + }
+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 + }
+27
src/Events/SessionAuthenticated.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + use Illuminate\Queue\SerializesModels; 7 + use SocialDept\AtpClient\Data\AccessToken; 8 + use SocialDept\AtpClient\Enums\AuthType; 9 + 10 + class SessionAuthenticated 11 + { 12 + use Dispatchable, SerializesModels; 13 + 14 + public function __construct( 15 + public readonly AccessToken $token, 16 + ) {} 17 + 18 + public function isOAuth(): bool 19 + { 20 + return $this->token->authType === AuthType::OAuth; 21 + } 22 + 23 + public function isLegacy(): bool 24 + { 25 + return $this->token->authType === AuthType::Legacy; 26 + } 27 + }
+16
src/Events/SessionRefreshing.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + use Illuminate\Queue\SerializesModels; 7 + use SocialDept\AtpClient\Session\Session; 8 + 9 + class SessionRefreshing 10 + { 11 + use Dispatchable, SerializesModels; 12 + 13 + public function __construct( 14 + public readonly Session $session, 15 + ) {} 16 + }
+18
src/Events/SessionUpdated.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + use Illuminate\Queue\SerializesModels; 7 + use SocialDept\AtpClient\Data\AccessToken; 8 + use SocialDept\AtpClient\Session\Session; 9 + 10 + class SessionUpdated 11 + { 12 + use Dispatchable, SerializesModels; 13 + 14 + public function __construct( 15 + public readonly Session $session, 16 + public readonly AccessToken $token, 17 + ) {} 18 + }
-17
src/Events/TokenRefreshed.php
··· 1 - <?php 2 - 3 - namespace SocialDept\AtpClient\Events; 4 - 5 - use Illuminate\Foundation\Events\Dispatchable; 6 - use Illuminate\Queue\SerializesModels; 7 - use SocialDept\AtpClient\Data\AccessToken; 8 - 9 - class TokenRefreshed 10 - { 11 - use Dispatchable, SerializesModels; 12 - 13 - public function __construct( 14 - public readonly string $identifier, 15 - public readonly AccessToken $token, 16 - ) {} 17 - }
-16
src/Events/TokenRefreshing.php
··· 1 - <?php 2 - 3 - namespace SocialDept\AtpClient\Events; 4 - 5 - use Illuminate\Foundation\Events\Dispatchable; 6 - use Illuminate\Queue\SerializesModels; 7 - 8 - class TokenRefreshing 9 - { 10 - use Dispatchable, SerializesModels; 11 - 12 - public function __construct( 13 - public readonly string $identifier, 14 - public readonly string $refreshToken, 15 - ) {} 16 - }
+31
src/Exceptions/AtpResponseException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Exceptions; 4 + 5 + use Illuminate\Http\Client\Response; 6 + 7 + class AtpResponseException extends \Exception 8 + { 9 + public function __construct( 10 + public readonly string $errorCode, 11 + public readonly string $errorMessage, 12 + public readonly int $httpStatus, 13 + public readonly string $endpoint, 14 + public readonly array $responseBody, 15 + ) { 16 + parent::__construct("{$errorCode}: {$errorMessage}", $httpStatus); 17 + } 18 + 19 + public static function fromResponse(Response $response, string $endpoint): self 20 + { 21 + $body = $response->json() ?? []; 22 + 23 + return new self( 24 + errorCode: $body['error'] ?? 'UnknownError', 25 + errorMessage: $body['message'] ?? $response->body(), 26 + httpStatus: $response->status(), 27 + endpoint: $endpoint, 28 + responseBody: $body, 29 + ); 30 + } 31 + }
+11
src/Exceptions/HandleResolutionException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Exceptions; 4 + 5 + class HandleResolutionException extends \Exception 6 + { 7 + public function __construct(string $handle) 8 + { 9 + parent::__construct("Unable to resolve handle '{$handle}' to a DID"); 10 + } 11 + }
+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 + }
+4 -3
src/Facades/Atp.php
··· 3 3 namespace SocialDept\AtpClient\Facades; 4 4 5 5 use Illuminate\Support\Facades\Facade; 6 + use SocialDept\AtpClient\AtpClient; 6 7 use SocialDept\AtpClient\Auth\OAuthEngine; 7 - use SocialDept\AtpClient\Client\AtpClient; 8 8 use SocialDept\AtpClient\Contracts\CredentialProvider; 9 9 10 10 /** 11 - * @method static AtpClient as(string $identifier) 12 - * @method static AtpClient login(string $identifier, 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 + * @method static AtpClient public(?string $service = null) 14 15 * @method static void setDefaultProvider(CredentialProvider $provider) 15 16 * 16 17 * @see \SocialDept\AtpClient\AtpClientServiceProvider
+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 + }
+1 -1
src/Http/DPoPClient.php
··· 67 67 $request = $request->withHeader('DPoP', $dpopProof); 68 68 69 69 if ($accessToken) { 70 - $request = $request->withHeader('Authorization', 'Bearer '.$accessToken); 70 + $request = $request->withHeader('Authorization', 'DPoP '.$accessToken); 71 71 } 72 72 73 73 return $request;
+79 -14
src/Http/HasHttp.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Http; 4 4 5 + use BackedEnum; 5 6 use Illuminate\Http\Client\Response as LaravelResponse; 7 + use Illuminate\Support\Facades\Http; 6 8 use InvalidArgumentException; 9 + use SocialDept\AtpClient\Auth\ScopeChecker; 10 + use SocialDept\AtpClient\Enums\Scope; 11 + use SocialDept\AtpClient\Exceptions\AtpResponseException; 7 12 use SocialDept\AtpClient\Exceptions\ValidationException; 8 13 use SocialDept\AtpClient\Session\Session; 9 14 use SocialDept\AtpClient\Session\SessionManager; ··· 11 16 12 17 trait HasHttp 13 18 { 14 - protected SessionManager $sessions; 19 + protected ?SessionManager $sessions = null; 15 20 16 - protected string $identifier; 21 + protected ?string $did = null; 17 22 18 - protected DPoPClient $dpopClient; 23 + protected ?DPoPClient $dpopClient = null; 24 + 25 + protected ?ScopeChecker $scopeChecker = null; 19 26 20 27 /** 21 28 * Make XRPC call 22 29 */ 23 30 protected function call( 24 - string $endpoint, 31 + string|BackedEnum $endpoint, 25 32 string $method, 26 33 ?array $params = null, 27 34 ?array $body = null 28 35 ): Response { 29 - $session = $this->sessions->ensureValid($this->identifier); 36 + $endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint; 37 + $session = $this->sessions->ensureValid($this->did); 30 38 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 31 39 32 40 $params = array_filter($params ?? [], fn ($v) => ! is_null($v)); ··· 40 48 default => throw new InvalidArgumentException("Unsupported method: {$method}"), 41 49 }; 42 50 43 - if (Schema::exists($endpoint)) { 51 + if ($response->failed() || isset($response->json()['error'])) { 52 + throw AtpResponseException::fromResponse($response, $endpoint); 53 + } 54 + 55 + if (config('atp-client.schema_validation') && Schema::exists($endpoint)) { 44 56 $this->validateResponse($endpoint, $response); 45 57 } 46 58 ··· 48 60 } 49 61 50 62 /** 51 - * Build authenticated request with DPoP proof and automatic nonce retry 63 + * Build authenticated request. 64 + * 65 + * OAuth sessions use DPoP proof with Bearer token. 66 + * Legacy sessions use plain Bearer token. 52 67 */ 53 68 protected function buildAuthenticatedRequest( 54 69 Session $session, 55 70 string $url, 56 71 string $method 57 72 ): \Illuminate\Http\Client\PendingRequest { 73 + if ($session->isLegacy()) { 74 + return Http::withHeader('Authorization', 'Bearer '.$session->accessToken()); 75 + } 76 + 58 77 return $this->dpopClient->request( 59 78 pdsEndpoint: $session->pdsEndpoint(), 60 79 url: $url, ··· 75 94 76 95 $data = $response->json(); 77 96 78 - if (! Schema::validate($endpoint, $data)) { 79 - $errors = Schema::getErrors($endpoint, $data); 97 + $errors = Schema::validateWithErrors($endpoint, $data); 98 + 99 + if (! empty($errors)) { 80 100 throw new ValidationException($errors); 81 101 } 82 102 } ··· 84 104 /** 85 105 * Make GET request 86 106 */ 87 - protected function get(string $endpoint, array $params = []): Response 107 + public function get(string|BackedEnum $endpoint, array $params = []): Response 88 108 { 89 109 return $this->call($endpoint, 'GET', $params); 90 110 } ··· 92 112 /** 93 113 * Make POST request 94 114 */ 95 - protected function post(string $endpoint, array $body = []): Response 115 + public function post(string|BackedEnum $endpoint, array $body = []): Response 96 116 { 97 117 return $this->call($endpoint, 'POST', null, $body); 98 118 } ··· 100 120 /** 101 121 * Make DELETE request 102 122 */ 103 - protected function delete(string $endpoint, array $params = []): Response 123 + public function delete(string|BackedEnum $endpoint, array $params = []): Response 104 124 { 105 125 return $this->call($endpoint, 'DELETE', $params); 106 126 } ··· 108 128 /** 109 129 * Make POST request with raw binary body (for blob uploads) 110 130 */ 111 - protected function postBlob(string $endpoint, string $data, string $mimeType): Response 131 + public function postBlob(string|BackedEnum $endpoint, string $data, string $mimeType): Response 112 132 { 113 - $session = $this->sessions->ensureValid($this->identifier); 133 + $endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint; 134 + $session = $this->sessions->ensureValid($this->did); 114 135 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 115 136 116 137 $response = $this->buildAuthenticatedRequest($session, $url, 'POST') 117 138 ->withBody($data, $mimeType) 118 139 ->post($url); 140 + 141 + if ($response->failed() || isset($response->json()['error'])) { 142 + throw AtpResponseException::fromResponse($response, $endpoint); 143 + } 119 144 120 145 return new Response($response); 146 + } 147 + 148 + /** 149 + * Require specific scopes before making a request. 150 + * 151 + * Checks if the session has the required scopes. In strict mode, throws 152 + * MissingScopeException if scopes are missing. In permissive mode, logs 153 + * a warning but allows the request to proceed. 154 + * 155 + * @param string|Scope ...$scopes The required scopes 156 + * 157 + * @throws \SocialDept\AtpClient\Exceptions\MissingScopeException 158 + */ 159 + protected function requireScopes(string|Scope ...$scopes): void 160 + { 161 + $session = $this->sessions->session($this->did); 162 + 163 + $this->getScopeChecker()->checkOrFail($session, $scopes); 164 + } 165 + 166 + /** 167 + * Check if the session has a specific scope. 168 + */ 169 + protected function hasScope(string|Scope $scope): bool 170 + { 171 + $session = $this->sessions->session($this->did); 172 + 173 + return $this->getScopeChecker()->hasScope($session, $scope); 174 + } 175 + 176 + /** 177 + * Get the scope checker instance. 178 + */ 179 + protected function getScopeChecker(): ScopeChecker 180 + { 181 + if ($this->scopeChecker === null) { 182 + $this->scopeChecker = app(ScopeChecker::class); 183 + } 184 + 185 + return $this->scopeChecker; 121 186 } 122 187 }
+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 + }
+12 -9
src/Providers/ArrayCredentialProvider.php
··· 15 15 */ 16 16 protected array $credentials = []; 17 17 18 - public function getCredentials(string $identifier): ?Credentials 18 + public function getCredentials(string $did): ?Credentials 19 19 { 20 - return $this->credentials[$identifier] ?? null; 20 + return $this->credentials[$did] ?? null; 21 21 } 22 22 23 - public function storeCredentials(string $identifier, AccessToken $token): void 23 + public function storeCredentials(string $did, AccessToken $token): void 24 24 { 25 - $this->credentials[$identifier] = new Credentials( 26 - identifier: $identifier, 25 + $this->credentials[$did] = new Credentials( 27 26 did: $token->did, 28 27 accessToken: $token->accessJwt, 29 28 refreshToken: $token->refreshJwt, 30 29 expiresAt: $token->expiresAt, 30 + handle: $token->handle, 31 + issuer: $token->issuer, 32 + scope: $token->scope, 33 + authType: $token->authType, 31 34 ); 32 35 } 33 36 34 - public function updateCredentials(string $identifier, AccessToken $token): void 37 + public function updateCredentials(string $did, AccessToken $token): void 35 38 { 36 - $this->storeCredentials($identifier, $token); 39 + $this->storeCredentials($did, $token); 37 40 } 38 41 39 - public function removeCredentials(string $identifier): void 42 + public function removeCredentials(string $did): void 40 43 { 41 - unset($this->credentials[$identifier]); 44 + unset($this->credentials[$did]); 42 45 } 43 46 }
+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 + }
+5 -186
src/RichText/TextBuilder.php
··· 2 2 3 3 namespace SocialDept\AtpClient\RichText; 4 4 5 - use SocialDept\AtpResolver\Facades\Resolver; 5 + use SocialDept\AtpClient\Builders\Concerns\BuildsRichText; 6 6 7 7 class TextBuilder 8 8 { 9 - protected string $text = ''; 10 - protected array $facets = []; 9 + use BuildsRichText; 11 10 12 11 /** 13 12 * Create a new text builder instance 14 13 */ 15 14 public static function make(): self 16 15 { 17 - return new self(); 16 + return new self; 18 17 } 19 18 20 19 /** ··· 22 21 */ 23 22 public static function build(callable $callback): array 24 23 { 25 - $builder = new self(); 24 + $builder = new self; 26 25 $callback($builder); 27 26 28 27 return $builder->toArray(); 29 28 } 30 29 31 30 /** 32 - * Add plain text 33 - */ 34 - public function text(string $text): self 35 - { 36 - $this->text .= $text; 37 - 38 - return $this; 39 - } 40 - 41 - /** 42 - * Add a new line 43 - */ 44 - public function newLine(): self 45 - { 46 - $this->text .= "\n"; 47 - 48 - return $this; 49 - } 50 - 51 - /** 52 - * Add mention (@handle) 53 - */ 54 - public function mention(string $handle, ?string $did = null): self 55 - { 56 - $handle = ltrim($handle, '@'); 57 - $start = $this->getBytePosition(); 58 - $this->text .= '@'.$handle; 59 - $end = $this->getBytePosition(); 60 - 61 - // Resolve DID if not provided 62 - if (! $did) { 63 - try { 64 - $did = Resolver::handleToDid($handle); 65 - } catch (\Exception $e) { 66 - // If resolution fails, still add the text but skip the facet 67 - return $this; 68 - } 69 - } 70 - 71 - $this->facets[] = [ 72 - 'index' => [ 73 - 'byteStart' => $start, 74 - 'byteEnd' => $end, 75 - ], 76 - 'features' => [ 77 - [ 78 - '$type' => 'app.bsky.richtext.facet#mention', 79 - 'did' => $did, 80 - ], 81 - ], 82 - ]; 83 - 84 - return $this; 85 - } 86 - 87 - /** 88 - * Add link with custom display text 89 - */ 90 - public function link(string $text, string $uri): self 91 - { 92 - $start = $this->getBytePosition(); 93 - $this->text .= $text; 94 - $end = $this->getBytePosition(); 95 - 96 - $this->facets[] = [ 97 - 'index' => [ 98 - 'byteStart' => $start, 99 - 'byteEnd' => $end, 100 - ], 101 - 'features' => [ 102 - [ 103 - '$type' => 'app.bsky.richtext.facet#link', 104 - 'uri' => $uri, 105 - ], 106 - ], 107 - ]; 108 - 109 - return $this; 110 - } 111 - 112 - /** 113 - * Add a URL (displayed as-is) 114 - */ 115 - public function url(string $url): self 116 - { 117 - return $this->link($url, $url); 118 - } 119 - 120 - /** 121 - * Add hashtag 122 - */ 123 - public function tag(string $tag): self 124 - { 125 - $tag = ltrim($tag, '#'); 126 - 127 - $start = $this->getBytePosition(); 128 - $this->text .= '#'.$tag; 129 - $end = $this->getBytePosition(); 130 - 131 - $this->facets[] = [ 132 - 'index' => [ 133 - 'byteStart' => $start, 134 - 'byteEnd' => $end, 135 - ], 136 - 'features' => [ 137 - [ 138 - '$type' => 'app.bsky.richtext.facet#tag', 139 - 'tag' => $tag, 140 - ], 141 - ], 142 - ]; 143 - 144 - return $this; 145 - } 146 - 147 - /** 148 - * Auto-detect and add facets from plain text 149 - */ 150 - public function autoDetect(string $text): self 151 - { 152 - $start = $this->getBytePosition(); 153 - $this->text .= $text; 154 - 155 - // Detect facets in the added text 156 - $detected = FacetDetector::detect($text); 157 - 158 - // Adjust byte positions to account for existing text 159 - foreach ($detected as $facet) { 160 - $facet['index']['byteStart'] += $start; 161 - $facet['index']['byteEnd'] += $start; 162 - $this->facets[] = $facet; 163 - } 164 - 165 - return $this; 166 - } 167 - 168 - /** 169 - * Get current byte position 170 - */ 171 - protected function getBytePosition(): int 172 - { 173 - return strlen($this->text); 174 - } 175 - 176 - /** 177 - * Get the text content 178 - */ 179 - public function getText(): string 180 - { 181 - return $this->text; 182 - } 183 - 184 - /** 185 - * Get the facets 186 - */ 187 - public function getFacets(): array 188 - { 189 - return $this->facets; 190 - } 191 - 192 - /** 193 31 * Build the final text and facets array 194 32 */ 195 33 public function toArray(): array 196 34 { 197 - return [ 198 - 'text' => $this->text, 199 - 'facets' => $this->facets, 200 - ]; 35 + return $this->getTextAndFacets(); 201 36 } 202 37 203 38 /** ··· 233 68 public function getByteCount(): int 234 69 { 235 70 return strlen($this->text); 236 - } 237 - 238 - /** 239 - * Check if text exceeds AT Protocol post limit (300 graphemes) 240 - */ 241 - public function exceedsLimit(int $limit = 300): bool 242 - { 243 - return $this->getGraphemeCount() > $limit; 244 - } 245 - 246 - /** 247 - * Get grapheme count (closest to what AT Protocol uses) 248 - */ 249 - public function getGraphemeCount(): int 250 - { 251 - return grapheme_strlen($this->text); 252 71 } 253 72 254 73 /**
+76 -4
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 { ··· 13 15 protected string $pdsEndpoint, 14 16 ) {} 15 17 16 - public function identifier(): string 18 + public function did(): string 17 19 { 18 - return $this->credentials->identifier; 20 + return $this->credentials->did; 19 21 } 20 22 21 - public function did(): string 23 + public function handle(): ?string 22 24 { 23 - return $this->credentials->did; 25 + return $this->credentials->handle; 24 26 } 25 27 26 28 public function accessToken(): string ··· 51 53 public function expiresIn(): int 52 54 { 53 55 return $this->credentials->expiresIn(); 56 + } 57 + 58 + public function scopes(): array 59 + { 60 + return $this->credentials->scope; 61 + } 62 + 63 + public function hasScope(string $scope): bool 64 + { 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; 54 126 } 55 127 56 128 public function withCredentials(Credentials $credentials): self
+62 -31
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\TokenRefreshed; 12 - use SocialDept\AtpClient\Events\TokenRefreshing; 11 + use SocialDept\AtpClient\Events\SessionAuthenticated; 12 + use SocialDept\AtpClient\Events\SessionRefreshing; 13 + use SocialDept\AtpClient\Events\SessionUpdated; 13 14 use SocialDept\AtpClient\Exceptions\AuthenticationException; 15 + use SocialDept\AtpClient\Exceptions\HandleResolutionException; 14 16 use SocialDept\AtpClient\Exceptions\SessionExpiredException; 15 17 use SocialDept\AtpResolver\Facades\Resolver; 18 + use SocialDept\AtpResolver\Support\Identity; 16 19 17 20 class SessionManager 18 21 { ··· 27 30 ) {} 28 31 29 32 /** 30 - * Get or create session for identifier 33 + * Resolve an actor (handle or DID) to a DID. 34 + * 35 + * @throws HandleResolutionException 36 + */ 37 + protected function resolveToDid(string $actor): string 38 + { 39 + // If already a DID, return as-is 40 + if (Identity::isDid($actor)) { 41 + return $actor; 42 + } 43 + 44 + // Resolve handle to DID 45 + $did = Resolver::handleToDid($actor); 46 + 47 + if (! $did) { 48 + throw new HandleResolutionException($actor); 49 + } 50 + 51 + return $did; 52 + } 53 + 54 + /** 55 + * Get or create session for an actor. 31 56 */ 32 - public function session(string $identifier): Session 57 + public function session(string $actor): Session 33 58 { 34 - if (! isset($this->sessions[$identifier])) { 35 - $this->sessions[$identifier] = $this->createSession($identifier); 59 + $did = $this->resolveToDid($actor); 60 + 61 + if (! isset($this->sessions[$did])) { 62 + $this->sessions[$did] = $this->createSession($did); 36 63 } 37 64 38 - return $this->sessions[$identifier]; 65 + return $this->sessions[$did]; 39 66 } 40 67 41 68 /** 42 - * Ensure session is valid, refresh if needed 69 + * Ensure session is valid, refresh if needed. 43 70 */ 44 - public function ensureValid(string $identifier): Session 71 + public function ensureValid(string $actor): Session 45 72 { 46 - $session = $this->session($identifier); 73 + $session = $this->session($actor); 47 74 48 75 // Check if token needs refresh 49 76 if ($session->expiresIn() < $this->refreshThreshold) { ··· 54 81 } 55 82 56 83 /** 57 - * Create session from app password 84 + * Create session from app password. 58 85 */ 59 86 public function fromAppPassword( 60 - string $identifier, 87 + string $actor, 61 88 string $password 62 89 ): Session { 63 - $pdsEndpoint = Resolver::resolvePds($identifier); 90 + $did = $this->resolveToDid($actor); 91 + $pdsEndpoint = Resolver::resolvePds($did); 64 92 65 93 $response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [ 66 - 'identifier' => $identifier, 94 + 'identifier' => $actor, 67 95 'password' => $password, 68 96 ]); 69 97 ··· 71 99 throw new AuthenticationException('Login failed'); 72 100 } 73 101 74 - $token = AccessToken::fromResponse($response->json()); 102 + $token = AccessToken::fromResponse($response->json(), $actor, $pdsEndpoint); 103 + 104 + // Store credentials using DID as key 105 + $this->credentials->storeCredentials($did, $token); 75 106 76 - // Store credentials 77 - $this->credentials->storeCredentials($identifier, $token); 107 + event(new SessionAuthenticated($token)); 78 108 79 - return $this->createSession($identifier); 109 + return $this->createSession($did); 80 110 } 81 111 82 112 /** 83 113 * Create session from credentials 84 114 */ 85 - protected function createSession(string $identifier): Session 115 + protected function createSession(string $did): Session 86 116 { 87 - $creds = $this->credentials->getCredentials($identifier); 117 + $creds = $this->credentials->getCredentials($did); 88 118 89 119 if (! $creds) { 90 - throw new SessionExpiredException("No credentials found for {$identifier}"); 120 + throw new SessionExpiredException("No credentials found for {$did}"); 91 121 } 92 122 93 123 // Get or create DPoP key ··· 98 128 $dpopKey = $this->dpopManager->generateKey($sessionId); 99 129 } 100 130 101 - // Resolve PDS endpoint 102 - $pdsEndpoint = Resolver::resolvePds($creds->did); 131 + // Use stored issuer if available, otherwise resolve PDS endpoint 132 + $pdsEndpoint = $creds->issuer ?? Resolver::resolvePds($creds->did); 103 133 104 134 return new Session($creds, $dpopKey, $pdsEndpoint); 105 135 } ··· 109 139 */ 110 140 protected function refreshSession(Session $session): Session 111 141 { 142 + $did = $session->did(); 143 + 112 144 // Fire event before refresh (allows developers to invalidate old token) 113 - event(new TokenRefreshing($session->identifier(), $session->refreshToken())); 145 + event(new SessionRefreshing($session)); 114 146 115 147 $newToken = $this->refresher->refresh( 116 148 refreshToken: $session->refreshToken(), 117 149 pdsEndpoint: $session->pdsEndpoint(), 118 150 dpopKey: $session->dpopKey(), 151 + handle: $session->handle(), 152 + authType: $session->authType(), 119 153 ); 120 154 121 155 // Update credentials (CRITICAL: refresh tokens are single-use) 122 - $this->credentials->updateCredentials( 123 - $session->identifier(), 124 - $newToken 125 - ); 156 + $this->credentials->updateCredentials($did, $newToken); 126 157 127 158 // Fire event after successful refresh 128 - event(new TokenRefreshed($session->identifier(), $newToken)); 159 + event(new SessionUpdated($session, $newToken)); 129 160 130 161 // Update session 131 - $newCreds = $this->credentials->getCredentials($session->identifier()); 162 + $newCreds = $this->credentials->getCredentials($did); 132 163 $newSession = $session->withCredentials($newCreds); 133 164 134 165 // Update cached session 135 - $this->sessions[$session->identifier()] = $newSession; 166 + $this->sessions[$did] = $newSession; 136 167 137 168 return $newSession; 138 169 }
+4 -8
src/Storage/EncryptedFileKeyStore.php
··· 3 3 namespace SocialDept\AtpClient\Storage; 4 4 5 5 use Illuminate\Contracts\Encryption\Encrypter; 6 - use phpseclib3\Crypt\PublicKeyLoader; 7 6 use SocialDept\AtpClient\Contracts\KeyStore; 8 7 use SocialDept\AtpClient\Data\DPoPKey; 9 8 ··· 23 22 public function store(string $sessionId, DPoPKey $key): void 24 23 { 25 24 $data = [ 26 - 'privateKey' => $key->privateKey->toString('PKCS8'), 27 - 'publicKey' => $key->publicKey->toString('PKCS8'), 25 + 'privateKey' => $key->toPEM(), 26 + 'publicKey' => $key->getPublicKey()->toString('PKCS8'), 28 27 'keyId' => $key->keyId, 29 28 ]; 30 29 ··· 47 46 $encrypted = file_get_contents($path); 48 47 $data = $this->encrypter->decrypt($encrypted); 49 48 50 - $privateKey = PublicKeyLoader::load($data['privateKey']); 51 - $publicKey = PublicKeyLoader::load($data['publicKey']); 52 - 53 49 return new DPoPKey( 54 - privateKey: $privateKey, 55 - publicKey: $publicKey, 50 + privateKey: $data['privateKey'], 51 + publicKey: $data['publicKey'], 56 52 keyId: $data['keyId'], 57 53 ); 58 54 }