This repository has no description
0

Configure Feed

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

repoident: add KnotURL newtype which parses & canonicalizes knot endpoints

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>

author did:plc:3fwecdnvtcscjnrx2p4n7a… committer
Tangled
date (Jul 30, 2026, 3:00 PM UTC) commit 189fdd42 parent 0aa373a6 change-id qrstywtk
+377 -1
+150
repoident/knoturl.go
··· 1 + package repoident 2 + 3 + import ( 4 + "cmp" 5 + "errors" 6 + "fmt" 7 + "net/url" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/samber/lo" 12 + ) 13 + 14 + const ( 15 + KnotServiceID = "tangled_knot" 16 + KnotServiceType = "TangledKnot" 17 + LegacyKnotServiceID = "atproto_pds" 18 + LegacyKnotServiceType = "AtprotoPersonalDataServer" 19 + ) 20 + 21 + type SchemePolicy int 22 + 23 + const ( 24 + RequireHTTPS SchemePolicy = iota 25 + AllowHTTP 26 + ) 27 + 28 + func SchemeFor(allowHTTP bool) SchemePolicy { 29 + return lo.Ternary(allowHTTP, AllowHTTP, RequireHTTPS) 30 + } 31 + 32 + var ( 33 + ErrNilIdentity = errors.New("nil identity has no knot service endpoint") 34 + ErrNoKnotService = errors.New("DID document declares no " + KnotServiceID + " or " + LegacyKnotServiceID + " service endpoint") 35 + ErrZeroKnotURL = errors.New("zero KnotURL has no base URL to encode") 36 + ) 37 + 38 + var defaultPorts = map[string]string{"https": "443", "http": "80"} 39 + 40 + type KnotURL struct { 41 + scheme string 42 + host string 43 + } 44 + 45 + func (k KnotURL) IsZero() bool { return k.host == "" } 46 + 47 + func (k KnotURL) Host() string { return k.host } 48 + 49 + func (k KnotURL) url() *url.URL { return &url.URL{Scheme: k.scheme, Host: k.host} } 50 + 51 + func (k KnotURL) String() string { 52 + if k.IsZero() { 53 + return "" 54 + } 55 + return k.url().String() 56 + } 57 + 58 + func (k KnotURL) JoinPath(elem ...string) string { 59 + return k.url().JoinPath(elem...).String() 60 + } 61 + 62 + func (k KnotURL) MarshalText() ([]byte, error) { 63 + if k.IsZero() { 64 + return nil, ErrZeroKnotURL 65 + } 66 + return []byte(k.String()), nil 67 + } 68 + 69 + func (k *KnotURL) UnmarshalText(text []byte) error { 70 + parsed, err := ParseKnotURL(string(text), AllowHTTP) 71 + if err != nil { 72 + return err 73 + } 74 + *k = parsed 75 + return nil 76 + } 77 + 78 + type pathMode int 79 + 80 + const ( 81 + rejectPath pathMode = iota 82 + stripPath 83 + ) 84 + 85 + func ParseKnotURL(raw string, policy SchemePolicy) (KnotURL, error) { 86 + return parseBase(raw, policy, rejectPath) 87 + } 88 + 89 + func KnotURLFromIdentity(ident *identity.Identity, policy SchemePolicy) (KnotURL, error) { 90 + if ident == nil { 91 + return KnotURL{}, ErrNilIdentity 92 + } 93 + raw := cmp.Or( 94 + typedEndpoint(ident, KnotServiceID, KnotServiceType), 95 + typedEndpoint(ident, LegacyKnotServiceID, LegacyKnotServiceType), 96 + ) 97 + if raw == "" { 98 + return KnotURL{}, ErrNoKnotService 99 + } 100 + return parseBase(raw, policy, stripPath) 101 + } 102 + 103 + func typedEndpoint(ident *identity.Identity, id, serviceType string) string { 104 + service := ident.Services[id] 105 + return lo.Ternary(service.Type == serviceType, service.URL, "") 106 + } 107 + 108 + func parseBase(raw string, policy SchemePolicy, paths pathMode) (KnotURL, error) { 109 + if raw == "" { 110 + return KnotURL{}, errors.New("empty knot URL") 111 + } 112 + u, err := url.Parse(raw) 113 + if err != nil { 114 + return KnotURL{}, fmt.Errorf("invalid knot URL %q: %w", raw, err) 115 + } 116 + if u.Hostname() == "" { 117 + return KnotURL{}, fmt.Errorf("knot URL %q has no host", raw) 118 + } 119 + if u.User != nil { 120 + return KnotURL{}, fmt.Errorf("knot URL %q has userinfo", raw) 121 + } 122 + if u.RawQuery != "" || u.Fragment != "" { 123 + return KnotURL{}, fmt.Errorf("knot URL %q has a query or fragment", raw) 124 + } 125 + if paths == rejectPath && u.Path != "" && u.Path != "/" { 126 + return KnotURL{}, fmt.Errorf("knot URL %q has a path", raw) 127 + } 128 + switch u.Scheme { 129 + case "https": 130 + case "http": 131 + if policy != AllowHTTP { 132 + return KnotURL{}, fmt.Errorf("knot URL %q must use https", raw) 133 + } 134 + default: 135 + return KnotURL{}, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme) 136 + } 137 + return KnotURL{scheme: u.Scheme, host: canonicalHost(u)}, nil 138 + } 139 + 140 + func canonicalHost(u *url.URL) string { 141 + host := strings.ToLower(u.Host) 142 + switch port := u.Port(); port { 143 + case "": 144 + return strings.TrimSuffix(host, ":") 145 + case defaultPorts[u.Scheme]: 146 + return strings.TrimSuffix(host, ":"+port) 147 + default: 148 + return host 149 + } 150 + }
+193
repoident/knoturl_test.go
··· 1 + package repoident 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + ) 11 + 12 + func knotService(url string) map[string]identity.ServiceEndpoint { 13 + return map[string]identity.ServiceEndpoint{KnotServiceID: {Type: KnotServiceType, URL: url}} 14 + } 15 + 16 + func identWith(services map[string]identity.ServiceEndpoint) *identity.Identity { 17 + return &identity.Identity{Services: services} 18 + } 19 + 20 + func mustParse(t *testing.T, raw string) KnotURL { 21 + t.Helper() 22 + u, err := ParseKnotURL(raw, AllowHTTP) 23 + if err != nil { 24 + t.Fatalf("ParseKnotURL(%q): %v", raw, err) 25 + } 26 + return u 27 + } 28 + 29 + func TestKnotURL_ParsedAsBaseAndAsServiceEndpoint(t *testing.T) { 30 + const canonical = "https://knot.oyster.cafe" 31 + const rejected = "" 32 + cases := map[string]struct{ wantAsBase, wantAsEndpoint string }{ 33 + canonical: {canonical, canonical}, 34 + canonical + "/": {canonical, canonical}, 35 + "HTTPS://Knot.Oyster.Cafe/": {canonical, canonical}, 36 + "https://KNOT.OYSTER.CAFE:443": {canonical, canonical}, 37 + "https://knot.oyster.cafe:": {canonical, canonical}, 38 + "https://knot.oyster.cafe:80": {canonical + ":80", canonical + ":80"}, 39 + "https://[2001:DB8::1]:443": {"https://[2001:db8::1]", "https://[2001:db8::1]"}, 40 + "https://[2001:db8::1]:8443": {"https://[2001:db8::1]:8443", "https://[2001:db8::1]:8443"}, 41 + "https://[fe80::1%25eth0]": {"https://[fe80::1%25eth0]", "https://[fe80::1%25eth0]"}, 42 + "https://☃.oyster.cafe": {"https://%E2%98%83.oyster.cafe", "https://%E2%98%83.oyster.cafe"}, 43 + canonical + "/repo/m5326fp3qemiriiqy": {rejected, canonical}, 44 + canonical + "/base": {rejected, canonical}, 45 + canonical + "?utm=knot": {rejected, rejected}, 46 + canonical + "#pulls": {rejected, rejected}, 47 + "https://nel@knot.oyster.cafe": {rejected, rejected}, 48 + "https://nel:hunter2@knot.oyster.cafe": {rejected, rejected}, 49 + "ftp://knot.oyster.cafe": {rejected, rejected}, 50 + "http://knot.oyster.cafe": {rejected, rejected}, 51 + "knot.oyster.cafe": {rejected, rejected}, 52 + "https://": {rejected, rejected}, 53 + "https://:443": {rejected, rejected}, 54 + "not a url at all": {rejected, rejected}, 55 + "": {rejected, rejected}, 56 + } 57 + for raw, want := range cases { 58 + t.Run(raw, func(t *testing.T) { 59 + check := func(door string, got KnotURL, err error, want string) { 60 + switch { 61 + case want == rejected: 62 + if err == nil { 63 + t.Errorf("%s accepted %q as %q, want an error", door, raw, got) 64 + } 65 + case err != nil: 66 + t.Errorf("%s(%q): %v", door, raw, err) 67 + case got.String() != want: 68 + t.Errorf("%s(%q) = %q, want %q", door, raw, got, want) 69 + case got != mustParse(t, want): 70 + t.Errorf("%s(%q) doesn't compare equal to its canonical spelling %q", door, raw, want) 71 + } 72 + } 73 + base, baseErr := ParseKnotURL(raw, RequireHTTPS) 74 + check("ParseKnotURL", base, baseErr, want.wantAsBase) 75 + endpoint, endpointErr := KnotURLFromIdentity(identWith(knotService(raw)), RequireHTTPS) 76 + check("KnotURLFromIdentity", endpoint, endpointErr, want.wantAsEndpoint) 77 + }) 78 + } 79 + } 80 + 81 + func TestKnotURLFromIdentity_PicksTheKnotService(t *testing.T) { 82 + const legacyURL = "https://knot.oyster.cafe" 83 + const tangledURL = "https://nel.pet/repo/fcicrjbr6oh3" 84 + cases := map[string]struct{ tangledType, want string }{ 85 + "legacy atproto_pds is the fallback": {"", legacyURL}, 86 + "tangled_knot wins over legacy": {KnotServiceType, "https://nel.pet"}, 87 + "tangled_knot with the wrong type is ignored": {"AtprotoLabeler", legacyURL}, 88 + } 89 + for name, tc := range cases { 90 + t.Run(name, func(t *testing.T) { 91 + services := map[string]identity.ServiceEndpoint{ 92 + LegacyKnotServiceID: {Type: LegacyKnotServiceType, URL: legacyURL}, 93 + } 94 + if tc.tangledType != "" { 95 + services[KnotServiceID] = identity.ServiceEndpoint{Type: tc.tangledType, URL: tangledURL} 96 + } 97 + u, err := KnotURLFromIdentity(identWith(services), RequireHTTPS) 98 + if err != nil { 99 + t.Fatalf("KnotURLFromIdentity: %v", err) 100 + } 101 + if u.String() != tc.want { 102 + t.Errorf("KnotURLFromIdentity = %q, want %q", u, tc.want) 103 + } 104 + }) 105 + } 106 + } 107 + 108 + func TestKnotURLFromIdentity_ErrNoKnotService(t *testing.T) { 109 + cases := map[string]*identity.Identity{ 110 + "nothing declared": identWith(nil), 111 + "another service only": identWith(map[string]identity.ServiceEndpoint{ 112 + "atproto_labeler": {Type: "AtprotoLabeler", URL: "https://nel.pet"}, 113 + }), 114 + "tangled_knot with an empty url": identWith(knotService("")), 115 + "legacy service with the wrong type": identWith(map[string]identity.ServiceEndpoint{ 116 + LegacyKnotServiceID: {Type: "AtprotoLabeler", URL: "https://knot.oyster.cafe"}, 117 + }), 118 + } 119 + for name, ident := range cases { 120 + t.Run(name, func(t *testing.T) { 121 + if _, err := KnotURLFromIdentity(ident, RequireHTTPS); !errors.Is(err, ErrNoKnotService) { 122 + t.Errorf("error = %v, want ErrNoKnotService", err) 123 + } 124 + }) 125 + } 126 + if _, err := KnotURLFromIdentity(nil, RequireHTTPS); !errors.Is(err, ErrNilIdentity) { 127 + t.Errorf("nil identity error = %v, want ErrNilIdentity", err) 128 + } 129 + } 130 + 131 + func TestKnotURL_ErrorQuotesTheDeclaredEndpoint(t *testing.T) { 132 + const declared = "https://knot.oyster.cafe/repo/limpet?utm=knot" 133 + _, err := KnotURLFromIdentity(identWith(knotService(declared)), RequireHTTPS) 134 + if err == nil { 135 + t.Fatal("KnotURLFromIdentity accepted a query") 136 + } 137 + if !strings.Contains(err.Error(), declared) { 138 + t.Errorf("error %q doesn't quote the declared endpoint %q", err, declared) 139 + } 140 + } 141 + 142 + func TestSchemePolicy_OnlyAllowHTTPPermitsPlaintext(t *testing.T) { 143 + if RequireHTTPS != 0 || SchemeFor(true) != AllowHTTP || SchemeFor(false) != RequireHTTPS { 144 + t.Fatalf("RequireHTTPS=%d SchemeFor(true)=%d: the zero value must stay RequireHTTPS", RequireHTTPS, SchemeFor(true)) 145 + } 146 + if u := mustParse(t, "http://knot.oyster.cafe:80"); u.String() != "http://knot.oyster.cafe" { 147 + t.Errorf("AllowHTTP parse = %q, want http://knot.oyster.cafe", u) 148 + } 149 + for _, policy := range []SchemePolicy{RequireHTTPS, SchemePolicy(42), SchemePolicy(-1)} { 150 + if _, err := ParseKnotURL("http://knot.oyster.cafe", policy); err == nil { 151 + t.Errorf("policy %d permitted http", policy) 152 + } 153 + } 154 + } 155 + 156 + func TestKnotURL_JoinPathKeepsTheDidColons(t *testing.T) { 157 + const want = "http://localhost:5555/did:plc:limpet" 158 + if got := mustParse(t, "http://localhost:5555").JoinPath("did:plc:limpet"); got != want { 159 + t.Errorf("JoinPath = %q, want %q", got, want) 160 + } 161 + } 162 + 163 + func TestKnotURL_ZeroValueIsInert(t *testing.T) { 164 + var zero KnotURL 165 + if !zero.IsZero() || zero.String() != "" || zero.Host() != "" { 166 + t.Errorf("zero KnotURL isn't inert: IsZero=%v String=%q Host=%q", zero.IsZero(), zero.String(), zero.Host()) 167 + } 168 + if _, err := zero.MarshalText(); !errors.Is(err, ErrZeroKnotURL) { 169 + t.Errorf("zero KnotURL MarshalText error = %v, want ErrZeroKnotURL", err) 170 + } 171 + } 172 + 173 + func TestKnotURL_JSONCanonicalizesAndValidates(t *testing.T) { 174 + var decoded struct { 175 + Knot KnotURL `json:"knot"` 176 + } 177 + if err := json.Unmarshal([]byte(`{"knot":"HTTP://Localhost:80/"}`), &decoded); err != nil { 178 + t.Fatalf("Unmarshal: %v", err) 179 + } 180 + if decoded.Knot.Host() != "localhost" { 181 + t.Errorf("decoded host = %q, want localhost", decoded.Knot.Host()) 182 + } 183 + out, err := json.Marshal(decoded) 184 + if err != nil { 185 + t.Fatalf("Marshal: %v", err) 186 + } 187 + if string(out) != `{"knot":"http://localhost"}` { 188 + t.Errorf("re-encoded = %s, want {\"knot\":\"http://localhost\"}", out) 189 + } 190 + if err := json.Unmarshal([]byte(`{"knot":"https://knot.oyster.cafe/repo/limpet"}`), &decoded); err == nil { 191 + t.Error("Unmarshal accepted a URL with a path") 192 + } 193 + }
+9
repoident/repoident.go
··· 18 18 return RepoDid(did), nil 19 19 } 20 20 21 + func (r *RepoDid) UnmarshalText(text []byte) error { 22 + did, err := NewRepoDid(string(text)) 23 + if err != nil { 24 + return err 25 + } 26 + *r = did 27 + return nil 28 + } 29 + 21 30 type OwnerDid syntax.DID 22 31 23 32 func (o OwnerDid) String() string { return string(o) }
+25 -1
repoident/repoident_test.go
··· 1 1 package repoident 2 2 3 - import "testing" 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + ) 4 7 5 8 func TestNewRepoDid_RejectsInvalid(t *testing.T) { 6 9 if _, err := NewRepoDid(""); err == nil { ··· 35 38 t.Errorf("got %q, want %q", got, raw) 36 39 } 37 40 } 41 + 42 + func TestDidJSONRoundTripsAndValidates(t *testing.T) { 43 + var pair struct { 44 + Repo RepoDid `json:"repo"` 45 + Owner OwnerDid `json:"owner"` 46 + } 47 + const raw = `{"repo":"did:plc:boltless","owner":"did:plc:akshay"}` 48 + if err := json.Unmarshal([]byte(raw), &pair); err != nil { 49 + t.Fatalf("Unmarshal: %v", err) 50 + } 51 + out, err := json.Marshal(pair) 52 + if err != nil { 53 + t.Fatalf("Marshal: %v", err) 54 + } 55 + if string(out) != raw { 56 + t.Errorf("re-encoded %s, want %s", out, raw) 57 + } 58 + if err := json.Unmarshal([]byte(`{"repo":"not-a-did","owner":"did:plc:akshay"}`), &pair); err == nil { 59 + t.Error("Unmarshal accepted a malformed repoDid") 60 + } 61 + }