mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-06-09 16:52:35 +00:00
let appview work with did:plc based storage servers
This commit is contained in:
@@ -263,10 +263,13 @@ func fetchLayersFromPDS(ctx context.Context, pdsEndpoint, did, attestationDigest
|
||||
// Two-hop flow: (1) get presigned URL from hold, (2) fetch blob from S3.
|
||||
// serviceToken is optional — pass "" for public holds.
|
||||
func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken string) ([]byte, error) {
|
||||
holdURL := atproto.ResolveHoldURL(holdEndpoint)
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdEndpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve hold endpoint %s: %w", holdEndpoint, err)
|
||||
}
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
|
||||
if holdURL == "" || holdDID == "" {
|
||||
return nil, fmt.Errorf("could not resolve hold endpoint: %s", holdEndpoint)
|
||||
if holdDID == "" {
|
||||
return nil, fmt.Errorf("could not resolve hold DID from: %s", holdEndpoint)
|
||||
}
|
||||
|
||||
// Step 1: Request presigned URL from hold
|
||||
|
||||
@@ -196,8 +196,7 @@ func (h *DeleteAccountHandler) deleteFromHolds(ctx context.Context, user *db.Use
|
||||
// deleteFromSingleHold deletes user data from a single hold
|
||||
func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *db.User, holdDID, relationship string) HoldDeleteResult {
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
|
||||
result := HoldDeleteResult{
|
||||
HoldDID: holdDID,
|
||||
@@ -205,6 +204,14 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold URL for deletion", "holdDid", holdDID, "error", err)
|
||||
result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
endpoint := holdURL + "/xrpc/io.atcr.hold.deleteUserData"
|
||||
|
||||
// Check if we have OAuth refresher (needed for service tokens)
|
||||
if h.Refresher == nil {
|
||||
result.Error = "OAuth not configured - cannot authenticate to hold"
|
||||
|
||||
@@ -527,7 +527,7 @@ const deviceSuccessTemplate = `
|
||||
<h1>✓ Device Authorized!</h1>
|
||||
<p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
<p><a href="/settings">View your authorized devices</a></p>
|
||||
<p><a href="/settings#devices">View your authorized devices</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -167,17 +167,24 @@ func (h *ExportUserDataHandler) fetchHoldExports(ctx context.Context, user *db.U
|
||||
// fetchSingleHoldExport fetches export data from a single hold
|
||||
func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string, meta holdMetadata) HoldExportResult {
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData"
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
|
||||
result := HoldExportResult{
|
||||
HoldDID: holdDID,
|
||||
Endpoint: endpoint,
|
||||
Relationship: meta.relationship,
|
||||
FirstSeen: meta.firstSeen,
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold URL for export", "holdDid", holdDID, "error", err)
|
||||
result.Error = fmt.Sprintf("Failed to resolve hold URL: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData"
|
||||
result.Endpoint = endpoint
|
||||
|
||||
// Check if we have OAuth refresher (needed for service tokens)
|
||||
if h.Refresher == nil {
|
||||
result.Error = "OAuth not configured - cannot authenticate to hold"
|
||||
|
||||
@@ -52,9 +52,9 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Resolve hold URL from DID
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
if holdURL == "" {
|
||||
slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID)
|
||||
holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err)
|
||||
h.renderError(w, "Failed to resolve hold service")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,9 +77,9 @@ func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Resolve hold DID to endpoint
|
||||
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
||||
if holdEndpoint == "" {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID)
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
@@ -197,8 +197,9 @@ func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// Resolve hold endpoint
|
||||
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
||||
if holdEndpoint == "" {
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
|
||||
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -285,8 +286,9 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
// Resolve hold endpoint
|
||||
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
||||
if holdEndpoint == "" {
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
|
||||
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,9 +51,11 @@ func NewCheckerWithTimeout(cacheTTL, httpTimeout time.Duration) *Checker {
|
||||
// Checks {endpoint}/xrpc/_health and returns true if reachable
|
||||
func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error) {
|
||||
// Convert DID to HTTP URL if needed
|
||||
// did:web:hold.example.com → https://hold.example.com
|
||||
// https://hold.example.com → https://hold.example.com (passthrough)
|
||||
httpURL := atproto.ResolveHoldURL(endpoint)
|
||||
// Resolves any DID (did:web, did:plc) via identity directory
|
||||
httpURL, err := atproto.ResolveHoldURL(ctx, endpoint)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to resolve hold URL: %w", err)
|
||||
}
|
||||
|
||||
// Build health check URL
|
||||
healthURL := httpURL + "/xrpc/_health"
|
||||
|
||||
@@ -65,19 +65,15 @@ func TestCheckHealth_WithDID(t *testing.T) {
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with DID format (did:web:host)
|
||||
// Extract host:port from test server URL
|
||||
// http://127.0.0.1:12345 → did:web:127.0.0.1:12345
|
||||
serverURL := server.URL
|
||||
didFormat := "did:web:" + serverURL[7:] // Remove "http://"
|
||||
|
||||
reachable, err := checker.CheckHealth(ctx, didFormat)
|
||||
// Test with URL format (DID resolution requires real identity directory,
|
||||
// so we test with the URL format which passes through directly)
|
||||
reachable, err := checker.CheckHealth(ctx, server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("CheckHealth with DID returned error: %v", err)
|
||||
t.Errorf("CheckHealth with URL returned error: %v", err)
|
||||
}
|
||||
|
||||
if !reachable {
|
||||
t.Error("Expected hold to be reachable with DID format")
|
||||
t.Error("Expected hold to be reachable with URL format")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -396,7 +396,10 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
}
|
||||
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
|
||||
}
|
||||
|
||||
// Create client for hold's PDS
|
||||
holdClient := atproto.NewClient(holdURL, holdDID, "")
|
||||
@@ -442,7 +445,10 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
// This is necessary for localhost/private holds that aren't discoverable via the relay
|
||||
func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error {
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
|
||||
}
|
||||
|
||||
// Create client for hold's PDS
|
||||
holdClient := atproto.NewClient(holdURL, holdDID, "")
|
||||
|
||||
@@ -296,6 +296,13 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
|
||||
// Single-hop hold migration: check if this hold has declared a successor
|
||||
holdDID = nr.resolveSuccessor(ctx, holdDID)
|
||||
|
||||
// Resolve hold DID to HTTP URL via identity directory (cached 24h)
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve hold URL for %s: %w", holdDID, err)
|
||||
}
|
||||
|
||||
// Auto-reconcile crew membership on first push/pull
|
||||
// This ensures users can push immediately after docker login without web sign-in
|
||||
// EnsureCrewMembership is best-effort and logs errors without failing the request
|
||||
@@ -463,6 +470,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
HoldDID: holdDID,
|
||||
HoldURL: holdURL,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Repository: repositoryName,
|
||||
ServiceToken: serviceToken, // Cached service token from puller's PDS
|
||||
@@ -551,20 +559,16 @@ func (nr *NamespaceResolver) resolveSuccessor(ctx context.Context, holdDID strin
|
||||
// isHoldReachable checks if a hold service is reachable
|
||||
// Used in test mode to fallback to default hold when user's hold is unavailable
|
||||
func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
|
||||
// Try to fetch the DID document
|
||||
hostname := strings.TrimPrefix(holdDID, "did:web:")
|
||||
|
||||
// Try HTTP first (local), then HTTPS
|
||||
for _, scheme := range []string{"http", "https"} {
|
||||
testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
|
||||
client := atproto.NewClient("", "", "")
|
||||
_, err := client.FetchDIDDocument(ctx, testURL)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
slog.Debug("Cannot resolve hold URL for reachability check", "component", "registry/middleware", "holdDID", holdDID, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
testURL := holdURL + "/.well-known/did.json"
|
||||
client := atproto.NewClient("", "", "")
|
||||
_, err = client.FetchDIDDocument(ctx, testURL)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
|
||||
|
||||
@@ -270,10 +270,8 @@ func TestIsHoldReachable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("reachable hold", func(t *testing.T) {
|
||||
// Extract hostname from test server URL
|
||||
// The mock server URL is like http://127.0.0.1:port, so we use the host part
|
||||
holdDID := fmt.Sprintf("did:web:%s", mockHold.Listener.Addr().String())
|
||||
reachable := resolver.isHoldReachable(ctx, holdDID)
|
||||
// Use URL format directly — DID resolution requires real identity directory
|
||||
reachable := resolver.isHoldReachable(ctx, mockHold.URL)
|
||||
assert.True(t, reachable, "should detect reachable hold")
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ type RegistryContext struct {
|
||||
// Puller = the authenticated user making the request (from JWT Subject)
|
||||
DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123")
|
||||
Handle string // Owner's handle (e.g., "alice.bsky.social")
|
||||
HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
|
||||
HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io" or "did:plc:abc123")
|
||||
HoldURL string // Resolved HTTP URL for the hold service
|
||||
PDSEndpoint string // Owner's PDS endpoint URL
|
||||
Repository string // Image repository name (e.g., "debian")
|
||||
ServiceToken string // Service token for hold authentication (from puller's PDS)
|
||||
|
||||
@@ -30,7 +30,11 @@ func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher
|
||||
}
|
||||
|
||||
// Resolve hold DID to HTTP endpoint
|
||||
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to resolve hold URL", "holdDID", holdDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get service token for the hold
|
||||
// Only works with OAuth (refresher required) - app passwords can't get service tokens
|
||||
|
||||
@@ -337,9 +337,8 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve hold DID to HTTP endpoint
|
||||
// For did:web, this is straightforward (e.g., did:web:hold01.atcr.io → https://hold01.atcr.io)
|
||||
holdEndpoint := atproto.ResolveHoldURL(s.ctx.HoldDID)
|
||||
// Use pre-resolved hold URL from RegistryContext
|
||||
holdEndpoint := s.ctx.HoldURL
|
||||
|
||||
// Use service token from middleware (already cached and validated)
|
||||
serviceToken := s.ctx.ServiceToken
|
||||
|
||||
@@ -40,8 +40,8 @@ type ProxyBlobStore struct {
|
||||
|
||||
// NewProxyBlobStore creates a new proxy blob store
|
||||
func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
|
||||
// Resolve DID to URL once at construction time
|
||||
holdURL := atproto.ResolveHoldURL(ctx.HoldDID)
|
||||
// Use pre-resolved URL from RegistryContext (resolved in Registry.Repository())
|
||||
holdURL := ctx.HoldURL
|
||||
|
||||
slog.Debug("NewProxyBlobStore created", "component", "proxy_blob_store", "hold_did", ctx.HoldDID, "hold_url", holdURL, "user_did", ctx.DID, "repo", ctx.Repository)
|
||||
|
||||
|
||||
@@ -197,33 +197,37 @@ func TestDoAuthenticatedRequest_ErrorWhenTokenUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveHoldURL tests DID to URL conversion
|
||||
// TestResolveHoldURL tests URL passthrough (no network needed)
|
||||
func TestResolveHoldURL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
holdDID string
|
||||
holdURL string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "did:web with http (TEST_MODE)",
|
||||
holdDID: "did:web:localhost:8080",
|
||||
name: "http URL passthrough",
|
||||
holdURL: "http://localhost:8080",
|
||||
expected: "http://localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "did:web with https (production)",
|
||||
holdDID: "did:web:hold01.atcr.io",
|
||||
name: "https URL passthrough",
|
||||
holdURL: "https://hold01.atcr.io",
|
||||
expected: "https://hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "did:web with port",
|
||||
holdDID: "did:web:hold.example.com:3000",
|
||||
name: "http URL with port passthrough",
|
||||
holdURL: "http://hold.example.com:3000",
|
||||
expected: "http://hold.example.com:3000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := atproto.ResolveHoldURL(tt.holdDID)
|
||||
result, err := atproto.ResolveHoldURL(ctx, tt.holdURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
@@ -277,9 +281,11 @@ func TestServiceTokenCacheKeyFormat(t *testing.T) {
|
||||
|
||||
// TestNewProxyBlobStore tests ProxyBlobStore creation
|
||||
func TestNewProxyBlobStore(t *testing.T) {
|
||||
expectedURL := "https://hold.example.com"
|
||||
ctx := &RegistryContext{
|
||||
DID: "did:plc:test",
|
||||
HoldDID: "did:web:hold.example.com",
|
||||
HoldURL: expectedURL,
|
||||
PDSEndpoint: "https://pds.example.com",
|
||||
Repository: "test-repo",
|
||||
}
|
||||
@@ -298,7 +304,6 @@ func TestNewProxyBlobStore(t *testing.T) {
|
||||
t.Error("Expected holdURL to be set")
|
||||
}
|
||||
|
||||
expectedURL := "https://hold.example.com"
|
||||
if store.holdURL != expectedURL {
|
||||
t.Errorf("Expected holdURL %s, got %s", expectedURL, store.holdURL)
|
||||
}
|
||||
|
||||
@@ -8,34 +8,56 @@ import (
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
|
||||
// Handles both formats for backward compatibility:
|
||||
// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
|
||||
// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
|
||||
// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
|
||||
func ResolveHoldURL(holdIdentifier string) string {
|
||||
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL.
|
||||
// For DIDs (both did:web and did:plc), resolves via the indigo identity directory
|
||||
// which caches results (24h TTL). Prefers the #atcr_hold service endpoint,
|
||||
// falls back to #atproto_pds.
|
||||
//
|
||||
// Supported formats:
|
||||
// - URL: https://hold.example.com → passthrough
|
||||
// - DID: did:web:hold01.atcr.io → resolved via /.well-known/did.json
|
||||
// - DID: did:plc:abc123 → resolved via PLC directory
|
||||
func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error) {
|
||||
// If it's already a URL (has scheme), return as-is
|
||||
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
|
||||
return holdIdentifier
|
||||
return holdIdentifier, nil
|
||||
}
|
||||
|
||||
// If it's a DID, convert to URL
|
||||
if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok {
|
||||
hostname := after
|
||||
|
||||
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
|
||||
if strings.Contains(hostname, ":") ||
|
||||
strings.Contains(hostname, "127.0.0.1") ||
|
||||
strings.Contains(hostname, "localhost") ||
|
||||
// Check if it's an IP address (contains only digits and dots in first part)
|
||||
(len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
|
||||
return "http://" + hostname
|
||||
}
|
||||
return "https://" + hostname
|
||||
// If it's a DID, resolve via identity directory
|
||||
if strings.HasPrefix(holdIdentifier, "did:") {
|
||||
return ResolveHoldDIDToURL(ctx, holdIdentifier)
|
||||
}
|
||||
|
||||
// Fallback: assume it's a hostname and use HTTPS
|
||||
return "https://" + holdIdentifier
|
||||
return "https://" + holdIdentifier, nil
|
||||
}
|
||||
|
||||
// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
|
||||
// Prefers the #atcr_hold service endpoint, falls back to #atproto_pds.
|
||||
// Uses the shared identity directory with cache TTL and event-driven invalidation.
|
||||
func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) {
|
||||
directory := GetDirectory()
|
||||
didParsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid hold DID %q: %w", did, err)
|
||||
}
|
||||
|
||||
ident, err := directory.LookupDID(ctx, didParsed)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err)
|
||||
}
|
||||
|
||||
// Prefer #atcr_hold service (hold-specific endpoint)
|
||||
if url := ident.GetServiceEndpoint("atcr_hold"); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// Fall back to #atproto_pds (hold publishes both with same URL)
|
||||
if url := ident.PDSEndpoint(); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
|
||||
}
|
||||
|
||||
// ResolveDIDToPDS resolves a DID to its PDS endpoint.
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
)
|
||||
|
||||
func TestResolveHoldURL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// URL passthrough and hostname fallback tests (no network needed)
|
||||
tests := []struct {
|
||||
name string
|
||||
holdIdentifier string
|
||||
@@ -39,77 +42,7 @@ func TestResolveHoldURL(t *testing.T) {
|
||||
want: "http://hold.example.com/some/path",
|
||||
},
|
||||
|
||||
// did:web to HTTPS (domain names)
|
||||
{
|
||||
name: "did:web domain to https",
|
||||
holdIdentifier: "did:web:hold01.atcr.io",
|
||||
want: "https://hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "did:web subdomain to https",
|
||||
holdIdentifier: "did:web:my-hold.example.com",
|
||||
want: "https://my-hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "did:web simple domain to https",
|
||||
holdIdentifier: "did:web:example.com",
|
||||
want: "https://example.com",
|
||||
},
|
||||
|
||||
// did:web to HTTP (ports)
|
||||
{
|
||||
name: "did:web with port to http",
|
||||
holdIdentifier: "did:web:172.28.0.3:8080",
|
||||
want: "http://172.28.0.3:8080",
|
||||
},
|
||||
{
|
||||
name: "did:web domain with port to http",
|
||||
holdIdentifier: "did:web:hold.example.com:8080",
|
||||
want: "http://hold.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "did:web localhost with port to http",
|
||||
holdIdentifier: "did:web:localhost:8080",
|
||||
want: "http://localhost:8080",
|
||||
},
|
||||
|
||||
// did:web to HTTP (localhost)
|
||||
{
|
||||
name: "did:web localhost to http",
|
||||
holdIdentifier: "did:web:localhost",
|
||||
want: "http://localhost",
|
||||
},
|
||||
|
||||
// did:web to HTTP (127.0.0.1)
|
||||
{
|
||||
name: "did:web 127.0.0.1 to http",
|
||||
holdIdentifier: "did:web:127.0.0.1",
|
||||
want: "http://127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "did:web 127.0.0.1 with port to http",
|
||||
holdIdentifier: "did:web:127.0.0.1:8080",
|
||||
want: "http://127.0.0.1:8080",
|
||||
},
|
||||
|
||||
// did:web to HTTP (IP addresses)
|
||||
{
|
||||
name: "did:web IPv4 address to http",
|
||||
holdIdentifier: "did:web:192.168.1.1",
|
||||
want: "http://192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "did:web IPv4 with port to http",
|
||||
holdIdentifier: "did:web:10.0.0.5:3000",
|
||||
want: "http://10.0.0.5:3000",
|
||||
},
|
||||
{
|
||||
name: "did:web private IP to http",
|
||||
holdIdentifier: "did:web:172.16.0.1",
|
||||
want: "http://172.16.0.1",
|
||||
},
|
||||
|
||||
// Fallback behavior (plain hostname)
|
||||
// Fallback behavior (plain hostname — not a DID, not a URL)
|
||||
{
|
||||
name: "plain hostname fallback to https",
|
||||
holdIdentifier: "hold.example.com",
|
||||
@@ -127,21 +60,14 @@ func TestResolveHoldURL(t *testing.T) {
|
||||
holdIdentifier: "",
|
||||
want: "https://",
|
||||
},
|
||||
{
|
||||
name: "did:web empty hostname",
|
||||
holdIdentifier: "did:web:",
|
||||
want: "https://",
|
||||
},
|
||||
{
|
||||
name: "just did:web prefix",
|
||||
holdIdentifier: "did:web",
|
||||
want: "https://did:web",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ResolveHoldURL(tt.holdIdentifier)
|
||||
got, err := ResolveHoldURL(ctx, tt.holdIdentifier)
|
||||
if err != nil {
|
||||
t.Errorf("ResolveHoldURL(%q) unexpected error: %v", tt.holdIdentifier, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.holdIdentifier, got, tt.want)
|
||||
}
|
||||
@@ -149,25 +75,58 @@ func TestResolveHoldURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveHoldURLRoundTrip tests that converting back and forth works
|
||||
// TestResolveHoldURLDIDRequiresNetwork tests that DID resolution requires
|
||||
// the identity directory (which needs network access)
|
||||
func TestResolveHoldURLDIDRequiresNetwork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// did:web and did:plc both go through the identity directory now.
|
||||
// Without a real server, these should return errors.
|
||||
tests := []struct {
|
||||
name string
|
||||
holdIdentifier string
|
||||
}{
|
||||
{"did:web nonexistent", "did:web:nonexistent.example.invalid"},
|
||||
{"did:plc nonexistent", "did:plc:nonexistent000000000000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ResolveHoldURL(ctx, tt.holdIdentifier)
|
||||
if err == nil {
|
||||
t.Errorf("ResolveHoldURL(%q) expected error for unresolvable DID, got nil", tt.holdIdentifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveHoldURLRoundTrip tests that URL passthrough is idempotent
|
||||
func TestResolveHoldURLRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHTTP bool // true if result should be http, false for https
|
||||
}{
|
||||
{"domain to https and idempotent", "did:web:hold.atcr.io", false},
|
||||
{"IP to http and idempotent", "did:web:192.168.1.1", true},
|
||||
{"port to http and idempotent", "did:web:example.com:8080", true},
|
||||
{"http URL idempotent", "http://192.168.1.1", true},
|
||||
{"https URL idempotent", "https://hold.atcr.io", false},
|
||||
{"http URL with port idempotent", "http://example.com:8080", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// First conversion
|
||||
first := ResolveHoldURL(tt.input)
|
||||
// First conversion (URL passthrough)
|
||||
first, err := ResolveHoldURL(ctx, tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("First ResolveHoldURL(%q) error: %v", tt.input, err)
|
||||
}
|
||||
|
||||
// Second conversion (should be idempotent since output is URL)
|
||||
second := ResolveHoldURL(first)
|
||||
second, err := ResolveHoldURL(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("Second ResolveHoldURL(%q) error: %v", first, err)
|
||||
}
|
||||
|
||||
if first != second {
|
||||
t.Errorf("ResolveHoldURL is not idempotent: first=%q, second=%q", first, second)
|
||||
|
||||
@@ -222,7 +222,10 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
|
||||
// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
|
||||
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
|
||||
// Resolve DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve hold URL: %w", err)
|
||||
}
|
||||
|
||||
// Build XRPC request URL
|
||||
// GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
|
||||
@@ -327,7 +330,10 @@ func (a *RemoteHoldAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDI
|
||||
// Uses O(1) lookup via getRecord with hash-based rkey instead of pagination
|
||||
func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||
// Resolve DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to resolve hold URL: %w", err)
|
||||
}
|
||||
|
||||
// Generate deterministic rkey from member DID (hash-based)
|
||||
rkey := atproto.CrewRecordKey(userDID)
|
||||
|
||||
Reference in New Issue
Block a user