A barebones implementation of an atproto PDS in PHP and Slim Framework 4.
pds php atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement HTTP client for PLC Directory operations

Andrés Ignacio Torres (May 23, 2026, 11:52 PM -0700) a11361fe 72347253

+207 -7
+14 -2
app/repositories.php
··· 23 23 use App\Domain\Crypto\KeypairFactory; 24 24 use App\Domain\Did\DidCacheRepository; 25 25 use App\Domain\Did\DidResolver; 26 + use App\Domain\Did\PlcDirectoryClient; 26 27 use App\Domain\Lexicon\LexiconRepository; 27 28 use App\Domain\OAuth\AccountDeviceRepository; 28 29 use App\Domain\OAuth\AuthorizationRequestRepository; ··· 38 39 use App\Domain\Repo\RepoRootRepository; 39 40 use App\Domain\Sequencer\SequencerRepository; 40 41 use App\Infrastructure\Account\Password\ScryptPasswordHasher; 41 - use App\Infrastructure\Atproto\AppView\GuzzleAppViewClient; 42 + use App\Infrastructure\Atproto\AppView\HttpAppViewClient; 42 43 use App\Infrastructure\Auth\JwtAuthTokenIssuer; 43 44 use App\Infrastructure\Crypto\Secp256k1KeypairFactory; 45 + use App\Infrastructure\Did\HttpPlcDirectoryClient; 44 46 use App\Infrastructure\Did\HttpDidResolver; 45 47 use App\Infrastructure\Database\Database; 46 48 use App\Infrastructure\Database\Schema\AccountSchema; ··· 133 135 'headers' => ['Accept' => 'application/json'], 134 136 ]); 135 137 136 - return new GuzzleAppViewClient($httpClient); 138 + return new HttpAppViewClient($httpClient); 137 139 }, 138 140 AccountRepository::class => fn (ContainerInterface $c): SqliteAccountRepository => 139 141 new SqliteAccountRepository($getDb($c, 'db.account')), ··· 208 210 new SqliteLexiconRepository($getDb($c, 'db.account')), 209 211 OAuthTokenRepository::class => fn (ContainerInterface $c): SqliteOAuthTokenRepository => 210 212 new SqliteOAuthTokenRepository($getDb($c, 'db.account')), 213 + PlcDirectoryClient::class => function (ContainerInterface $c): HttpPlcDirectoryClient { 214 + $settings = $c->get(SettingsInterface::class); 215 + assert($settings instanceof SettingsInterface); 216 + /** @var array{plcDirectoryUrl: string} $pdsSettings */ 217 + $pdsSettings = $settings->get('pds'); 218 + $http = new GuzzleClient(['timeout' => 15.0]); 219 + $cbor = $c->get(DagCborEncoder::class); 220 + assert($cbor instanceof DagCborEncoder); 221 + return new HttpPlcDirectoryClient($http, $cbor, $pdsSettings['plcDirectoryUrl']); 222 + }, 211 223 PasswordHasher::class => autowire(ScryptPasswordHasher::class), 212 224 RefreshTokenRepository::class => fn (ContainerInterface $c): SqliteRefreshTokenRepository => 213 225 new SqliteRefreshTokenRepository($getDb($c, 'db.account')),
+64
src/Domain/Did/PlcDirectoryClient.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Domain\Did; 6 + 7 + use App\Domain\Crypto\Keypair; 8 + 9 + /** 10 + * Submits PLC operations to the configured PLC directory. 11 + * 12 + * Each "operation" is a JSON-like map describing the desired identity 13 + * state. `formatAndSign` adds the signature; `didForOp` derives the 14 + * resulting `did:plc:*` from a signed op; `submit` POSTs the signed 15 + * operation to the directory. 16 + * 17 + * @see https://github.com/did-method-plc/did-method-plc 18 + */ 19 + interface PlcDirectoryClient 20 + { 21 + public const OP_TYPE = 'plc_operation'; 22 + 23 + /** 24 + * Build a signed plcOp for a fresh account. 25 + * 26 + * @param list<string> $rotationKeys did:key strings, in priority order 27 + * @param string $signingKey did:key string for atproto signing key 28 + * @param string $handle bare handle (e.g. "alice.example.com") 29 + * @param string $pdsEndpoint full URL (e.g. "https://example.com") 30 + * 31 + * @return array<string, mixed> the signed operation 32 + */ 33 + public function buildAndSignGenesisOp( 34 + array $rotationKeys, 35 + string $signingKey, 36 + string $handle, 37 + string $pdsEndpoint, 38 + Keypair $signer, 39 + ): array; 40 + 41 + /** 42 + * Sign an unsigned plcOp (operation lacking a `sig` field). 43 + * 44 + * @param array<string, mixed> $unsignedOp 45 + * @return array<string, mixed> the operation with a `sig` field appended 46 + */ 47 + public function signOp(array $unsignedOp, Keypair $signer): array; 48 + 49 + /** 50 + * Compute the `did:plc:*` derived from a signed (genesis) operation. 51 + * 52 + * @param array<string, mixed> $signedOp 53 + */ 54 + public function didForOp(array $signedOp): string; 55 + 56 + /** 57 + * POST the signed operation to the PLC directory at the given DID. 58 + * 59 + * @param array<string, mixed> $signedOp 60 + * 61 + * @throws PlcDirectoryClientException on transport/HTTP failure 62 + */ 63 + public function submit(string $did, array $signedOp): void; 64 + }
+11
src/Domain/Did/PlcDirectoryClientException.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Domain\Did; 6 + 7 + use RuntimeException; 8 + 9 + class PlcDirectoryClientException extends RuntimeException 10 + { 11 + }
+1 -1
src/Infrastructure/Atproto/AppView/GuzzleAppViewClient.php src/Infrastructure/Atproto/AppView/HttpAppViewClient.php
··· 9 9 use GuzzleHttp\ClientInterface; 10 10 use GuzzleHttp\Exception\GuzzleException; 11 11 12 - class GuzzleAppViewClient implements AppViewClient 12 + class HttpAppViewClient implements AppViewClient 13 13 { 14 14 private ClientInterface $httpClient; 15 15
+113
src/Infrastructure/Did/HttpPlcDirectoryClient.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Did; 6 + 7 + use App\Domain\Common\Base32; 8 + use App\Domain\Crypto\Keypair; 9 + use App\Domain\Did\PlcDirectoryClient; 10 + use App\Domain\Did\PlcDirectoryClientException; 11 + use App\Domain\Repo\DagCborEncoder; 12 + use GuzzleHttp\ClientInterface; 13 + use GuzzleHttp\Exception\GuzzleException; 14 + 15 + /** 16 + * HTTP-backed {@see PlcDirectoryClient}. 17 + * 18 + * Implements the did:plc operation format: 19 + * - operation is a CBOR-canonical map 20 + * - `sig` is base64url(no-padding) of the secp256k1 signature over the 21 + * CBOR encoding of the operation WITHOUT the sig field 22 + * - resulting DID is `did:plc:` + first 24 chars of 23 + * base32-lower(sha256(cbor(signed-op))) 24 + * 25 + * @see https://github.com/did-method-plc/did-method-plc 26 + */ 27 + final class HttpPlcDirectoryClient implements PlcDirectoryClient 28 + { 29 + public function __construct( 30 + private readonly ClientInterface $httpClient, 31 + private readonly DagCborEncoder $cbor, 32 + private readonly string $plcDirectoryUrl, 33 + ) { 34 + } 35 + 36 + public function buildAndSignGenesisOp( 37 + array $rotationKeys, 38 + string $signingKey, 39 + string $handle, 40 + string $pdsEndpoint, 41 + Keypair $signer, 42 + ): array { 43 + $unsigned = [ 44 + 'type' => self::OP_TYPE, 45 + 'rotationKeys' => $rotationKeys, 46 + 'verificationMethods' => [ 47 + 'atproto' => $signingKey, 48 + ], 49 + 'alsoKnownAs' => [ 50 + 'at://' . $handle, 51 + ], 52 + 'services' => [ 53 + 'atproto_pds' => [ 54 + 'type' => 'AtprotoPersonalDataServer', 55 + 'endpoint' => rtrim($pdsEndpoint, '/'), 56 + ], 57 + ], 58 + 'prev' => null, 59 + ]; 60 + 61 + return $this->signOp($unsigned, $signer); 62 + } 63 + 64 + public function signOp(array $unsignedOp, Keypair $signer): array 65 + { 66 + unset($unsignedOp['sig']); 67 + $bytes = $this->cbor->encode($unsignedOp); 68 + $sig = $signer->sign($bytes); 69 + $unsignedOp['sig'] = self::base64UrlEncode($sig); 70 + return $unsignedOp; 71 + } 72 + 73 + public function didForOp(array $signedOp): string 74 + { 75 + $bytes = $this->cbor->encode($signedOp); 76 + $hash = hash('sha256', $bytes, true); 77 + $b32 = Base32::encode($hash); 78 + return 'did:plc:' . substr($b32, 0, 24); 79 + } 80 + 81 + public function submit(string $did, array $signedOp): void 82 + { 83 + $url = rtrim($this->plcDirectoryUrl, '/') . '/' . rawurlencode($did); 84 + try { 85 + $response = $this->httpClient->request('POST', $url, [ 86 + 'json' => $signedOp, 87 + 'timeout' => 15, 88 + 'headers' => [ 89 + 'Accept' => 'application/json', 90 + 'Content-Type' => 'application/json', 91 + ], 92 + ]); 93 + } catch (GuzzleException $e) { 94 + throw new PlcDirectoryClientException( 95 + "Failed to submit plcOp for {$did}: " . $e->getMessage(), 96 + 0, 97 + $e, 98 + ); 99 + } 100 + 101 + $status = $response->getStatusCode(); 102 + if ($status < 200 || $status >= 300) { 103 + throw new PlcDirectoryClientException( 104 + "PLC directory rejected operation for {$did}: HTTP {$status} {$response->getBody()}" 105 + ); 106 + } 107 + } 108 + 109 + public static function base64UrlEncode(string $bytes): string 110 + { 111 + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); 112 + } 113 + }
+4 -4
tests/Infrastructure/Atproto/AppView/GuzzleAppViewClientTest.php tests/Infrastructure/Atproto/AppView/HttpAppViewClientTest.php
··· 5 5 namespace Tests\Infrastructure\Atproto\AppView; 6 6 7 7 use App\Domain\Pds\Atproto\AppView\AppViewException; 8 - use App\Infrastructure\Atproto\AppView\GuzzleAppViewClient; 8 + use App\Infrastructure\Atproto\AppView\HttpAppViewClient; 9 9 use GuzzleHttp\Client; 10 10 use GuzzleHttp\Exception\ConnectException; 11 11 use GuzzleHttp\Handler\MockHandler; ··· 15 15 use GuzzleHttp\Psr7\Response; 16 16 use Tests\TestCase; 17 17 18 - class GuzzleAppViewClientTest extends TestCase 18 + class HttpAppViewClientTest extends TestCase 19 19 { 20 20 /** 21 21 * @param list<Response|\Throwable> $queue 22 22 * @param array<int, array<string, mixed>> $history 23 23 */ 24 - private function makeClient(array $queue, array &$history = []): GuzzleAppViewClient 24 + private function makeClient(array $queue, array &$history = []): HttpAppViewClient 25 25 { 26 26 $mock = new MockHandler($queue); 27 27 $stack = HandlerStack::create($mock); ··· 33 33 'base_uri' => 'https://api.bsky.app/', 34 34 ]); 35 35 36 - return new GuzzleAppViewClient($httpClient); 36 + return new HttpAppViewClient($httpClient); 37 37 } 38 38 39 39 public function testResolveHandleReturnsDidOnSuccess(): void