···33import (
44 "context"
55 "fmt"
66+ "strings"
6778 "github.com/bluesky-social/indigo/atproto/syntax"
89)
1010+1111+// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
1212+// Handles both formats for backward compatibility:
1313+// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
1414+// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
1515+// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
1616+func ResolveHoldURL(holdIdentifier string) string {
1717+ // If it's already a URL (has scheme), return as-is
1818+ if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
1919+ return holdIdentifier
2020+ }
2121+2222+ // If it's a DID, convert to URL
2323+ if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok {
2424+ hostname := after
2525+2626+ // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
2727+ if strings.Contains(hostname, ":") ||
2828+ strings.Contains(hostname, "127.0.0.1") ||
2929+ strings.Contains(hostname, "localhost") ||
3030+ // Check if it's an IP address (contains only digits and dots in first part)
3131+ (len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
3232+ return "http://" + hostname
3333+ }
3434+ return "https://" + hostname
3535+ }
3636+3737+ // Fallback: assume it's a hostname and use HTTPS
3838+ return "https://" + holdIdentifier
3939+}
9401041// ResolveDIDToPDS resolves a DID to its PDS endpoint.
1142// Uses the shared identity directory with cache TTL and event-driven invalidation.
+186
pkg/atproto/resolver_test.go
···66 "testing"
77)
8899+func TestResolveHoldURL(t *testing.T) {
1010+ tests := []struct {
1111+ name string
1212+ holdIdentifier string
1313+ want string
1414+ }{
1515+ // URL passthrough tests
1616+ {
1717+ name: "http URL passthrough",
1818+ holdIdentifier: "http://hold.example.com",
1919+ want: "http://hold.example.com",
2020+ },
2121+ {
2222+ name: "https URL passthrough",
2323+ holdIdentifier: "https://hold.example.com",
2424+ want: "https://hold.example.com",
2525+ },
2626+ {
2727+ name: "http URL with port passthrough",
2828+ holdIdentifier: "http://hold.example.com:8080",
2929+ want: "http://hold.example.com:8080",
3030+ },
3131+ {
3232+ name: "https URL with port passthrough",
3333+ holdIdentifier: "https://hold.example.com:8443",
3434+ want: "https://hold.example.com:8443",
3535+ },
3636+ {
3737+ name: "http URL with path passthrough",
3838+ holdIdentifier: "http://hold.example.com/some/path",
3939+ want: "http://hold.example.com/some/path",
4040+ },
4141+4242+ // did:web to HTTPS (domain names)
4343+ {
4444+ name: "did:web domain to https",
4545+ holdIdentifier: "did:web:hold01.atcr.io",
4646+ want: "https://hold01.atcr.io",
4747+ },
4848+ {
4949+ name: "did:web subdomain to https",
5050+ holdIdentifier: "did:web:my-hold.example.com",
5151+ want: "https://my-hold.example.com",
5252+ },
5353+ {
5454+ name: "did:web simple domain to https",
5555+ holdIdentifier: "did:web:example.com",
5656+ want: "https://example.com",
5757+ },
5858+5959+ // did:web to HTTP (ports)
6060+ {
6161+ name: "did:web with port to http",
6262+ holdIdentifier: "did:web:172.28.0.3:8080",
6363+ want: "http://172.28.0.3:8080",
6464+ },
6565+ {
6666+ name: "did:web domain with port to http",
6767+ holdIdentifier: "did:web:hold.example.com:8080",
6868+ want: "http://hold.example.com:8080",
6969+ },
7070+ {
7171+ name: "did:web localhost with port to http",
7272+ holdIdentifier: "did:web:localhost:8080",
7373+ want: "http://localhost:8080",
7474+ },
7575+7676+ // did:web to HTTP (localhost)
7777+ {
7878+ name: "did:web localhost to http",
7979+ holdIdentifier: "did:web:localhost",
8080+ want: "http://localhost",
8181+ },
8282+8383+ // did:web to HTTP (127.0.0.1)
8484+ {
8585+ name: "did:web 127.0.0.1 to http",
8686+ holdIdentifier: "did:web:127.0.0.1",
8787+ want: "http://127.0.0.1",
8888+ },
8989+ {
9090+ name: "did:web 127.0.0.1 with port to http",
9191+ holdIdentifier: "did:web:127.0.0.1:8080",
9292+ want: "http://127.0.0.1:8080",
9393+ },
9494+9595+ // did:web to HTTP (IP addresses)
9696+ {
9797+ name: "did:web IPv4 address to http",
9898+ holdIdentifier: "did:web:192.168.1.1",
9999+ want: "http://192.168.1.1",
100100+ },
101101+ {
102102+ name: "did:web IPv4 with port to http",
103103+ holdIdentifier: "did:web:10.0.0.5:3000",
104104+ want: "http://10.0.0.5:3000",
105105+ },
106106+ {
107107+ name: "did:web private IP to http",
108108+ holdIdentifier: "did:web:172.16.0.1",
109109+ want: "http://172.16.0.1",
110110+ },
111111+112112+ // Fallback behavior (plain hostname)
113113+ {
114114+ name: "plain hostname fallback to https",
115115+ holdIdentifier: "hold.example.com",
116116+ want: "https://hold.example.com",
117117+ },
118118+ {
119119+ name: "plain single word fallback to https",
120120+ holdIdentifier: "myhold",
121121+ want: "https://myhold",
122122+ },
123123+124124+ // Edge cases
125125+ {
126126+ name: "empty string fallback",
127127+ holdIdentifier: "",
128128+ want: "https://",
129129+ },
130130+ {
131131+ name: "did:web empty hostname",
132132+ holdIdentifier: "did:web:",
133133+ want: "https://",
134134+ },
135135+ {
136136+ name: "just did:web prefix",
137137+ holdIdentifier: "did:web",
138138+ want: "https://did:web",
139139+ },
140140+ }
141141+142142+ for _, tt := range tests {
143143+ t.Run(tt.name, func(t *testing.T) {
144144+ got := ResolveHoldURL(tt.holdIdentifier)
145145+ if got != tt.want {
146146+ t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.holdIdentifier, got, tt.want)
147147+ }
148148+ })
149149+ }
150150+}
151151+152152+// TestResolveHoldURLRoundTrip tests that converting back and forth works
153153+func TestResolveHoldURLRoundTrip(t *testing.T) {
154154+ tests := []struct {
155155+ name string
156156+ input string
157157+ wantHTTP bool // true if result should be http, false for https
158158+ }{
159159+ {"domain to https and idempotent", "did:web:hold.atcr.io", false},
160160+ {"IP to http and idempotent", "did:web:192.168.1.1", true},
161161+ {"port to http and idempotent", "did:web:example.com:8080", true},
162162+ }
163163+164164+ for _, tt := range tests {
165165+ t.Run(tt.name, func(t *testing.T) {
166166+ // First conversion
167167+ first := ResolveHoldURL(tt.input)
168168+169169+ // Second conversion (should be idempotent since output is URL)
170170+ second := ResolveHoldURL(first)
171171+172172+ if first != second {
173173+ t.Errorf("ResolveHoldURL is not idempotent: first=%q, second=%q", first, second)
174174+ }
175175+176176+ // Verify correct protocol
177177+ if tt.wantHTTP {
178178+ if !hasPrefix(first, "http://") {
179179+ t.Errorf("Expected http:// prefix, got %q", first)
180180+ }
181181+ } else {
182182+ if !hasPrefix(first, "https://") {
183183+ t.Errorf("Expected https:// prefix, got %q", first)
184184+ }
185185+ }
186186+ })
187187+ }
188188+}
189189+190190+// Helper function to check prefix
191191+func hasPrefix(s, prefix string) bool {
192192+ return len(s) >= len(prefix) && s[:len(prefix)] == prefix
193193+}
194194+9195// TestResolveIdentity tests resolving identifiers to DID, handle, and PDS endpoint
10196func TestResolveIdentity(t *testing.T) {
11197 tests := []struct {
-33
pkg/atproto/utils.go
···11-package atproto
22-33-import "strings"
44-55-// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
66-// Handles both formats for backward compatibility:
77-// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
88-// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
99-// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
1010-func ResolveHoldURL(holdIdentifier string) string {
1111- // If it's already a URL (has scheme), return as-is
1212- if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
1313- return holdIdentifier
1414- }
1515-1616- // If it's a DID, convert to URL
1717- if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok {
1818- hostname := after
1919-2020- // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
2121- if strings.Contains(hostname, ":") ||
2222- strings.Contains(hostname, "127.0.0.1") ||
2323- strings.Contains(hostname, "localhost") ||
2424- // Check if it's an IP address (contains only digits and dots in first part)
2525- (len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
2626- return "http://" + hostname
2727- }
2828- return "https://" + hostname
2929- }
3030-3131- // Fallback: assume it's a hostname and use HTTPS
3232- return "https://" + holdIdentifier
3333-}
-189
pkg/atproto/utils_test.go
···11-package atproto
22-33-import "testing"
44-55-func TestResolveHoldURL(t *testing.T) {
66- tests := []struct {
77- name string
88- holdIdentifier string
99- want string
1010- }{
1111- // URL passthrough tests
1212- {
1313- name: "http URL passthrough",
1414- holdIdentifier: "http://hold.example.com",
1515- want: "http://hold.example.com",
1616- },
1717- {
1818- name: "https URL passthrough",
1919- holdIdentifier: "https://hold.example.com",
2020- want: "https://hold.example.com",
2121- },
2222- {
2323- name: "http URL with port passthrough",
2424- holdIdentifier: "http://hold.example.com:8080",
2525- want: "http://hold.example.com:8080",
2626- },
2727- {
2828- name: "https URL with port passthrough",
2929- holdIdentifier: "https://hold.example.com:8443",
3030- want: "https://hold.example.com:8443",
3131- },
3232- {
3333- name: "http URL with path passthrough",
3434- holdIdentifier: "http://hold.example.com/some/path",
3535- want: "http://hold.example.com/some/path",
3636- },
3737-3838- // did:web to HTTPS (domain names)
3939- {
4040- name: "did:web domain to https",
4141- holdIdentifier: "did:web:hold01.atcr.io",
4242- want: "https://hold01.atcr.io",
4343- },
4444- {
4545- name: "did:web subdomain to https",
4646- holdIdentifier: "did:web:my-hold.example.com",
4747- want: "https://my-hold.example.com",
4848- },
4949- {
5050- name: "did:web simple domain to https",
5151- holdIdentifier: "did:web:example.com",
5252- want: "https://example.com",
5353- },
5454-5555- // did:web to HTTP (ports)
5656- {
5757- name: "did:web with port to http",
5858- holdIdentifier: "did:web:172.28.0.3:8080",
5959- want: "http://172.28.0.3:8080",
6060- },
6161- {
6262- name: "did:web domain with port to http",
6363- holdIdentifier: "did:web:hold.example.com:8080",
6464- want: "http://hold.example.com:8080",
6565- },
6666- {
6767- name: "did:web localhost with port to http",
6868- holdIdentifier: "did:web:localhost:8080",
6969- want: "http://localhost:8080",
7070- },
7171-7272- // did:web to HTTP (localhost)
7373- {
7474- name: "did:web localhost to http",
7575- holdIdentifier: "did:web:localhost",
7676- want: "http://localhost",
7777- },
7878-7979- // did:web to HTTP (127.0.0.1)
8080- {
8181- name: "did:web 127.0.0.1 to http",
8282- holdIdentifier: "did:web:127.0.0.1",
8383- want: "http://127.0.0.1",
8484- },
8585- {
8686- name: "did:web 127.0.0.1 with port to http",
8787- holdIdentifier: "did:web:127.0.0.1:8080",
8888- want: "http://127.0.0.1:8080",
8989- },
9090-9191- // did:web to HTTP (IP addresses)
9292- {
9393- name: "did:web IPv4 address to http",
9494- holdIdentifier: "did:web:192.168.1.1",
9595- want: "http://192.168.1.1",
9696- },
9797- {
9898- name: "did:web IPv4 with port to http",
9999- holdIdentifier: "did:web:10.0.0.5:3000",
100100- want: "http://10.0.0.5:3000",
101101- },
102102- {
103103- name: "did:web private IP to http",
104104- holdIdentifier: "did:web:172.16.0.1",
105105- want: "http://172.16.0.1",
106106- },
107107-108108- // Fallback behavior (plain hostname)
109109- {
110110- name: "plain hostname fallback to https",
111111- holdIdentifier: "hold.example.com",
112112- want: "https://hold.example.com",
113113- },
114114- {
115115- name: "plain single word fallback to https",
116116- holdIdentifier: "myhold",
117117- want: "https://myhold",
118118- },
119119-120120- // Edge cases
121121- {
122122- name: "empty string fallback",
123123- holdIdentifier: "",
124124- want: "https://",
125125- },
126126- {
127127- name: "did:web empty hostname",
128128- holdIdentifier: "did:web:",
129129- want: "https://",
130130- },
131131- {
132132- name: "just did:web prefix",
133133- holdIdentifier: "did:web",
134134- want: "https://did:web",
135135- },
136136- }
137137-138138- for _, tt := range tests {
139139- t.Run(tt.name, func(t *testing.T) {
140140- got := ResolveHoldURL(tt.holdIdentifier)
141141- if got != tt.want {
142142- t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.holdIdentifier, got, tt.want)
143143- }
144144- })
145145- }
146146-}
147147-148148-// TestResolveHoldURLRoundTrip tests that converting back and forth works
149149-func TestResolveHoldURLRoundTrip(t *testing.T) {
150150- tests := []struct {
151151- name string
152152- input string
153153- wantHTTP bool // true if result should be http, false for https
154154- }{
155155- {"domain to https and idempotent", "did:web:hold.atcr.io", false},
156156- {"IP to http and idempotent", "did:web:192.168.1.1", true},
157157- {"port to http and idempotent", "did:web:example.com:8080", true},
158158- }
159159-160160- for _, tt := range tests {
161161- t.Run(tt.name, func(t *testing.T) {
162162- // First conversion
163163- first := ResolveHoldURL(tt.input)
164164-165165- // Second conversion (should be idempotent since output is URL)
166166- second := ResolveHoldURL(first)
167167-168168- if first != second {
169169- t.Errorf("ResolveHoldURL is not idempotent: first=%q, second=%q", first, second)
170170- }
171171-172172- // Verify correct protocol
173173- if tt.wantHTTP {
174174- if !hasPrefix(first, "http://") {
175175- t.Errorf("Expected http:// prefix, got %q", first)
176176- }
177177- } else {
178178- if !hasPrefix(first, "https://") {
179179- t.Errorf("Expected https:// prefix, got %q", first)
180180- }
181181- }
182182- })
183183- }
184184-}
185185-186186-// Helper function to check prefix
187187-func hasPrefix(s, prefix string) bool {
188188- return len(s) >= len(prefix) && s[:len(prefix)] == prefix
189189-}
+2-36
pkg/auth/hold_remote.go
···99 "log/slog"
1010 "net/http"
1111 "net/url"
1212- "strings"
1312 "sync"
1413 "time"
1514···219218// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
220219func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
221220 // Resolve DID to URL
222222- holdURL, err := a.resolveDIDToURL(holdDID)
223223- if err != nil {
224224- return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
225225- }
221221+ holdURL := atproto.ResolveHoldURL(holdDID)
226222227223 // Build XRPC request URL
228224 // GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
···326322// isCrewMemberNoCache queries XRPC without caching (internal helper)
327323func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
328324 // Resolve DID to URL
329329- holdURL, err := a.resolveDIDToURL(holdDID)
330330- if err != nil {
331331- return false, fmt.Errorf("failed to resolve hold DID: %w", err)
332332- }
325325+ holdURL := atproto.ResolveHoldURL(holdDID)
333326334327 // Build XRPC request URL
335328 // GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection=io.atcr.hold.crew
···405398 }
406399407400 return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
408408-}
409409-410410-// resolveDIDToURL converts a did:web DID to an HTTP/HTTPS URL
411411-// Example: did:web:hold01.atcr.io → https://hold01.atcr.io
412412-// Example (test mode): did:web:172.28.0.3:8080 → http://172.28.0.3:8080
413413-func (a *RemoteHoldAuthorizer) resolveDIDToURL(did string) (string, error) {
414414- // Handle did:web format
415415- if !strings.HasPrefix(did, "did:web:") {
416416- return "", fmt.Errorf("only did:web is supported, got: %s", did)
417417- }
418418-419419- // Extract hostname from did:web:hostname
420420- hostname := strings.TrimPrefix(did, "did:web:")
421421-422422- // In test mode OR for local addresses, use HTTP instead of HTTPS
423423- // This matches the logic in pkg/appview/storage/proxy_blob_store.go:resolveHoldURL
424424- if a.testMode ||
425425- strings.Contains(hostname, ":") ||
426426- strings.Contains(hostname, "127.0.0.1") ||
427427- strings.Contains(hostname, "localhost") ||
428428- // Check if it's an IP address (contains only digits and dots)
429429- (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
430430- return "http://" + hostname, nil
431431- }
432432-433433- // Convert to HTTPS URL for production domains
434434- return "https://" + hostname, nil
435401}
436402437403// nullString converts a string to sql.NullString