Skip to content

Commit 663a6f8

Browse files
Add LexiconResolveHandle
1 parent 8ee9cea commit 663a6f8

5 files changed

Lines changed: 513 additions & 3 deletions

File tree

src/CarpaNet.BuildTasks/LexiconResolver.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,91 @@ public async Task<Dictionary<string, string>> ResolveAuthorityAsync(
147147
return await ResolveDidAsync(did, cancellationToken).ConfigureAwait(false);
148148
}
149149

150+
/// <summary>
151+
/// Resolves an AT Protocol handle to a DID, then enumerates all lexicon records.
152+
/// Resolution strategy: DNS TXT (_atproto.{handle}) first, HTTPS well-known fallback.
153+
/// </summary>
154+
public async Task<Dictionary<string, string>> ResolveHandleAsync(
155+
string handle,
156+
CancellationToken cancellationToken = default)
157+
{
158+
_logInfo($"Resolving handle: {handle}");
159+
160+
var did = await ResolveHandleToDidAsync(handle, cancellationToken).ConfigureAwait(false);
161+
if (did == null)
162+
throw new LexiconResolutionException($"Could not resolve handle '{handle}' to a DID (tried DNS TXT and HTTPS well-known)");
163+
164+
_logInfo($"Handle '{handle}' resolved to DID: {did}");
165+
166+
return await ResolveDidAsync(did, cancellationToken).ConfigureAwait(false);
167+
}
168+
169+
/// <summary>
170+
/// Resolves an AT Protocol handle to a DID using DNS TXT first, then HTTPS fallback.
171+
/// </summary>
172+
private async Task<string?> ResolveHandleToDidAsync(string handle, CancellationToken cancellationToken)
173+
{
174+
// Try DNS TXT first: _atproto.{handle}
175+
var did = await TryResolveHandleDnsAsync(handle, cancellationToken).ConfigureAwait(false);
176+
if (did != null)
177+
{
178+
_logInfo($"Handle '{handle}' resolved via DNS TXT");
179+
return did;
180+
}
181+
182+
// Fallback: HTTPS well-known
183+
did = await TryResolveHandleHttpsAsync(handle, cancellationToken).ConfigureAwait(false);
184+
if (did != null)
185+
{
186+
_logInfo($"Handle '{handle}' resolved via HTTPS well-known");
187+
return did;
188+
}
189+
190+
return null;
191+
}
192+
193+
/// <summary>
194+
/// Attempts handle resolution via DNS TXT record at _atproto.{handle}.
195+
/// </summary>
196+
private async Task<string?> TryResolveHandleDnsAsync(string handle, CancellationToken cancellationToken)
197+
{
198+
try
199+
{
200+
var dnsName = $"_atproto.{handle}";
201+
return await ResolveDnsToDidAsync(dnsName, cancellationToken).ConfigureAwait(false);
202+
}
203+
catch
204+
{
205+
return null;
206+
}
207+
}
208+
209+
/// <summary>
210+
/// Attempts handle resolution via HTTPS GET https://{handle}/.well-known/atproto-did.
211+
/// </summary>
212+
private async Task<string?> TryResolveHandleHttpsAsync(string handle, CancellationToken cancellationToken)
213+
{
214+
try
215+
{
216+
var url = $"https://{handle}/.well-known/atproto-did";
217+
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
218+
if (!response.IsSuccessStatusCode)
219+
return null;
220+
221+
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
222+
var did = body.Trim();
223+
224+
if (did.StartsWith("did:", StringComparison.OrdinalIgnoreCase))
225+
return did;
226+
227+
return null;
228+
}
229+
catch
230+
{
231+
return null;
232+
}
233+
}
234+
150235
/// <summary>
151236
/// Enumerates all lexicon records published by a DID using com.atproto.repo.listRecords.
152237
/// Skips DNS resolution and goes directly to DID → PDS → listRecords.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.Build.Framework;
4+
using Microsoft.Build.Utilities;
5+
6+
namespace CarpaNet.BuildTasks;
7+
8+
/// <summary>
9+
/// MSBuild task that resolves AT Protocol handles to DIDs, then enumerates their lexicon records.
10+
/// Invoked when <see cref="LexiconResolveHandle"/> items are present.
11+
/// </summary>
12+
public sealed class ResolveHandleLexiconsTask : Microsoft.Build.Utilities.Task
13+
{
14+
/// <summary>
15+
/// Handles to resolve (from LexiconResolveHandle MSBuild items).
16+
/// </summary>
17+
[Required]
18+
public ITaskItem[] Handles { get; set; } = Array.Empty<ITaskItem>();
19+
20+
/// <summary>
21+
/// Directory to cache resolved lexicon files.
22+
/// </summary>
23+
[Required]
24+
public string CacheDir { get; set; } = string.Empty;
25+
26+
/// <summary>
27+
/// Cache TTL in hours. Set to 0 to force refresh.
28+
/// </summary>
29+
public double CacheTtlHours { get; set; } = 24;
30+
31+
/// <summary>
32+
/// Whether to fail the build on resolution errors (true) or just warn (false).
33+
/// </summary>
34+
public bool FailOnError { get; set; } = true;
35+
36+
/// <summary>
37+
/// PLC directory URL for did:plc resolution.
38+
/// </summary>
39+
public string PlcDirectoryUrl { get; set; } = "https://plc.directory";
40+
41+
/// <summary>
42+
/// Semicolon-separated DNS server IP addresses.
43+
/// </summary>
44+
public string DnsServers { get; set; } = string.Empty;
45+
46+
/// <summary>
47+
/// Output: resolved lexicon file paths to be fed into LexiconFiles.
48+
/// </summary>
49+
[Output]
50+
public ITaskItem[] ResolvedLexiconFiles { get; set; } = Array.Empty<ITaskItem>();
51+
52+
public override bool Execute()
53+
{
54+
if (Handles.Length == 0)
55+
return true;
56+
57+
var cache = new LexiconCache(CacheDir, CacheTtlHours);
58+
var resolvedFiles = new List<ITaskItem>();
59+
60+
var dnsServers = string.IsNullOrWhiteSpace(DnsServers)
61+
? null
62+
: DnsServers.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
63+
64+
using var resolver = new LexiconResolver(
65+
PlcDirectoryUrl,
66+
dnsServers,
67+
msg => Log.LogMessage(MessageImportance.Normal, msg),
68+
msg => Log.LogWarning(msg));
69+
70+
foreach (var item in Handles)
71+
{
72+
var handle = item.ItemSpec;
73+
74+
// Strip leading @ (common copy-paste from Bluesky)
75+
if (handle.StartsWith("@", StringComparison.Ordinal))
76+
handle = handle.Substring(1);
77+
78+
if (!IsValidHandle(handle))
79+
{
80+
Log.LogError("Invalid handle format: '{0}'. Handles must have at least 2 dot-separated segments with a valid TLD (last segment must not start with a digit).", handle);
81+
return false;
82+
}
83+
84+
try
85+
{
86+
// Check authority manifest cache first (keyed by handle)
87+
var cachedNsids = cache.TryGetAuthorityManifest(handle);
88+
if (cachedNsids != null)
89+
{
90+
Log.LogMessage(MessageImportance.Low, "Using cached handle manifest for '{0}' ({1} lexicons)", handle, cachedNsids.Count);
91+
92+
// Verify all individual NSID caches still exist
93+
var allCached = true;
94+
foreach (var nsid in cachedNsids)
95+
{
96+
if (!cache.IsCached(nsid))
97+
{
98+
allCached = false;
99+
break;
100+
}
101+
}
102+
103+
if (allCached)
104+
{
105+
foreach (var nsid in cachedNsids)
106+
{
107+
resolvedFiles.Add(new TaskItem(cache.GetJsonPath(nsid)));
108+
}
109+
110+
continue;
111+
}
112+
113+
Log.LogMessage(MessageImportance.Normal, "Some cached lexicons for handle '{0}' are missing, re-resolving", handle);
114+
}
115+
116+
// Resolve from network: handle → DID → PDS → listRecords
117+
var resolved = resolver.ResolveHandleAsync(handle).GetAwaiter().GetResult();
118+
var nsidList = new List<string>();
119+
120+
foreach (var (nsid, json) in resolved)
121+
{
122+
cache.Store(nsid, json);
123+
resolvedFiles.Add(new TaskItem(cache.GetJsonPath(nsid)));
124+
nsidList.Add(nsid);
125+
Log.LogMessage(MessageImportance.Normal, "Resolved lexicon: {0}", nsid);
126+
}
127+
128+
cache.StoreAuthorityManifest(handle, nsidList);
129+
Log.LogMessage(MessageImportance.Normal, "Resolved {0} lexicons for handle '{1}'", nsidList.Count, handle);
130+
}
131+
catch (LexiconResolutionException ex)
132+
{
133+
if (FailOnError)
134+
{
135+
Log.LogError("Handle lexicon resolution failed for '{0}': {1}", handle, ex.Message);
136+
return false;
137+
}
138+
else
139+
{
140+
Log.LogWarning("Handle lexicon resolution failed for '{0}' (continuing because CarpaNet_LexiconFailOnError=false): {1}", handle, ex.Message);
141+
}
142+
}
143+
catch (Exception ex)
144+
{
145+
if (FailOnError)
146+
{
147+
Log.LogError("Handle lexicon resolution failed for '{0}' with unexpected error: {1}", handle, ex.Message);
148+
return false;
149+
}
150+
else
151+
{
152+
Log.LogWarning("Handle lexicon resolution failed for '{0}' with unexpected error (continuing because CarpaNet_LexiconFailOnError=false): {1}", handle, ex.Message);
153+
}
154+
}
155+
}
156+
157+
ResolvedLexiconFiles = resolvedFiles.ToArray();
158+
return !Log.HasLoggedErrors;
159+
}
160+
161+
/// <summary>
162+
/// Validates that a string is a well-formed AT Protocol handle.
163+
/// Handles are domain-like: at least 2 dot-separated segments, each alphanumeric/hyphen,
164+
/// and the last segment (TLD) must not start with a digit.
165+
/// </summary>
166+
internal static bool IsValidHandle(string handle)
167+
{
168+
if (string.IsNullOrWhiteSpace(handle))
169+
return false;
170+
171+
var segments = handle.Split('.');
172+
if (segments.Length < 2)
173+
return false;
174+
175+
foreach (var segment in segments)
176+
{
177+
if (string.IsNullOrEmpty(segment) || segment.Length > 63)
178+
return false;
179+
180+
if (!char.IsLetterOrDigit(segment[0]) || !char.IsLetterOrDigit(segment[segment.Length - 1]))
181+
return false;
182+
183+
foreach (var c in segment)
184+
{
185+
if (!char.IsLetterOrDigit(c) && c != '-')
186+
return false;
187+
}
188+
}
189+
190+
// Last segment (TLD) must not start with a digit
191+
if (char.IsDigit(segments[segments.Length - 1][0]))
192+
return false;
193+
194+
return true;
195+
}
196+
}

src/CarpaNet.SourceGen/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,22 @@ At build time, the authority is resolved to a DID (via DNS, unless a DID is prov
108108

109109
This is useful for third-party or custom lexicon namespaces where you want everything the author publishes without maintaining an explicit list.
110110

111-
`LexiconResolve`, `LexiconResolveAuthority`, and `LexiconFiles` can all be combined freely:
111+
### 6. Resolve All Lexicons from an AT Protocol Handle
112+
113+
If you know someone's AT Protocol handle (e.g. their Bluesky handle), you can use `LexiconResolveHandle` to fetch all lexicons they've published:
114+
115+
```xml
116+
<ItemGroup>
117+
<LexiconResolveHandle Include="atproto-lexicons.bsky.social" />
118+
<LexiconResolveHandle Include="bsky-lexicons.bsky.social" />
119+
</ItemGroup>
120+
```
121+
122+
At build time, the handle is resolved to a DID using standard AT Protocol handle resolution (DNS TXT `_atproto.{handle}` first, HTTPS `https://{handle}/.well-known/atproto-did` fallback), then the DID is resolved to a PDS endpoint, and `com.atproto.repo.listRecords` is used to enumerate all `com.atproto.lexicon.schema` records. Each discovered lexicon is fetched, cached, and fed into the source generator.
123+
124+
This differs from `LexiconResolveAuthority` in that it takes a user handle rather than an NSID authority or DID. Use this when you know an account's handle but not the authority namespace or DID they publish lexicons under.
125+
126+
`LexiconResolve`, `LexiconResolveAuthority`, `LexiconResolveHandle`, and `LexiconFiles` can all be combined freely:
112127

113128
```xml
114129
<ItemGroup>
@@ -121,6 +136,9 @@ This is useful for third-party or custom lexicon namespaces where you want every
121136
<!-- All lexicons from these authorities -->
122137
<LexiconResolveAuthority Include="blog.pckt" />
123138
<LexiconResolveAuthority Include="site.standard" />
139+
140+
<!-- All lexicons from these handles -->
141+
<LexiconResolveHandle Include="alice.bsky.social" />
124142
</ItemGroup>
125143
```
126144

src/CarpaNet.SourceGen/build/CarpaNet.SourceGen.targets

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
AssemblyFile="$(_CarpaNetBuildTasksAssembly)" />
1414
<UsingTask TaskName="CarpaNet.BuildTasks.ResolveAuthorityLexiconsTask"
1515
AssemblyFile="$(_CarpaNetBuildTasksAssembly)" />
16+
<UsingTask TaskName="CarpaNet.BuildTasks.ResolveHandleLexiconsTask"
17+
AssemblyFile="$(_CarpaNetBuildTasksAssembly)" />
1618

1719
<!-- Default configuration for DNS-based lexicon resolution -->
1820
<PropertyGroup>
@@ -37,6 +39,21 @@
3739
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="IsATProtoLexicon" />
3840
</ItemGroup>
3941

42+
<!-- Resolve all lexicons for a handle (handle → DID → listRecords) -->
43+
<Target Name="_CarpaNetResolveHandleLexicons"
44+
BeforeTargets="_CarpaNetMapLexiconFiles"
45+
Condition="'@(LexiconResolveHandle)' != ''">
46+
<ResolveHandleLexiconsTask
47+
Handles="@(LexiconResolveHandle)"
48+
CacheDir="$(CarpaNet_LexiconCacheDir)"
49+
CacheTtlHours="$(CarpaNet_LexiconCacheTtlHours)"
50+
FailOnError="$(CarpaNet_LexiconFailOnError)"
51+
PlcDirectoryUrl="$(CarpaNet_PlcDirectoryUrl)"
52+
DnsServers="$(CarpaNet_DnsServers)">
53+
<Output TaskParameter="ResolvedLexiconFiles" ItemName="LexiconFiles" />
54+
</ResolveHandleLexiconsTask>
55+
</Target>
56+
4057
<!-- Resolve all lexicons for an authority via listRecords -->
4158
<Target Name="_CarpaNetResolveAuthorityLexicons"
4259
BeforeTargets="_CarpaNetMapLexiconFiles"
@@ -69,7 +86,7 @@
6986

7087
<!-- Auto-resolve transitive lexicon dependencies when enabled -->
7188
<Target Name="_CarpaNetAutoResolveLexiconDeps"
72-
DependsOnTargets="_CarpaNetResolveAuthorityLexicons;_CarpaNetResolveLexicons"
89+
DependsOnTargets="_CarpaNetResolveHandleLexicons;_CarpaNetResolveAuthorityLexicons;_CarpaNetResolveLexicons"
7390
BeforeTargets="_CarpaNetMapLexiconFiles"
7491
Condition="'$(CarpaNet_LexiconAutoResolve)' == 'true' AND '@(LexiconFiles)' != ''">
7592
<AutoResolveLexiconDepsTask
@@ -87,7 +104,7 @@
87104
<!-- Convert LexiconFiles items to AdditionalFiles with marker metadata -->
88105
<Target Name="_CarpaNetMapLexiconFiles"
89106
BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun"
90-
DependsOnTargets="_CarpaNetResolveAuthorityLexicons;_CarpaNetResolveLexicons;_CarpaNetAutoResolveLexiconDeps">
107+
DependsOnTargets="_CarpaNetResolveHandleLexicons;_CarpaNetResolveAuthorityLexicons;_CarpaNetResolveLexicons;_CarpaNetAutoResolveLexiconDeps">
91108
<ItemGroup>
92109
<AdditionalFiles Include="@(LexiconFiles)" IsATProtoLexicon="true" />
93110
</ItemGroup>

0 commit comments

Comments
 (0)