···149149 | DNS-Based Lexicon Resolution
150150 |--------------------------------------------------------------------------
151151 |
152152- | Configure DNS-based lexicon resolution for third-party schemas.
153153- | Requires socialdept/atp-resolver package for DID resolution.
152152+ | Configure DNS-based lexicon resolution following AT Protocol specification.
153153+ |
154154+ | When enabled, the schema loader will attempt to discover custom lexicons via:
155155+ | 1. Querying DNS TXT record at _lexicon.<authority-domain> for DID
156156+ | 2. Resolving DID to PDS endpoint (requires socialdept/atp-resolver)
157157+ | 3. Fetching lexicon from repository via com.atproto.repo.getRecord
158158+ |
159159+ | IMPORTANT: DNS resolution requires the optional socialdept/atp-resolver package.
160160+ | Install with: composer require socialdept/atp-resolver
161161+ |
162162+ | If atp-resolver is not installed, DNS resolution will be skipped and a
163163+ | warning will be logged. The schema loader will fall back to local sources.
154164 |
155165 */
156166157167 'dns_resolution' => [
158158- // Enable DNS-based lexicon resolution
168168+ // Enable DNS-based lexicon resolution (requires socialdept/atp-resolver)
159169 'enabled' => env('SCHEMA_DNS_RESOLUTION_ENABLED', true),
160160-161161- // Use Resolver for DID resolution (requires socialdept/atp-resolver)
162162- 'use_resolver' => env('SCHEMA_USE_RESOLVER', true),
163163-164164- // Fallback behavior when schema not found: fail, warn, allow
165165- 'fallback' => env('SCHEMA_DNS_FALLBACK', 'warn'),
166166-167167- // DNS nameservers (null = system default)
168168- 'nameservers' => env('SCHEMA_DNS_NAMESERVERS', null),
169169-170170- // DNS query timeout (seconds)
171171- 'timeout' => env('SCHEMA_DNS_TIMEOUT', 5),
172172-173173- // Auto-generate Data classes for resolved schemas
174174- 'auto_generate' => env('SCHEMA_DNS_AUTO_GENERATE', false),
175170 ],
176171177172 /*
+141-26
src/Parser/SchemaLoader.php
···4949 protected int $httpTimeout;
50505151 /**
5252+ * Whether the atp-resolver package is available.
5353+ */
5454+ protected bool $hasResolver = false;
5555+5656+ /**
5757+ * Whether we've shown the resolver warning.
5858+ */
5959+ protected static bool $resolverWarningShown = false;
6060+6161+ /**
5262 * Create a new SchemaLoader instance.
5363 *
5464 * @param array<string> $sources
···6777 $this->cachePrefix = $cachePrefix;
6878 $this->dnsResolutionEnabled = $dnsResolutionEnabled;
6979 $this->httpTimeout = $httpTimeout;
8080+ $this->hasResolver = class_exists('SocialDept\\Resolver\\Resolver');
7081 }
71827283 /**
···268279 }
269280270281 /**
271271- * Load schema via DNS resolution from official sources.
282282+ * Load schema via DNS resolution following AT Protocol spec.
283283+ *
284284+ * AT Protocol DNS-based lexicon discovery:
285285+ * 1. Query DNS TXT record at _lexicon.<authority-domain>
286286+ * 2. Extract DID from TXT record (format: did=<DID>)
287287+ * 3. Resolve DID to PDS endpoint (requires atp-resolver package)
288288+ * 4. Fetch lexicon from repository via com.atproto.repo.getRecord
272289 */
273290 protected function loadViaDns(string $nsid): ?array
274291 {
275275- // Try to fetch from official AT Protocol GitHub repository
276276- $urls = $this->getOfficialSchemaUrls($nsid);
292292+ // Check if atp-resolver is available
293293+ if (! $this->hasResolver) {
294294+ $this->showResolverWarning();
277295278278- foreach ($urls as $url) {
279279- try {
280280- $response = Http::timeout($this->httpTimeout)
281281- ->get($url);
296296+ return null;
297297+ }
298298+299299+ try {
300300+ $nsidParsed = Nsid::parse($nsid);
301301+302302+ // Step 1: Query DNS TXT record for DID
303303+ $did = $this->queryLexiconDid($nsidParsed);
304304+ if ($did === null) {
305305+ return null;
306306+ }
282307283283- if ($response->successful()) {
284284- $data = $response->json();
308308+ // Step 2: Resolve DID to PDS endpoint
309309+ $pdsUrl = $this->resolvePdsEndpoint($did);
310310+ if ($pdsUrl === null) {
311311+ return null;
312312+ }
285313286286- if (is_array($data) && isset($data['lexicon'])) {
287287- return $data;
288288- }
314314+ // Step 3: Fetch lexicon schema from repository
315315+ return $this->fetchLexiconFromRepository($pdsUrl, $did, $nsid);
316316+ } catch (\Exception $e) {
317317+ // Silently fail and return null - will try other sources or fail with main error
318318+ return null;
319319+ }
320320+ }
321321+322322+ /**
323323+ * Query DNS TXT record for lexicon DID.
324324+ *
325325+ * Queries _lexicon.<authority-domain> for TXT record containing did=<DID>
326326+ */
327327+ protected function queryLexiconDid(Nsid $nsid): ?string
328328+ {
329329+ // Convert authority to domain (e.g., pub.leaflet -> leaflet.pub)
330330+ $authority = $nsid->getAuthority();
331331+ $parts = explode('.', $authority);
332332+ $domain = implode('.', array_reverse($parts));
333333+334334+ // Query DNS TXT record at _lexicon.<domain>
335335+ $hostname = "_lexicon.{$domain}";
336336+337337+ try {
338338+ $records = dns_get_record($hostname, DNS_TXT);
339339+340340+ if ($records === false || empty($records)) {
341341+ return null;
342342+ }
343343+344344+ // Look for TXT record with did= prefix
345345+ foreach ($records as $record) {
346346+ if (isset($record['txt']) && str_starts_with($record['txt'], 'did=')) {
347347+ return substr($record['txt'], 4); // Remove 'did=' prefix
289348 }
290290- } catch (\Exception $e) {
291291- // Continue to next URL
292292- continue;
293349 }
350350+ } catch (\Exception $e) {
351351+ // DNS query failed
352352+ return null;
294353 }
295354296355 return null;
297356 }
298357299358 /**
300300- * Get official schema URLs for an NSID.
301301- *
302302- * @return array<string>
359359+ * Resolve DID to PDS endpoint using atp-resolver.
303360 */
304304- protected function getOfficialSchemaUrls(string $nsid): array
361361+ protected function resolvePdsEndpoint(string $did): ?string
305362 {
306306- $path = str_replace('.', '/', $nsid);
363363+ if (! $this->hasResolver) {
364364+ return null;
365365+ }
307366308308- return [
309309- // Official AT Protocol lexicons repository (main branch)
310310- "https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/{$path}.json",
367367+ try {
368368+ // Get resolver from Laravel container if available
369369+ if (function_exists('app') && app()->has(\SocialDept\Resolver\Resolver::class)) {
370370+ $resolver = app(\SocialDept\Resolver\Resolver::class);
371371+ } else {
372372+ // Can't instantiate without dependencies
373373+ return null;
374374+ }
375375+376376+ // Use the resolvePds method which handles DID resolution and PDS extraction
377377+ return $resolver->resolvePds($did);
378378+ } catch (\Exception $e) {
379379+ return null;
380380+ }
381381+ }
311382312312- // Fallback to legacy location
313313- "https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/{$nsid}.json",
314314- ];
383383+ /**
384384+ * Fetch lexicon schema from AT Protocol repository.
385385+ */
386386+ protected function fetchLexiconFromRepository(string $pdsUrl, string $did, string $nsid): ?array
387387+ {
388388+ try {
389389+ // Construct XRPC call to com.atproto.repo.getRecord
390390+ $response = Http::timeout($this->httpTimeout)
391391+ ->get("{$pdsUrl}/xrpc/com.atproto.repo.getRecord", [
392392+ 'repo' => $did,
393393+ 'collection' => 'com.atproto.lexicon.schema',
394394+ 'rkey' => $nsid,
395395+ ]);
396396+397397+ if ($response->successful()) {
398398+ $data = $response->json();
399399+400400+ // Extract the lexicon schema from the record value
401401+ if (isset($data['value']) && is_array($data['value']) && isset($data['value']['lexicon'])) {
402402+ return $data['value'];
403403+ }
404404+ }
405405+ } catch (\Exception $e) {
406406+ return null;
407407+ }
408408+409409+ return null;
410410+ }
411411+412412+ /**
413413+ * Show warning about missing atp-resolver package.
414414+ */
415415+ protected function showResolverWarning(): void
416416+ {
417417+ if (self::$resolverWarningShown) {
418418+ return;
419419+ }
420420+421421+ if (function_exists('logger')) {
422422+ logger()->warning(
423423+ 'DNS-based lexicon resolution requires the socialdept/atp-resolver package. '.
424424+ 'Install it with: composer require socialdept/atp-resolver '.
425425+ 'Falling back to local lexicon sources only.'
426426+ );
427427+ }
428428+429429+ self::$resolverWarningShown = true;
315430 }
316431}