Resolve AT Protocol DIDs, handles, and schemas with intelligent caching for Laravel

Implement core functionality

+35
.php-cs-fixer.php
··· 1 + <?php 2 + 3 + use PhpCsFixer\Config; 4 + use PhpCsFixer\Finder; 5 + 6 + $finder = Finder::create() 7 + ->in(__DIR__ . '/src') 8 + ->in(__DIR__ . '/tests') 9 + ->name('*.php') 10 + ->notName('*.blade.php') 11 + ->ignoreDotFiles(true) 12 + ->ignoreVCS(true); 13 + 14 + return (new Config()) 15 + ->setRules([ 16 + '@PSR12' => true, 17 + 'array_syntax' => ['syntax' => 'short'], 18 + 'ordered_imports' => ['sort_algorithm' => 'alpha'], 19 + 'no_unused_imports' => true, 20 + 'not_operator_with_successor_space' => true, 21 + 'trailing_comma_in_multiline' => true, 22 + 'phpdoc_scalar' => true, 23 + 'unary_operator_spaces' => true, 24 + 'binary_operator_spaces' => true, 25 + 'blank_line_before_statement' => [ 26 + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 27 + ], 28 + 'phpdoc_single_line_var_spacing' => true, 29 + 'phpdoc_var_without_name' => true, 30 + 'method_argument_space' => [ 31 + 'on_multiline' => 'ensure_fully_multiline', 32 + 'keep_multiple_spaces_after_comma' => true, 33 + ], 34 + ]) 35 + ->setFinder($finder);
+327 -31
README.md
··· 1 - # Beacon 1 + [![Beacon Header](./header.png)](https://github.com/socialdept/atp-signals) 2 + 3 + <h3 align="center"> 4 + Resolve AT Protocol identities in your Laravel application. 5 + </h3> 6 + 7 + <p align="center"> 8 + <br> 9 + <a href="https://packagist.org/packages/socialdept/atp-beacon" title="Latest Version on Packagist"><img src="https://img.shields.io/packagist/v/socialdept/atp-beacon.svg?style=flat-square"></a> 10 + <a href="https://packagist.org/packages/socialdept/atp-beacon" title="Total Downloads"><img src="https://img.shields.io/packagist/dt/socialdept/atp-beacon.svg?style=flat-square"></a> 11 + <a href="https://github.com/socialdept/atp-beacon/actions/workflows/tests.yml" title="GitHub Tests Action Status"><img src="https://img.shields.io/github/actions/workflow/status/socialdept/atp-beacon/tests.yml?branch=main&label=tests&style=flat-square"></a> 12 + <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/atp-beacon?style=flat-square"></a> 13 + </p> 14 + 15 + --- 16 + 17 + ## What is Beacon? 18 + 19 + **Beacon** is a Laravel package that resolves AT Protocol identities. Convert DIDs to handles, find PDS endpoints, and resolve DID documents with automatic caching and fallback support for both `did:plc` and `did:web` methods. 20 + 21 + Think of it as a Swiss Army knife for AT Protocol identity resolution. 22 + 23 + ## Why use Beacon? 2 24 3 - [![Latest Version on Packagist][ico-version]][link-packagist] 4 - [![Total Downloads][ico-downloads]][link-downloads] 5 - [![Build Status][ico-travis]][link-travis] 6 - [![StyleCI][ico-styleci]][link-styleci] 25 + - **Simple API** - Resolve DIDs and handles with one method call 26 + - **Automatic caching** - Smart caching with configurable TTLs 27 + - **Multiple DID methods** - Support for `did:plc` and `did:web` 28 + - **PDS discovery** - Find the correct PDS endpoint for any user 29 + - **Production ready** - Battle-tested with proper error handling 30 + - **Zero config** - Works out of the box with sensible defaults 7 31 8 - This is where your description should go. Take a look at [contributing.md](CONTRIBUTING.md) to see a to do list. 32 + ## Quick Example 33 + 34 + ```php 35 + use SocialDept\Beacon\Facades\Beacon; 36 + 37 + // Resolve a DID to its document 38 + $document = Beacon::resolveDid('did:plc:ewvi7nxzyoun6zhxrhs64oiz'); 39 + $handle = $document->getHandle(); // "user.bsky.social" 40 + $pds = $document->getPdsEndpoint(); // "https://bsky.social" 41 + 42 + // Resolve a handle to its DID 43 + $did = Beacon::resolveHandle('user.bsky.social'); 44 + // "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 45 + 46 + // Find someone's PDS endpoint 47 + $pds = Beacon::resolvePds('alice.bsky.social'); 48 + // "https://bsky.social" 49 + ``` 9 50 10 51 ## Installation 11 52 12 - Via Composer 53 + ```bash 54 + composer require socialdept/atp-beacon 55 + ``` 56 + 57 + Beacon will auto-register with Laravel. Optionally publish the config: 13 58 14 59 ```bash 15 - composer require social-dept/beacon 60 + php artisan vendor:publish --tag=beacon-config 61 + ``` 62 + 63 + ## Basic Usage 64 + 65 + ### Resolving DIDs 66 + 67 + Beacon supports both `did:plc` and `did:web` methods: 68 + 69 + ```php 70 + use SocialDept\Beacon\Facades\Beacon; 71 + 72 + // PLC directory resolution 73 + $document = Beacon::resolveDid('did:plc:ewvi7nxzyoun6zhxrhs64oiz'); 74 + 75 + // Web DID resolution 76 + $document = Beacon::resolveDid('did:web:example.com'); 77 + 78 + // Access document data 79 + $handle = $document->getHandle(); 80 + $pdsEndpoint = $document->getPdsEndpoint(); 81 + $services = $document->service; 82 + ``` 83 + 84 + ### Resolving Handles 85 + 86 + Convert human-readable handles to DIDs: 87 + 88 + ```php 89 + $did = Beacon::resolveHandle('alice.bsky.social'); 90 + // "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 91 + 92 + // Get the full DID document 93 + $document = Beacon::resolveHandleToDid('alice.bsky.social'); 94 + ``` 95 + 96 + ### Finding PDS Endpoints 97 + 98 + Automatically discover a user's Personal Data Server: 99 + 100 + ```php 101 + // From a DID 102 + $pds = Beacon::resolvePds('did:plc:ewvi7nxzyoun6zhxrhs64oiz'); 103 + 104 + // From a handle 105 + $pds = Beacon::resolvePds('alice.bsky.social'); 106 + 107 + // Returns: "https://bsky.social" or user's custom PDS 108 + ``` 109 + 110 + This is particularly useful when you need to make API calls to a user's PDS instead of hardcoding Bluesky's public instance. 111 + 112 + ### Cache Management 113 + 114 + Beacon automatically caches resolutions. Clear the cache when needed: 115 + 116 + ```php 117 + // Clear specific DID cache 118 + Beacon::clearDidCache('did:plc:abc123'); 119 + 120 + // Clear specific handle cache 121 + Beacon::clearHandleCache('alice.bsky.social'); 122 + 123 + // Clear specific PDS cache 124 + Beacon::clearPdsCache('alice.bsky.social'); 125 + 126 + // Clear all cached data 127 + Beacon::clearCache(); 128 + ``` 129 + 130 + ### Disable Caching 131 + 132 + Pass `false` as the second parameter to bypass cache: 133 + 134 + ```php 135 + $document = Beacon::resolveDid('did:plc:abc123', useCache: false); 136 + $did = Beacon::resolveHandle('alice.bsky.social', useCache: false); 137 + $pds = Beacon::resolvePds('alice.bsky.social', useCache: false); 16 138 ``` 17 139 18 - ## Usage 140 + ### Identity Validation 19 141 20 - ## Change log 142 + Beacon includes static helper methods to validate DIDs and handles: 21 143 22 - Please see the [changelog](changelog.md) for more information on what has changed recently. 144 + ```php 145 + use SocialDept\Beacon\Support\Identity; 23 146 24 - ## Testing 147 + // Validate handles 148 + Identity::isHandle('alice.bsky.social'); // true 149 + Identity::isHandle('invalid'); // false 25 150 26 - ```bash 27 - composer test 151 + // Validate DIDs 152 + Identity::isDid('did:plc:ewvi7nxzyoun6zhxrhs64oiz'); // true 153 + Identity::isDid('did:web:example.com'); // true 154 + Identity::isDid('invalid'); // false 155 + 156 + // Extract DID method 157 + Identity::extractDidMethod('did:plc:abc123'); // "plc" 158 + Identity::extractDidMethod('did:web:test'); // "web" 159 + 160 + // Check specific DID types 161 + Identity::isPlcDid('did:plc:abc123'); // true 162 + Identity::isWebDid('did:web:test'); // true 28 163 ``` 29 164 30 - ## Contributing 165 + These helpers are useful for validating user input before making resolution calls. 31 166 32 - Please see [contributing.md](CONTRIBUTING.md) for details and a todolist. 167 + ## Configuration 33 168 34 - ## Security 169 + Beacon works great with zero configuration, but you can customize behavior in `config/beacon.php`: 170 + 171 + ```php 172 + return [ 173 + // PLC directory for did:plc resolution 174 + 'plc_directory' => env('BEACON_PLC_DIRECTORY', 'https://plc.directory'), 35 175 36 - If you discover any security related issues, please email author@email.com instead of using the issue tracker. 176 + // Default PDS endpoint for handle resolution 177 + 'pds_endpoint' => env('BEACON_PDS_ENDPOINT', 'https://bsky.social'), 178 + 179 + // HTTP request timeout 180 + 'timeout' => env('BEACON_TIMEOUT', 10), 181 + 182 + // Cache configuration 183 + 'cache' => [ 184 + 'enabled' => env('BEACON_CACHE_ENABLED', true), 185 + 186 + // Cache TTL for DID documents (1 hour) 187 + 'did_ttl' => env('BEACON_CACHE_DID_TTL', 3600), 188 + 189 + // Cache TTL for handle resolutions (1 hour) 190 + 'handle_ttl' => env('BEACON_CACHE_HANDLE_TTL', 3600), 191 + 192 + // Cache TTL for PDS endpoints (1 hour) 193 + 'pds_ttl' => env('BEACON_CACHE_PDS_TTL', 3600), 194 + ], 195 + ]; 196 + ``` 197 + 198 + ## API Reference 199 + 200 + ### Available Methods 201 + 202 + ```php 203 + // DID Resolution 204 + Beacon::resolveDid(string $did, bool $useCache = true): DidDocument 205 + 206 + // Handle Resolution 207 + Beacon::handleToDid(string $handle, bool $useCache = true): string 208 + Beacon::resolveHandle(string $handle, bool $useCache = true): DidDocument 209 + 210 + // Identity Resolution 211 + Beacon::resolveIdentity(string $actor, bool $useCache = true): DidDocument 212 + 213 + // PDS Resolution 214 + Beacon::resolvePds(string $actor, bool $useCache = true): ?string 215 + 216 + // Cache Management 217 + Beacon::clearDidCache(string $did): void 218 + Beacon::clearHandleCache(string $handle): void 219 + Beacon::clearPdsCache(string $actor): void 220 + Beacon::clearCache(): void 221 + 222 + // Identity Validation (static helpers) 223 + Identity::isHandle(?string $handle): bool 224 + Identity::isDid(?string $did): bool 225 + Identity::extractDidMethod(string $did): ?string 226 + Identity::isPlcDid(string $did): bool 227 + Identity::isWebDid(string $did): bool 228 + ``` 229 + 230 + ### DidDocument Object 231 + 232 + ```php 233 + $document->id; // string - The DID 234 + $document->alsoKnownAs; // array - Alternative identifiers 235 + $document->verificationMethod; // array - Verification methods 236 + $document->service; // array - Service endpoints 237 + $document->raw; // array - Raw DID document 238 + 239 + // Helper methods 240 + $document->getHandle(); // ?string - Extract handle from alsoKnownAs 241 + $document->getPdsEndpoint(); // ?string - Extract PDS service endpoint 242 + $document->toArray(); // array - Convert to array 243 + ``` 244 + 245 + ## Error Handling 246 + 247 + Beacon throws descriptive exceptions when resolution fails: 248 + 249 + ```php 250 + use SocialDept\Beacon\Exceptions\DidResolutionException; 251 + use SocialDept\Beacon\Exceptions\HandleResolutionException; 252 + 253 + try { 254 + $document = Beacon::resolveDid('did:invalid:format'); 255 + } catch (DidResolutionException $e) { 256 + // Handle DID resolution errors 257 + logger()->error('DID resolution failed', [ 258 + 'message' => $e->getMessage(), 259 + ]); 260 + } 261 + 262 + try { 263 + $did = Beacon::resolveHandle('invalid-handle'); 264 + } catch (HandleResolutionException $e) { 265 + // Handle handle resolution errors 266 + } 267 + ``` 268 + 269 + ## Use Cases 270 + 271 + ### Building an AppView 272 + 273 + ```php 274 + // Resolve user identity from DID 275 + $document = Beacon::resolveDid($event->did); 276 + $handle = $document->getHandle(); 277 + 278 + // Make authenticated requests to their PDS 279 + $pds = Beacon::resolvePds($event->did); 280 + $client = new AtProtoClient($pds); 281 + ``` 282 + 283 + ### Custom Feed Generators 284 + 285 + ```php 286 + // Resolve multiple handles efficiently (caching kicks in) 287 + $dids = collect(['alice.bsky.social', 'bob.bsky.social']) 288 + ->map(fn($handle) => Beacon::resolveHandle($handle)) 289 + ->all(); 290 + ``` 291 + 292 + ### Profile Resolution 293 + 294 + ```php 295 + // Get complete identity information 296 + $document = Beacon::resolveHandleToDid($username); 297 + 298 + $profile = [ 299 + 'did' => $document->id, 300 + 'handle' => $document->getHandle(), 301 + 'pds' => $document->getPdsEndpoint(), 302 + ]; 303 + ``` 304 + 305 + ### Input Validation 306 + 307 + ```php 308 + use SocialDept\Beacon\Support\Identity; 309 + use SocialDept\Beacon\Facades\Beacon; 310 + 311 + // Validate user input before resolving 312 + $userInput = request()->input('identifier'); 313 + 314 + if (Identity::isHandle($userInput)) { 315 + $did = Beacon::resolveHandle($userInput); 316 + } elseif (Identity::isDid($userInput)) { 317 + $document = Beacon::resolveDid($userInput); 318 + } else { 319 + abort(422, 'Invalid handle or DID'); 320 + } 321 + ``` 322 + 323 + ## Requirements 324 + 325 + - PHP 8.2+ 326 + - Laravel 11+ 327 + - `ext-gmp` extension 328 + 329 + ## Resources 330 + 331 + - [AT Protocol Documentation](https://atproto.com/) 332 + - [DID:PLC Specification](https://github.com/did-method-plc/did-method-plc) 333 + - [DID:Web Specification](https://w3c-ccg.github.io/did-method-web/) 334 + - [PLC Directory](https://plc.directory/) 335 + 336 + ## Support & Contributing 337 + 338 + Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-beacon/issues). 339 + 340 + Want to contribute? We'd love your help! Check out the [contribution guidelines](CONTRIBUTING.md). 37 341 38 342 ## Credits 39 343 40 - - [Author Name][link-author] 41 - - [All Contributors][link-contributors] 344 + - [Miguel Batres](https://batres.co) - founder & lead maintainer 345 + - [All contributors](https://github.com/socialdept/atp-beacon/graphs/contributors) 42 346 43 347 ## License 44 348 45 - MIT. Please see the [license file](LICENSE) for more information. 349 + Beacon is open-source software licensed under the [MIT license](LICENSE). 46 350 47 - [ico-version]: https://img.shields.io/packagist/v/social-dept/beacon.svg?style=flat-square 48 - [ico-downloads]: https://img.shields.io/packagist/dt/social-dept/beacon.svg?style=flat-square 49 - [ico-travis]: https://img.shields.io/travis/social-dept/beacon/master.svg?style=flat-square 50 - [ico-styleci]: https://styleci.io/repos/12345678/shield 351 + --- 51 352 52 - [link-packagist]: https://packagist.org/packages/social-dept/beacon 53 - [link-downloads]: https://packagist.org/packages/social-dept/beacon 54 - [link-travis]: https://travis-ci.org/social-dept/beacon 55 - [link-styleci]: https://styleci.io/repos/12345678 56 - [link-author]: https://github.com/social-dept 57 - [link-contributors]: ../../contributors 353 + **Built for the Federation** • By Social Dept.
+12 -14
composer.json
··· 1 1 { 2 2 "name": "socialdept/atp-beacon", 3 - "description": ":package_description", 3 + "description": "Resolve AT Protocol DIDs, handles, and lexicon schemas in Laravel", 4 + "type": "library", 4 5 "license": "MIT", 5 - "authors": [ 6 - { 7 - "name": "Author Name", 8 - "email": "author@email.com", 9 - "homepage": "http://author.com" 10 - } 11 - ], 12 - "homepage": "https://github.com/social-dept/beacon", 13 - "keywords": ["Laravel", "Beacon"], 14 6 "require": { 15 - "illuminate/support": "~9" 7 + "php": "^8.2", 8 + "ext-gmp": "*", 9 + "illuminate/support": "^11.0|^12.0", 10 + "illuminate/console": "^11.0|^12.0", 11 + "illuminate/cache": "^11.0|^12.0", 12 + "guzzlehttp/guzzle": "^7.5" 16 13 }, 17 14 "require-dev": { 18 - "phpunit/phpunit": "~9.0", 19 - "orchestra/testbench": "~7" 15 + "orchestra/testbench": "^9.0", 16 + "phpunit/phpunit": "^11.0", 17 + "friendsofphp/php-cs-fixer": "^3.89" 20 18 }, 21 19 "autoload": { 22 20 "psr-4": { ··· 25 23 }, 26 24 "autoload-dev": { 27 25 "psr-4": { 28 - "SocialDept\\Beacon\\Tests\\": "tests" 26 + "SocialDept\\Beacon\\Tests\\": "tests/" 29 27 } 30 28 }, 31 29 "extra": {
+67 -1
config/beacon.php
··· 1 1 <?php 2 2 3 3 return [ 4 - // 4 + 5 + /* 6 + |-------------------------------------------------------------------------- 7 + | PLC Directory URL 8 + |-------------------------------------------------------------------------- 9 + | 10 + | The URL of the PLC (Public Ledger of Credentials) directory used for 11 + | resolving DID:PLC identifiers. The default is the official AT Protocol 12 + | PLC directory. 13 + | 14 + */ 15 + 16 + 'plc_directory' => env('BEACON_PLC_DIRECTORY', 'https://plc.directory'), 17 + 18 + /* 19 + |-------------------------------------------------------------------------- 20 + | PDS Endpoint 21 + |-------------------------------------------------------------------------- 22 + | 23 + | The Personal Data Server endpoint used for handle resolution. This is 24 + | used when resolving handles to DIDs via the AT Protocol API. 25 + | 26 + */ 27 + 28 + 'pds_endpoint' => env('BEACON_PDS_ENDPOINT', 'https://bsky.social'), 29 + 30 + /* 31 + |-------------------------------------------------------------------------- 32 + | Request Timeout 33 + |-------------------------------------------------------------------------- 34 + | 35 + | The timeout in seconds for HTTP requests to external services when 36 + | resolving DIDs, handles, and lexicons. 37 + | 38 + */ 39 + 40 + 'timeout' => env('BEACON_TIMEOUT', 10), 41 + 42 + /* 43 + |-------------------------------------------------------------------------- 44 + | Cache Configuration 45 + |-------------------------------------------------------------------------- 46 + | 47 + | Configure caching behavior for resolved DIDs, handles, and lexicons. 48 + | TTL values are in seconds. 49 + | 50 + */ 51 + 52 + 'cache' => [ 53 + 54 + // Enable or disable caching globally 55 + 'enabled' => env('BEACON_CACHE_ENABLED', true), 56 + 57 + // Cache TTL for DID documents (1 hour default) 58 + 'did_ttl' => env('BEACON_CACHE_DID_TTL', 3600), 59 + 60 + // Cache TTL for handle resolutions (1 hour default) 61 + 'handle_ttl' => env('BEACON_CACHE_HANDLE_TTL', 3600), 62 + 63 + // Cache TTL for PDS endpoints (1 hour default) 64 + 'pds_ttl' => env('BEACON_CACHE_PDS_TTL', 3600), 65 + 66 + // Cache TTL for lexicon schemas (24 hours default) 67 + 'lexicon_ttl' => env('BEACON_CACHE_LEXICON_TTL', 86400), 68 + 69 + ], 70 + 5 71 ];
header.png

This is a binary file and will not be displayed.

+1 -1
phpunit.xml
··· 10 10 processIsolation="false" 11 11 stopOnFailure="false"> 12 12 <testsuites> 13 - <testsuite name="Package"> 13 + <testsuite name="Beacon Test Suite"> 14 14 <directory suffix=".php">./tests/</directory> 15 15 </testsuite> 16 16 </testsuites>
+177 -2
src/Beacon.php
··· 2 2 3 3 namespace SocialDept\Beacon; 4 4 5 + use SocialDept\Beacon\Contracts\CacheStore; 6 + use SocialDept\Beacon\Contracts\DidResolver; 7 + use SocialDept\Beacon\Contracts\HandleResolver; 8 + use SocialDept\Beacon\Data\DidDocument; 9 + use SocialDept\Beacon\Exceptions\DidResolutionException; 10 + use SocialDept\Beacon\Exceptions\HandleResolutionException; 11 + use SocialDept\Beacon\Support\Concerns\HasConfig; 12 + use SocialDept\Beacon\Support\Identity; 13 + 5 14 class Beacon 6 15 { 7 - // Build wonderful things 8 - } 16 + use HasConfig; 17 + 18 + /** 19 + * Create a new Beacon instance. 20 + */ 21 + public function __construct( 22 + protected DidResolver $didResolver, 23 + protected HandleResolver $handleResolver, 24 + protected CacheStore $cache 25 + ) { 26 + } 27 + 28 + /** 29 + * Resolve a DID to a DID Document. 30 + * 31 + * @param string $did 32 + * @param bool $useCache 33 + * @return DidDocument 34 + * @throws DidResolutionException 35 + */ 36 + public function resolveDid(string $did, bool $useCache = true): DidDocument 37 + { 38 + $cacheKey = "did:{$did}"; 39 + 40 + if ($useCache && $this->cache->has($cacheKey)) { 41 + $cached = $this->cache->get($cacheKey); 42 + 43 + if ($cached instanceof DidDocument) { 44 + return $cached; 45 + } 46 + } 47 + 48 + $document = $this->didResolver->resolve($did); 49 + 50 + if ($useCache) { 51 + $ttl = $this->getConfig('beacon.cache.did_ttl', 3600); 52 + $this->cache->put($cacheKey, $document, $ttl); 53 + } 54 + 55 + return $document; 56 + } 57 + 58 + /** 59 + * Convert a handle to its DID. 60 + * 61 + * @param string $handle 62 + * @param bool $useCache 63 + * @return string 64 + * @throws HandleResolutionException 65 + */ 66 + public function handleToDid(string $handle, bool $useCache = true): string 67 + { 68 + $cacheKey = "handle:{$handle}"; 69 + 70 + if ($useCache && $this->cache->has($cacheKey)) { 71 + return $this->cache->get($cacheKey); 72 + } 73 + 74 + $did = $this->handleResolver->resolve($handle); 75 + 76 + if ($useCache) { 77 + $ttl = $this->getConfig('beacon.cache.handle_ttl', 3600); 78 + $this->cache->put($cacheKey, $did, $ttl); 79 + } 80 + 81 + return $did; 82 + } 83 + 84 + /** 85 + * Resolve a handle to its DID Document. 86 + * 87 + * @param string $handle 88 + * @param bool $useCache 89 + * @return DidDocument 90 + * @throws DidResolutionException 91 + * @throws HandleResolutionException 92 + */ 93 + public function resolveHandle(string $handle, bool $useCache = true): DidDocument 94 + { 95 + $did = $this->handleToDid($handle, $useCache); 96 + 97 + return $this->resolveDid($did, $useCache); 98 + } 99 + 100 + /** 101 + * Resolve an identity (DID or handle) to its DID Document. 102 + * 103 + * @param string $identifier A DID or handle 104 + * @param bool $useCache 105 + * @return DidDocument 106 + * @throws DidResolutionException 107 + * @throws HandleResolutionException 108 + */ 109 + public function resolveIdentity(string $identifier, bool $useCache = true): DidDocument 110 + { 111 + return Identity::isDid($identifier) 112 + ? $this->resolveDid($identifier, $useCache) 113 + : $this->resolveHandle($identifier, $useCache); 114 + } 115 + 116 + /** 117 + * Clear cached data for a DID. 118 + * 119 + * @param string $did 120 + */ 121 + public function clearDidCache(string $did): void 122 + { 123 + $this->cache->forget("did:{$did}"); 124 + } 125 + 126 + /** 127 + * Clear cached data for a handle. 128 + * 129 + * @param string $handle 130 + */ 131 + public function clearHandleCache(string $handle): void 132 + { 133 + $this->cache->forget("handle:{$handle}"); 134 + } 135 + 136 + /** 137 + * Clear all cached data. 138 + */ 139 + public function clearCache(): void 140 + { 141 + $this->cache->flush(); 142 + } 143 + 144 + /** 145 + * Resolve a DID or handle to its PDS endpoint. 146 + * 147 + * @param string $actor A DID (e.g., "did:plc:abc123") or handle (e.g., "user.bsky.social") 148 + * @param bool $useCache 149 + * @return string|null The PDS endpoint URL or null if not found 150 + * @throws DidResolutionException 151 + * @throws HandleResolutionException 152 + */ 153 + public function resolvePds(string $actor, bool $useCache = true): ?string 154 + { 155 + $cacheKey = "pds:{$actor}"; 156 + 157 + if ($useCache && $this->cache->has($cacheKey)) { 158 + return $this->cache->get($cacheKey); 159 + } 160 + 161 + // Determine if input is a DID or handle 162 + $document = $this->resolveIdentity($actor, $useCache); 163 + 164 + $pdsEndpoint = $document->getPdsEndpoint(); 165 + 166 + if ($useCache && $pdsEndpoint !== null) { 167 + $ttl = $this->getConfig('beacon.cache.pds_ttl', 3600); 168 + $this->cache->put($cacheKey, $pdsEndpoint, $ttl); 169 + } 170 + 171 + return $pdsEndpoint; 172 + } 173 + 174 + /** 175 + * Clear cached PDS endpoint for a DID or handle. 176 + * 177 + * @param string $actor 178 + */ 179 + public function clearPdsCache(string $actor): void 180 + { 181 + $this->cache->forget("pds:{$actor}"); 182 + } 183 + }
+44 -47
src/BeaconServiceProvider.php
··· 3 3 namespace SocialDept\Beacon; 4 4 5 5 use Illuminate\Support\ServiceProvider; 6 + use SocialDept\Beacon\Cache\LaravelCacheStore; 7 + use SocialDept\Beacon\Contracts\CacheStore; 8 + use SocialDept\Beacon\Contracts\DidResolver; 9 + use SocialDept\Beacon\Contracts\HandleResolver; 10 + use SocialDept\Beacon\Resolvers\AtProtoHandleResolver; 11 + use SocialDept\Beacon\Resolvers\DidResolverManager; 6 12 7 13 class BeaconServiceProvider extends ServiceProvider 8 14 { 9 15 /** 10 - * Perform post-registration booting of services. 11 - * 12 - * @return void 16 + * Register any package services. 13 17 */ 14 - public function boot(): void 18 + public function register(): void 15 19 { 16 - // $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'social-dept'); 17 - // $this->loadViewsFrom(__DIR__.'/../resources/views', 'social-dept'); 18 - // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 19 - // $this->loadRoutesFrom(__DIR__.'/routes.php'); 20 + $this->mergeConfigFrom(__DIR__.'/../config/beacon.php', 'beacon'); 20 21 21 - // Publishing is only necessary when using the CLI. 22 - if ($this->app->runningInConsole()) { 23 - $this->bootForConsole(); 24 - } 22 + // Register cache store 23 + $this->app->singleton(CacheStore::class, function ($app) { 24 + return new LaravelCacheStore($app->make('cache')->store()); 25 + }); 26 + 27 + // Register DID resolver 28 + $this->app->singleton(DidResolver::class, function ($app) { 29 + return new DidResolverManager(); 30 + }); 31 + 32 + // Register handle resolver 33 + $this->app->singleton(HandleResolver::class, function ($app) { 34 + return new AtProtoHandleResolver(); 35 + }); 36 + 37 + // Register Beacon service 38 + $this->app->singleton('beacon', function ($app) { 39 + return new Beacon( 40 + $app->make(DidResolver::class), 41 + $app->make(HandleResolver::class), 42 + $app->make(CacheStore::class), 43 + ); 44 + }); 45 + 46 + $this->app->alias('beacon', Beacon::class); 25 47 } 26 48 27 49 /** 28 - * Register any package services. 29 - * 30 - * @return void 50 + * Bootstrap the application services. 31 51 */ 32 - public function register(): void 52 + public function boot(): void 33 53 { 34 - $this->mergeConfigFrom(__DIR__.'/../config/beacon.php', 'beacon'); 35 - 36 - // Register the service the package provides. 37 - $this->app->singleton('beacon', function ($app) { 38 - return new Beacon; 39 - }); 54 + if ($this->app->runningInConsole()) { 55 + $this->bootForConsole(); 56 + } 40 57 } 41 58 42 59 /** 43 60 * Get the services provided by the provider. 44 61 * 45 - * @return array 62 + * @return array<string> 46 63 */ 47 - public function provides() 64 + public function provides(): array 48 65 { 49 - return ['beacon']; 66 + return ['beacon', Beacon::class]; 50 67 } 51 68 52 69 /** 53 70 * Console-specific booting. 54 - * 55 - * @return void 56 71 */ 57 72 protected function bootForConsole(): void 58 73 { 59 - // Publishing the configuration file. 74 + // Publish config 60 75 $this->publishes([ 61 76 __DIR__.'/../config/beacon.php' => config_path('beacon.php'), 62 - ], 'beacon.config'); 63 - 64 - // Publishing the views. 65 - /*$this->publishes([ 66 - __DIR__.'/../resources/views' => base_path('resources/views/vendor/social-dept'), 67 - ], 'beacon.views');*/ 68 - 69 - // Publishing assets. 70 - /*$this->publishes([ 71 - __DIR__.'/../resources/assets' => public_path('vendor/social-dept'), 72 - ], 'beacon.assets');*/ 73 - 74 - // Publishing the translation files. 75 - /*$this->publishes([ 76 - __DIR__.'/../resources/lang' => resource_path('lang/vendor/social-dept'), 77 - ], 'beacon.lang');*/ 78 - 79 - // Registering package commands. 80 - // $this->commands([]); 77 + ], 'beacon-config'); 81 78 } 82 79 }
+69
src/Cache/LaravelCacheStore.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Cache; 4 + 5 + use Illuminate\Contracts\Cache\Repository; 6 + use SocialDept\Beacon\Contracts\CacheStore; 7 + 8 + class LaravelCacheStore implements CacheStore 9 + { 10 + protected string $prefix = 'beacon:'; 11 + 12 + /** 13 + * Create a new Laravel cache store instance. 14 + */ 15 + public function __construct( 16 + protected Repository $cache 17 + ) { 18 + } 19 + 20 + /** 21 + * Get a cached value. 22 + * 23 + * @param string $key 24 + */ 25 + public function get(string $key): mixed 26 + { 27 + return $this->cache->get($this->prefix.$key); 28 + } 29 + 30 + /** 31 + * Store a value in the cache. 32 + * 33 + * @param string $key 34 + * @param mixed $value 35 + * @param int $ttl Time to live in seconds 36 + */ 37 + public function put(string $key, mixed $value, int $ttl): void 38 + { 39 + $this->cache->put($this->prefix.$key, $value, $ttl); 40 + } 41 + 42 + /** 43 + * Check if a key exists in the cache. 44 + * 45 + * @param string $key 46 + */ 47 + public function has(string $key): bool 48 + { 49 + return $this->cache->has($this->prefix.$key); 50 + } 51 + 52 + /** 53 + * Remove a value from the cache. 54 + * 55 + * @param string $key 56 + */ 57 + public function forget(string $key): void 58 + { 59 + $this->cache->forget($this->prefix.$key); 60 + } 61 + 62 + /** 63 + * Clear all cached values. 64 + */ 65 + public function flush(): void 66 + { 67 + $this->cache->flush(); 68 + } 69 + }
+42
src/Contracts/CacheStore.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Contracts; 4 + 5 + interface CacheStore 6 + { 7 + /** 8 + * Get a cached value. 9 + * 10 + * @param string $key 11 + * @return mixed 12 + */ 13 + public function get(string $key): mixed; 14 + 15 + /** 16 + * Store a value in the cache. 17 + * 18 + * @param string $key 19 + * @param mixed $value 20 + * @param int $ttl Time to live in seconds 21 + */ 22 + public function put(string $key, mixed $value, int $ttl): void; 23 + 24 + /** 25 + * Check if a key exists in the cache. 26 + * 27 + * @param string $key 28 + */ 29 + public function has(string $key): bool; 30 + 31 + /** 32 + * Remove a value from the cache. 33 + * 34 + * @param string $key 35 + */ 36 + public function forget(string $key): void; 37 + 38 + /** 39 + * Clear all cached values. 40 + */ 41 + public function flush(): void; 42 + }
+25
src/Contracts/DidResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Contracts; 4 + 5 + use SocialDept\Beacon\Data\DidDocument; 6 + 7 + interface DidResolver 8 + { 9 + /** 10 + * Resolve a DID to a DID Document. 11 + * 12 + * @param string $did The DID to resolve (e.g., "did:plc:abc123" or "did:web:example.com") 13 + * @return DidDocument 14 + * 15 + * @throws \SocialDept\Beacon\Exceptions\DidResolutionException 16 + */ 17 + public function resolve(string $did): DidDocument; 18 + 19 + /** 20 + * Check if this resolver supports the given DID method. 21 + * 22 + * @param string $method The DID method (e.g., "plc", "web") 23 + */ 24 + public function supports(string $method): bool; 25 + }
+16
src/Contracts/HandleResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Contracts; 4 + 5 + interface HandleResolver 6 + { 7 + /** 8 + * Resolve a handle to a DID. 9 + * 10 + * @param string $handle The handle to resolve (e.g., "user.bsky.social") 11 + * @return string The resolved DID 12 + * 13 + * @throws \SocialDept\Beacon\Exceptions\HandleResolutionException 14 + */ 15 + public function resolve(string $handle): string; 16 + }
+76
src/Data/DidDocument.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Data; 4 + 5 + class DidDocument 6 + { 7 + /** 8 + * Create a new DID Document instance. 9 + * 10 + * @param string $id The DID (e.g., "did:plc:abc123") 11 + * @param array $alsoKnownAs Alternative identifiers (handles) 12 + * @param array $verificationMethod Verification methods (keys) 13 + * @param array $service Service endpoints (PDS, etc.) 14 + * @param array $raw The raw DID document 15 + */ 16 + public function __construct( 17 + public readonly string $id, 18 + public readonly array $alsoKnownAs = [], 19 + public readonly array $verificationMethod = [], 20 + public readonly array $service = [], 21 + public readonly array $raw = [], 22 + ) { 23 + } 24 + 25 + /** 26 + * Create a DID Document from a raw array. 27 + * 28 + * @param array $data 29 + */ 30 + public static function fromArray(array $data): self 31 + { 32 + return new self( 33 + id: $data['id'] ?? '', 34 + alsoKnownAs: $data['alsoKnownAs'] ?? [], 35 + verificationMethod: $data['verificationMethod'] ?? [], 36 + service: $data['service'] ?? [], 37 + raw: $data, 38 + ); 39 + } 40 + 41 + /** 42 + * Get the PDS (Personal Data Server) endpoint. 43 + */ 44 + public function getPdsEndpoint(): ?string 45 + { 46 + foreach ($this->service as $service) { 47 + if (($service['type'] ?? '') === 'AtprotoPersonalDataServer') { 48 + return $service['serviceEndpoint'] ?? null; 49 + } 50 + } 51 + 52 + return null; 53 + } 54 + 55 + /** 56 + * Get the handle from alsoKnownAs. 57 + */ 58 + public function getHandle(): ?string 59 + { 60 + foreach ($this->alsoKnownAs as $alias) { 61 + if (str_starts_with($alias, 'at://')) { 62 + return substr($alias, 5); 63 + } 64 + } 65 + 66 + return null; 67 + } 68 + 69 + /** 70 + * Convert to array. 71 + */ 72 + public function toArray(): array 73 + { 74 + return $this->raw; 75 + } 76 + }
+10
src/Exceptions/BeaconException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Exceptions; 4 + 5 + use Exception; 6 + 7 + class BeaconException extends Exception 8 + { 9 + // 10 + }
+43
src/Exceptions/DidResolutionException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Exceptions; 4 + 5 + class DidResolutionException extends BeaconException 6 + { 7 + /** 8 + * Create a new exception for unsupported DID method. 9 + * 10 + * @param string $method 11 + */ 12 + public static function unsupportedMethod(string $method): self 13 + { 14 + return new self("Unsupported DID method: {$method}"); 15 + } 16 + 17 + /** 18 + * Create a new exception for invalid DID format. 19 + * 20 + * @param string $did 21 + */ 22 + public static function invalidFormat(string $did): self 23 + { 24 + return new self("Invalid DID format: {$did}"); 25 + } 26 + 27 + /** 28 + * Create a new exception for resolution failure. 29 + * 30 + * @param string $did 31 + * @param string $reason 32 + */ 33 + public static function resolutionFailed(string $did, string $reason = ''): self 34 + { 35 + $message = "Failed to resolve DID: {$did}"; 36 + 37 + if ($reason) { 38 + $message .= " ({$reason})"; 39 + } 40 + 41 + return new self($message); 42 + } 43 + }
+33
src/Exceptions/HandleResolutionException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Exceptions; 4 + 5 + class HandleResolutionException extends BeaconException 6 + { 7 + /** 8 + * Create a new exception for invalid handle format. 9 + * 10 + * @param string $handle 11 + */ 12 + public static function invalidFormat(string $handle): self 13 + { 14 + return new self("Invalid handle format: {$handle}"); 15 + } 16 + 17 + /** 18 + * Create a new exception for resolution failure. 19 + * 20 + * @param string $handle 21 + * @param string $reason 22 + */ 23 + public static function resolutionFailed(string $handle, string $reason = ''): self 24 + { 25 + $message = "Failed to resolve handle: {$handle}"; 26 + 27 + if ($reason) { 28 + $message .= " ({$reason})"; 29 + } 30 + 31 + return new self($message); 32 + } 33 + }
+75
src/Resolvers/AtProtoHandleResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Resolvers; 4 + 5 + use GuzzleHttp\Client; 6 + use GuzzleHttp\Exception\GuzzleException; 7 + use SocialDept\Beacon\Contracts\HandleResolver; 8 + use SocialDept\Beacon\Exceptions\HandleResolutionException; 9 + use SocialDept\Beacon\Support\Concerns\HasConfig; 10 + 11 + class AtProtoHandleResolver implements HandleResolver 12 + { 13 + use HasConfig; 14 + 15 + protected Client $client; 16 + 17 + protected string $pdsEndpoint; 18 + 19 + /** 20 + * Create a new AT Protocol handle resolver instance. 21 + * 22 + * @param string|null $pdsEndpoint The PDS endpoint to use for resolution 23 + */ 24 + public function __construct(?string $pdsEndpoint = null, ?int $timeout = null) 25 + { 26 + $this->pdsEndpoint = $pdsEndpoint ?? $this->getConfig('beacon.pds_endpoint', 'https://bsky.social'); 27 + $this->client = new Client([ 28 + 'timeout' => $timeout ?? $this->getConfig('beacon.timeout', 10), 29 + 'headers' => [ 30 + 'Accept' => 'application/json', 31 + 'User-Agent' => 'Beacon/1.0', 32 + ], 33 + ]); 34 + } 35 + 36 + /** 37 + * Resolve a handle to a DID. 38 + * 39 + * @param string $handle The handle to resolve (e.g., "user.bsky.social") 40 + * @return string The resolved DID 41 + */ 42 + public function resolve(string $handle): string 43 + { 44 + $this->validateHandle($handle); 45 + 46 + try { 47 + $response = $this->client->get("{$this->pdsEndpoint}/xrpc/com.atproto.identity.resolveHandle", [ 48 + 'query' => ['handle' => $handle], 49 + ]); 50 + 51 + $data = json_decode($response->getBody()->getContents(), true); 52 + 53 + if (! isset($data['did'])) { 54 + throw HandleResolutionException::resolutionFailed($handle, 'No DID in response'); 55 + } 56 + 57 + return $data['did']; 58 + } catch (GuzzleException $e) { 59 + throw HandleResolutionException::resolutionFailed($handle, $e->getMessage()); 60 + } 61 + } 62 + 63 + /** 64 + * Validate a handle format. 65 + * 66 + * @param string $handle 67 + */ 68 + protected function validateHandle(string $handle): void 69 + { 70 + // Handle must be a valid domain name 71 + if (! preg_match('/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $handle)) { 72 + throw HandleResolutionException::invalidFormat($handle); 73 + } 74 + } 75 + }
+74
src/Resolvers/DidResolverManager.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Resolvers; 4 + 5 + use SocialDept\Beacon\Contracts\DidResolver; 6 + use SocialDept\Beacon\Data\DidDocument; 7 + use SocialDept\Beacon\Exceptions\DidResolutionException; 8 + use SocialDept\Beacon\Support\Concerns\ParsesDid; 9 + 10 + class DidResolverManager implements DidResolver 11 + { 12 + use ParsesDid; 13 + 14 + /** 15 + * @var array<string, DidResolver> 16 + */ 17 + protected array $resolvers = []; 18 + 19 + /** 20 + * Create a new DID resolver manager instance. 21 + */ 22 + public function __construct() 23 + { 24 + $this->registerDefaultResolvers(); 25 + } 26 + 27 + /** 28 + * Resolve a DID to a DID Document. 29 + * 30 + * @param string $did The DID to resolve 31 + */ 32 + public function resolve(string $did): DidDocument 33 + { 34 + $method = $this->extractMethod($did); 35 + 36 + if (! $this->supports($method)) { 37 + throw DidResolutionException::unsupportedMethod($method); 38 + } 39 + 40 + return $this->resolvers[$method]->resolve($did); 41 + } 42 + 43 + /** 44 + * Check if this resolver supports the given DID method. 45 + * 46 + * @param string $method The DID method 47 + */ 48 + public function supports(string $method): bool 49 + { 50 + return isset($this->resolvers[$method]); 51 + } 52 + 53 + /** 54 + * Register a DID resolver for a specific method. 55 + * 56 + * @param string $method 57 + * @param DidResolver $resolver 58 + */ 59 + public function register(string $method, DidResolver $resolver): self 60 + { 61 + $this->resolvers[$method] = $resolver; 62 + 63 + return $this; 64 + } 65 + 66 + /** 67 + * Register the default resolvers. 68 + */ 69 + protected function registerDefaultResolvers(): void 70 + { 71 + $this->register('plc', new PlcDidResolver()); 72 + $this->register('web', new WebDidResolver()); 73 + } 74 + }
+73
src/Resolvers/PlcDidResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Resolvers; 4 + 5 + use GuzzleHttp\Client; 6 + use GuzzleHttp\Exception\GuzzleException; 7 + use SocialDept\Beacon\Contracts\DidResolver; 8 + use SocialDept\Beacon\Data\DidDocument; 9 + use SocialDept\Beacon\Exceptions\DidResolutionException; 10 + use SocialDept\Beacon\Support\Concerns\HasConfig; 11 + use SocialDept\Beacon\Support\Concerns\ParsesDid; 12 + 13 + class PlcDidResolver implements DidResolver 14 + { 15 + use HasConfig; 16 + use ParsesDid; 17 + 18 + protected Client $client; 19 + 20 + protected string $plcDirectory; 21 + 22 + /** 23 + * Create a new PLC DID resolver instance. 24 + * 25 + * @param string $plcDirectory The PLC directory URL 26 + */ 27 + public function __construct(?string $plcDirectory = null, ?int $timeout = null) 28 + { 29 + $this->plcDirectory = $plcDirectory ?? $this->getConfig('beacon.plc_directory', 'https://plc.directory'); 30 + $this->client = new Client([ 31 + 'timeout' => $timeout ?? $this->getConfig('beacon.timeout', 10), 32 + 'headers' => [ 33 + 'Accept' => 'application/json', 34 + 'User-Agent' => 'Beacon/1.0', 35 + ], 36 + ]); 37 + } 38 + 39 + /** 40 + * Resolve a DID:PLC to a DID Document. 41 + * 42 + * @param string $did The DID to resolve (e.g., "did:plc:abc123") 43 + */ 44 + public function resolve(string $did): DidDocument 45 + { 46 + if (! $this->supports($this->extractMethod($did))) { 47 + throw DidResolutionException::unsupportedMethod($this->extractMethod($did)); 48 + } 49 + 50 + try { 51 + $response = $this->client->get("{$this->plcDirectory}/{$did}"); 52 + $data = json_decode($response->getBody()->getContents(), true); 53 + 54 + if (! is_array($data)) { 55 + throw DidResolutionException::resolutionFailed($did, 'Invalid response format'); 56 + } 57 + 58 + return DidDocument::fromArray($data); 59 + } catch (GuzzleException $e) { 60 + throw DidResolutionException::resolutionFailed($did, $e->getMessage()); 61 + } 62 + } 63 + 64 + /** 65 + * Check if this resolver supports the given DID method. 66 + * 67 + * @param string $method The DID method (e.g., "plc") 68 + */ 69 + public function supports(string $method): bool 70 + { 71 + return $method === 'plc'; 72 + } 73 + }
+96
src/Resolvers/WebDidResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Resolvers; 4 + 5 + use GuzzleHttp\Client; 6 + use GuzzleHttp\Exception\GuzzleException; 7 + use SocialDept\Beacon\Contracts\DidResolver; 8 + use SocialDept\Beacon\Data\DidDocument; 9 + use SocialDept\Beacon\Exceptions\DidResolutionException; 10 + use SocialDept\Beacon\Support\Concerns\HasConfig; 11 + use SocialDept\Beacon\Support\Concerns\ParsesDid; 12 + 13 + class WebDidResolver implements DidResolver 14 + { 15 + use HasConfig; 16 + use ParsesDid; 17 + 18 + protected Client $client; 19 + 20 + /** 21 + * Create a new Web DID resolver instance. 22 + */ 23 + public function __construct(?int $timeout = null) 24 + { 25 + $this->client = new Client([ 26 + 'timeout' => $timeout ?? $this->getConfig('beacon.timeout', 10), 27 + 'headers' => [ 28 + 'Accept' => 'application/json', 29 + 'User-Agent' => 'Beacon/1.0', 30 + ], 31 + ]); 32 + } 33 + 34 + /** 35 + * Resolve a DID:Web to a DID Document. 36 + * 37 + * @param string $did The DID to resolve (e.g., "did:web:example.com") 38 + */ 39 + public function resolve(string $did): DidDocument 40 + { 41 + if (! $this->supports($this->extractMethod($did))) { 42 + throw DidResolutionException::unsupportedMethod($this->extractMethod($did)); 43 + } 44 + 45 + $url = $this->buildDidUrl($did); 46 + 47 + try { 48 + $response = $this->client->get($url); 49 + $data = json_decode($response->getBody()->getContents(), true); 50 + 51 + if (! is_array($data)) { 52 + throw DidResolutionException::resolutionFailed($did, 'Invalid response format'); 53 + } 54 + 55 + return DidDocument::fromArray($data); 56 + } catch (GuzzleException $e) { 57 + throw DidResolutionException::resolutionFailed($did, $e->getMessage()); 58 + } 59 + } 60 + 61 + /** 62 + * Check if this resolver supports the given DID method. 63 + * 64 + * @param string $method The DID method (e.g., "web") 65 + */ 66 + public function supports(string $method): bool 67 + { 68 + return $method === 'web'; 69 + } 70 + 71 + /** 72 + * Build the URL to fetch the DID document from. 73 + * 74 + * @param string $did 75 + */ 76 + protected function buildDidUrl(string $did): string 77 + { 78 + $identifier = $this->extractIdentifier($did); 79 + 80 + // Decode URL-encoded characters 81 + $domain = str_replace('%3A', ':', $identifier); 82 + 83 + // Split domain and path 84 + $parts = explode(':', $domain); 85 + $domainName = array_shift($parts); 86 + 87 + // If there's a path, append it; otherwise use .well-known/did.json 88 + if (count($parts) > 0) { 89 + $path = implode('/', $parts).'/did.json'; 90 + } else { 91 + $path = '.well-known/did.json'; 92 + } 93 + 94 + return "https://{$domainName}/{$path}"; 95 + } 96 + }
+22
src/Support/Concerns/HasConfig.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Support\Concerns; 4 + 5 + trait HasConfig 6 + { 7 + /** 8 + * Get configuration value with fallback for testing. 9 + */ 10 + protected function getConfig(string $key, mixed $default): mixed 11 + { 12 + if (! function_exists('config')) { 13 + return $default; 14 + } 15 + 16 + try { 17 + return config($key, $default); 18 + } catch (\Throwable) { 19 + return $default; 20 + } 21 + } 22 + }
+40
src/Support/Concerns/ParsesDid.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Support\Concerns; 4 + 5 + use SocialDept\Beacon\Exceptions\DidResolutionException; 6 + 7 + trait ParsesDid 8 + { 9 + /** 10 + * Extract the method from a DID. 11 + */ 12 + protected function extractMethod(string $did): string 13 + { 14 + if (! str_starts_with($did, 'did:')) { 15 + throw DidResolutionException::invalidFormat($did); 16 + } 17 + 18 + $parts = explode(':', $did); 19 + 20 + if (count($parts) < 3) { 21 + throw DidResolutionException::invalidFormat($did); 22 + } 23 + 24 + return $parts[1]; 25 + } 26 + 27 + /** 28 + * Extract the identifier from a DID. 29 + */ 30 + protected function extractIdentifier(string $did): string 31 + { 32 + $parts = explode(':', $did); 33 + 34 + if (count($parts) < 3) { 35 + throw DidResolutionException::invalidFormat($did); 36 + } 37 + 38 + return implode(':', array_slice($parts, 2)); 39 + } 40 + }
+69
src/Support/Identity.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Support; 4 + 5 + class Identity 6 + { 7 + // "***.bsky.social" "alice.test" 8 + protected const HANDLE_REGEX = '/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/'; 9 + 10 + // "did:plc:1234..." "did:web:alice.test" 11 + protected const DID_REGEX = '/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/'; 12 + 13 + /** 14 + * Check if a string is a valid handle. 15 + * 16 + * @param string|null $handle 17 + */ 18 + public static function isHandle(?string $handle): bool 19 + { 20 + return preg_match(self::HANDLE_REGEX, $handle ?? '') === 1; 21 + } 22 + 23 + /** 24 + * Check if a string is a valid DID. 25 + * 26 + * @param string|null $did 27 + */ 28 + public static function isDid(?string $did): bool 29 + { 30 + return preg_match(self::DID_REGEX, $did ?? '') === 1; 31 + } 32 + 33 + /** 34 + * Extract the DID method from a DID string. 35 + * 36 + * @param string $did 37 + * @return string|null Returns the method (e.g., "plc", "web") or null if invalid 38 + */ 39 + public static function extractDidMethod(string $did): ?string 40 + { 41 + if (! self::isDid($did)) { 42 + return null; 43 + } 44 + 45 + $parts = explode(':', $did); 46 + 47 + return $parts[1] ?? null; 48 + } 49 + 50 + /** 51 + * Check if a DID uses the PLC method. 52 + * 53 + * @param string $did 54 + */ 55 + public static function isPlcDid(string $did): bool 56 + { 57 + return self::extractDidMethod($did) === 'plc'; 58 + } 59 + 60 + /** 61 + * Check if a DID uses the Web method. 62 + * 63 + * @param string $did 64 + */ 65 + public static function isWebDid(string $did): bool 66 + { 67 + return self::extractDidMethod($did) === 'web'; 68 + } 69 + }
+121
tests/Unit/BeaconIdentityTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Beacon; 7 + use SocialDept\Beacon\Contracts\CacheStore; 8 + use SocialDept\Beacon\Contracts\DidResolver; 9 + use SocialDept\Beacon\Contracts\HandleResolver; 10 + use SocialDept\Beacon\Data\DidDocument; 11 + 12 + class BeaconIdentityTest extends TestCase 13 + { 14 + public function test_it_can_convert_handle_to_did(): void 15 + { 16 + $didResolver = $this->createMock(DidResolver::class); 17 + $handleResolver = $this->createMock(HandleResolver::class); 18 + $cache = $this->createMock(CacheStore::class); 19 + 20 + $handleResolver->expects($this->once()) 21 + ->method('resolve') 22 + ->with('user.bsky.social') 23 + ->willReturn('did:plc:abc123'); 24 + 25 + $cache->method('has')->willReturn(false); 26 + $cache->expects($this->once()) 27 + ->method('put') 28 + ->with('handle:user.bsky.social', 'did:plc:abc123', $this->anything()); 29 + 30 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 31 + $did = $beacon->handleToDid('user.bsky.social'); 32 + 33 + $this->assertSame('did:plc:abc123', $did); 34 + } 35 + 36 + public function test_it_can_resolve_handle(): void 37 + { 38 + $didResolver = $this->createMock(DidResolver::class); 39 + $handleResolver = $this->createMock(HandleResolver::class); 40 + $cache = $this->createMock(CacheStore::class); 41 + 42 + $handleResolver->expects($this->once()) 43 + ->method('resolve') 44 + ->with('user.bsky.social') 45 + ->willReturn('did:plc:abc123'); 46 + 47 + $didDocument = DidDocument::fromArray([ 48 + 'id' => 'did:plc:abc123', 49 + 'alsoKnownAs' => ['at://user.bsky.social'], 50 + ]); 51 + 52 + $didResolver->expects($this->once()) 53 + ->method('resolve') 54 + ->with('did:plc:abc123') 55 + ->willReturn($didDocument); 56 + 57 + $cache->method('has')->willReturn(false); 58 + 59 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 60 + $document = $beacon->resolveHandle('user.bsky.social'); 61 + 62 + $this->assertInstanceOf(DidDocument::class, $document); 63 + $this->assertSame('did:plc:abc123', $document->id); 64 + } 65 + 66 + public function test_it_can_resolve_identity_with_did(): void 67 + { 68 + $didResolver = $this->createMock(DidResolver::class); 69 + $handleResolver = $this->createMock(HandleResolver::class); 70 + $cache = $this->createMock(CacheStore::class); 71 + 72 + $didDocument = DidDocument::fromArray([ 73 + 'id' => 'did:plc:abc123', 74 + ]); 75 + 76 + $didResolver->expects($this->once()) 77 + ->method('resolve') 78 + ->with('did:plc:abc123') 79 + ->willReturn($didDocument); 80 + 81 + $cache->method('has')->willReturn(false); 82 + $handleResolver->expects($this->never())->method('resolve'); 83 + 84 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 85 + $document = $beacon->resolveIdentity('did:plc:abc123'); 86 + 87 + $this->assertInstanceOf(DidDocument::class, $document); 88 + $this->assertSame('did:plc:abc123', $document->id); 89 + } 90 + 91 + public function test_it_can_resolve_identity_with_handle(): void 92 + { 93 + $didResolver = $this->createMock(DidResolver::class); 94 + $handleResolver = $this->createMock(HandleResolver::class); 95 + $cache = $this->createMock(CacheStore::class); 96 + 97 + $handleResolver->expects($this->once()) 98 + ->method('resolve') 99 + ->with('user.bsky.social') 100 + ->willReturn('did:plc:abc123'); 101 + 102 + $didDocument = DidDocument::fromArray([ 103 + 'id' => 'did:plc:abc123', 104 + 'alsoKnownAs' => ['at://user.bsky.social'], 105 + ]); 106 + 107 + $didResolver->expects($this->once()) 108 + ->method('resolve') 109 + ->with('did:plc:abc123') 110 + ->willReturn($didDocument); 111 + 112 + $cache->method('has')->willReturn(false); 113 + 114 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 115 + $document = $beacon->resolveIdentity('user.bsky.social'); 116 + 117 + $this->assertInstanceOf(DidDocument::class, $document); 118 + $this->assertSame('did:plc:abc123', $document->id); 119 + $this->assertSame('user.bsky.social', $document->getHandle()); 120 + } 121 + }
+161
tests/Unit/BeaconTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Beacon; 7 + use SocialDept\Beacon\Contracts\CacheStore; 8 + use SocialDept\Beacon\Contracts\DidResolver; 9 + use SocialDept\Beacon\Contracts\HandleResolver; 10 + use SocialDept\Beacon\Data\DidDocument; 11 + 12 + class BeaconTest extends TestCase 13 + { 14 + public function test_it_can_resolve_pds_from_did(): void 15 + { 16 + $didResolver = $this->createMock(DidResolver::class); 17 + $handleResolver = $this->createMock(HandleResolver::class); 18 + $cache = $this->createMock(CacheStore::class); 19 + 20 + $didDocument = DidDocument::fromArray([ 21 + 'id' => 'did:plc:abc123', 22 + 'service' => [ 23 + [ 24 + 'type' => 'AtprotoPersonalDataServer', 25 + 'serviceEndpoint' => 'https://pds.example.com', 26 + ], 27 + ], 28 + ]); 29 + 30 + $didResolver->expects($this->once()) 31 + ->method('resolve') 32 + ->with('did:plc:abc123') 33 + ->willReturn($didDocument); 34 + 35 + $cache->method('has')->willReturn(false); 36 + 37 + // Expect multiple cache puts (DID document + PDS endpoint) 38 + $cache->expects($this->exactly(2)) 39 + ->method('put') 40 + ->willReturnCallback(function ($key, $value, $ttl) { 41 + $this->assertContains($key, ['did:did:plc:abc123', 'pds:did:plc:abc123']); 42 + 43 + return null; 44 + }); 45 + 46 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 47 + $pds = $beacon->resolvePds('did:plc:abc123'); 48 + 49 + $this->assertSame('https://pds.example.com', $pds); 50 + } 51 + 52 + public function test_it_can_resolve_pds_from_handle(): void 53 + { 54 + $didResolver = $this->createMock(DidResolver::class); 55 + $handleResolver = $this->createMock(HandleResolver::class); 56 + $cache = $this->createMock(CacheStore::class); 57 + 58 + $handleResolver->expects($this->once()) 59 + ->method('resolve') 60 + ->with('user.bsky.social') 61 + ->willReturn('did:plc:abc123'); 62 + 63 + $didDocument = DidDocument::fromArray([ 64 + 'id' => 'did:plc:abc123', 65 + 'service' => [ 66 + [ 67 + 'type' => 'AtprotoPersonalDataServer', 68 + 'serviceEndpoint' => 'https://pds.example.com', 69 + ], 70 + ], 71 + ]); 72 + 73 + $didResolver->expects($this->once()) 74 + ->method('resolve') 75 + ->with('did:plc:abc123') 76 + ->willReturn($didDocument); 77 + 78 + $cache->method('has')->willReturn(false); 79 + 80 + // Expect multiple cache puts (handle + DID document + PDS endpoint) 81 + $cache->expects($this->exactly(3)) 82 + ->method('put') 83 + ->willReturnCallback(function ($key, $value, $ttl) { 84 + $this->assertContains($key, ['handle:user.bsky.social', 'did:did:plc:abc123', 'pds:user.bsky.social']); 85 + 86 + return null; 87 + }); 88 + 89 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 90 + $pds = $beacon->resolvePds('user.bsky.social'); 91 + 92 + $this->assertSame('https://pds.example.com', $pds); 93 + } 94 + 95 + public function test_it_returns_null_when_no_pds_endpoint(): void 96 + { 97 + $didResolver = $this->createMock(DidResolver::class); 98 + $handleResolver = $this->createMock(HandleResolver::class); 99 + $cache = $this->createMock(CacheStore::class); 100 + 101 + $didDocument = DidDocument::fromArray([ 102 + 'id' => 'did:plc:abc123', 103 + 'service' => [], 104 + ]); 105 + 106 + $didResolver->expects($this->once()) 107 + ->method('resolve') 108 + ->with('did:plc:abc123') 109 + ->willReturn($didDocument); 110 + 111 + $cache->method('has')->willReturn(false); 112 + 113 + // DID document is still cached, but PDS endpoint is not (since it's null) 114 + $cache->expects($this->once()) 115 + ->method('put') 116 + ->with('did:did:plc:abc123', $didDocument, $this->anything()); 117 + 118 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 119 + $pds = $beacon->resolvePds('did:plc:abc123'); 120 + 121 + $this->assertNull($pds); 122 + } 123 + 124 + public function test_it_uses_cached_pds_endpoint(): void 125 + { 126 + $didResolver = $this->createMock(DidResolver::class); 127 + $handleResolver = $this->createMock(HandleResolver::class); 128 + $cache = $this->createMock(CacheStore::class); 129 + 130 + $cache->expects($this->once()) 131 + ->method('has') 132 + ->with('pds:did:plc:abc123') 133 + ->willReturn(true); 134 + 135 + $cache->expects($this->once()) 136 + ->method('get') 137 + ->with('pds:did:plc:abc123') 138 + ->willReturn('https://cached-pds.example.com'); 139 + 140 + $didResolver->expects($this->never())->method('resolve'); 141 + 142 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 143 + $pds = $beacon->resolvePds('did:plc:abc123'); 144 + 145 + $this->assertSame('https://cached-pds.example.com', $pds); 146 + } 147 + 148 + public function test_it_can_clear_pds_cache(): void 149 + { 150 + $didResolver = $this->createMock(DidResolver::class); 151 + $handleResolver = $this->createMock(HandleResolver::class); 152 + $cache = $this->createMock(CacheStore::class); 153 + 154 + $cache->expects($this->once()) 155 + ->method('forget') 156 + ->with('pds:did:plc:abc123'); 157 + 158 + $beacon = new Beacon($didResolver, $handleResolver, $cache); 159 + $beacon->clearPdsCache('did:plc:abc123'); 160 + } 161 + }
+87
tests/Unit/DidDocumentTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Data\DidDocument; 7 + 8 + class DidDocumentTest extends TestCase 9 + { 10 + public function test_it_can_be_created_from_array(): void 11 + { 12 + $data = [ 13 + 'id' => 'did:plc:abc123', 14 + 'alsoKnownAs' => ['at://user.bsky.social'], 15 + 'verificationMethod' => [], 16 + 'service' => [ 17 + [ 18 + 'type' => 'AtprotoPersonalDataServer', 19 + 'serviceEndpoint' => 'https://bsky.social', 20 + ], 21 + ], 22 + ]; 23 + 24 + $document = DidDocument::fromArray($data); 25 + 26 + $this->assertSame('did:plc:abc123', $document->id); 27 + $this->assertSame(['at://user.bsky.social'], $document->alsoKnownAs); 28 + $this->assertCount(1, $document->service); 29 + } 30 + 31 + public function test_it_can_get_pds_endpoint(): void 32 + { 33 + $document = DidDocument::fromArray([ 34 + 'id' => 'did:plc:abc123', 35 + 'service' => [ 36 + [ 37 + 'type' => 'AtprotoPersonalDataServer', 38 + 'serviceEndpoint' => 'https://bsky.social', 39 + ], 40 + ], 41 + ]); 42 + 43 + $this->assertSame('https://bsky.social', $document->getPdsEndpoint()); 44 + } 45 + 46 + public function test_it_returns_null_when_no_pds_endpoint(): void 47 + { 48 + $document = DidDocument::fromArray([ 49 + 'id' => 'did:plc:abc123', 50 + 'service' => [], 51 + ]); 52 + 53 + $this->assertNull($document->getPdsEndpoint()); 54 + } 55 + 56 + public function test_it_can_get_handle(): void 57 + { 58 + $document = DidDocument::fromArray([ 59 + 'id' => 'did:plc:abc123', 60 + 'alsoKnownAs' => ['at://user.bsky.social'], 61 + ]); 62 + 63 + $this->assertSame('user.bsky.social', $document->getHandle()); 64 + } 65 + 66 + public function test_it_returns_null_when_no_handle(): void 67 + { 68 + $document = DidDocument::fromArray([ 69 + 'id' => 'did:plc:abc123', 70 + 'alsoKnownAs' => [], 71 + ]); 72 + 73 + $this->assertNull($document->getHandle()); 74 + } 75 + 76 + public function test_it_can_convert_to_array(): void 77 + { 78 + $data = [ 79 + 'id' => 'did:plc:abc123', 80 + 'alsoKnownAs' => ['at://user.bsky.social'], 81 + ]; 82 + 83 + $document = DidDocument::fromArray($data); 84 + 85 + $this->assertSame($data, $document->toArray()); 86 + } 87 + }
+82
tests/Unit/DidResolverManagerTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Contracts\DidResolver; 7 + use SocialDept\Beacon\Data\DidDocument; 8 + use SocialDept\Beacon\Exceptions\DidResolutionException; 9 + use SocialDept\Beacon\Resolvers\DidResolverManager; 10 + 11 + class DidResolverManagerTest extends TestCase 12 + { 13 + public function test_it_supports_plc_method_by_default(): void 14 + { 15 + $manager = new DidResolverManager(); 16 + 17 + $this->assertTrue($manager->supports('plc')); 18 + } 19 + 20 + public function test_it_supports_web_method_by_default(): void 21 + { 22 + $manager = new DidResolverManager(); 23 + 24 + $this->assertTrue($manager->supports('web')); 25 + } 26 + 27 + public function test_it_does_not_support_unknown_methods(): void 28 + { 29 + $manager = new DidResolverManager(); 30 + 31 + $this->assertFalse($manager->supports('unknown')); 32 + } 33 + 34 + public function test_it_can_register_custom_resolver(): void 35 + { 36 + $manager = new DidResolverManager(); 37 + 38 + $customResolver = $this->createMock(DidResolver::class); 39 + $customResolver->method('supports')->willReturn(true); 40 + 41 + $manager->register('custom', $customResolver); 42 + 43 + $this->assertTrue($manager->supports('custom')); 44 + } 45 + 46 + public function test_it_throws_exception_for_unsupported_method(): void 47 + { 48 + $this->expectException(DidResolutionException::class); 49 + $this->expectExceptionMessage('Unsupported DID method: unknown'); 50 + 51 + $manager = new DidResolverManager(); 52 + $manager->resolve('did:unknown:abc123'); 53 + } 54 + 55 + public function test_it_throws_exception_for_invalid_did_format(): void 56 + { 57 + $this->expectException(DidResolutionException::class); 58 + $this->expectExceptionMessage('Invalid DID format'); 59 + 60 + $manager = new DidResolverManager(); 61 + $manager->resolve('not-a-did'); 62 + } 63 + 64 + public function test_it_delegates_to_registered_resolver(): void 65 + { 66 + $manager = new DidResolverManager(); 67 + 68 + $mockDocument = DidDocument::fromArray(['id' => 'did:custom:abc123']); 69 + 70 + $customResolver = $this->createMock(DidResolver::class); 71 + $customResolver->expects($this->once()) 72 + ->method('resolve') 73 + ->with('did:custom:abc123') 74 + ->willReturn($mockDocument); 75 + 76 + $manager->register('custom', $customResolver); 77 + 78 + $result = $manager->resolve('did:custom:abc123'); 79 + 80 + $this->assertSame($mockDocument, $result); 81 + } 82 + }
+62
tests/Unit/IdentityTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Support\Identity; 7 + 8 + class IdentityTest extends TestCase 9 + { 10 + public function test_it_validates_handles(): void 11 + { 12 + $this->assertTrue(Identity::isHandle('alice.bsky.social')); 13 + $this->assertTrue(Identity::isHandle('user.test')); 14 + $this->assertTrue(Identity::isHandle('my-handle.example.com')); 15 + $this->assertTrue(Identity::isHandle('a.b.c.d.example.com')); 16 + 17 + $this->assertFalse(Identity::isHandle('')); 18 + $this->assertFalse(Identity::isHandle(null)); 19 + $this->assertFalse(Identity::isHandle('invalid')); 20 + $this->assertFalse(Identity::isHandle('no-tld')); 21 + $this->assertFalse(Identity::isHandle('.invalid')); 22 + $this->assertFalse(Identity::isHandle('invalid.')); 23 + } 24 + 25 + public function test_it_validates_dids(): void 26 + { 27 + $this->assertTrue(Identity::isDid('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 28 + $this->assertTrue(Identity::isDid('did:web:example.com')); 29 + $this->assertTrue(Identity::isDid('did:plc:abc123')); 30 + $this->assertTrue(Identity::isDid('did:web:alice.test')); 31 + 32 + $this->assertFalse(Identity::isDid('')); 33 + $this->assertFalse(Identity::isDid(null)); 34 + $this->assertFalse(Identity::isDid('invalid')); 35 + $this->assertFalse(Identity::isDid('did:')); 36 + $this->assertFalse(Identity::isDid('did:plc:')); 37 + $this->assertFalse(Identity::isDid('not-a-did')); 38 + } 39 + 40 + public function test_it_extracts_did_method(): void 41 + { 42 + $this->assertSame('plc', Identity::extractDidMethod('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 43 + $this->assertSame('web', Identity::extractDidMethod('did:web:example.com')); 44 + 45 + $this->assertNull(Identity::extractDidMethod('invalid')); 46 + $this->assertNull(Identity::extractDidMethod('')); 47 + } 48 + 49 + public function test_it_checks_plc_did(): void 50 + { 51 + $this->assertTrue(Identity::isPlcDid('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 52 + $this->assertFalse(Identity::isPlcDid('did:web:example.com')); 53 + $this->assertFalse(Identity::isPlcDid('invalid')); 54 + } 55 + 56 + public function test_it_checks_web_did(): void 57 + { 58 + $this->assertTrue(Identity::isWebDid('did:web:example.com')); 59 + $this->assertFalse(Identity::isWebDid('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 60 + $this->assertFalse(Identity::isWebDid('invalid')); 61 + } 62 + }
+46
tests/Unit/PlcDidResolverTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Exceptions\DidResolutionException; 7 + use SocialDept\Beacon\Resolvers\PlcDidResolver; 8 + 9 + class PlcDidResolverTest extends TestCase 10 + { 11 + public function test_it_supports_plc_method(): void 12 + { 13 + $resolver = new PlcDidResolver(); 14 + 15 + $this->assertTrue($resolver->supports('plc')); 16 + $this->assertFalse($resolver->supports('web')); 17 + $this->assertFalse($resolver->supports('unknown')); 18 + } 19 + 20 + public function test_it_throws_exception_for_invalid_did_format(): void 21 + { 22 + $this->expectException(DidResolutionException::class); 23 + $this->expectExceptionMessage('Invalid DID format'); 24 + 25 + $resolver = new PlcDidResolver(); 26 + $resolver->resolve('invalid-did'); 27 + } 28 + 29 + public function test_it_throws_exception_for_incomplete_did(): void 30 + { 31 + $this->expectException(DidResolutionException::class); 32 + $this->expectExceptionMessage('Invalid DID format'); 33 + 34 + $resolver = new PlcDidResolver(); 35 + $resolver->resolve('did:plc'); 36 + } 37 + 38 + public function test_it_throws_exception_for_unsupported_method(): void 39 + { 40 + $this->expectException(DidResolutionException::class); 41 + $this->expectExceptionMessage('Unsupported DID method: web'); 42 + 43 + $resolver = new PlcDidResolver(); 44 + $resolver->resolve('did:web:example.com'); 45 + } 46 + }
+37
tests/Unit/WebDidResolverTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Beacon\Tests\Unit; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Beacon\Exceptions\DidResolutionException; 7 + use SocialDept\Beacon\Resolvers\WebDidResolver; 8 + 9 + class WebDidResolverTest extends TestCase 10 + { 11 + public function test_it_supports_web_method(): void 12 + { 13 + $resolver = new WebDidResolver(); 14 + 15 + $this->assertTrue($resolver->supports('web')); 16 + $this->assertFalse($resolver->supports('plc')); 17 + $this->assertFalse($resolver->supports('unknown')); 18 + } 19 + 20 + public function test_it_throws_exception_for_invalid_did_format(): void 21 + { 22 + $this->expectException(DidResolutionException::class); 23 + $this->expectExceptionMessage('Invalid DID format'); 24 + 25 + $resolver = new WebDidResolver(); 26 + $resolver->resolve('invalid-did'); 27 + } 28 + 29 + public function test_it_throws_exception_for_unsupported_method(): void 30 + { 31 + $this->expectException(DidResolutionException::class); 32 + $this->expectExceptionMessage('Unsupported DID method: plc'); 33 + 34 + $resolver = new WebDidResolver(); 35 + $resolver->resolve('did:plc:abc123'); 36 + } 37 + }