Go boilerplate library for building atproto apps
atproto go
1
fork

Configure Feed

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

feat: initial commit

Patrick Dewey 81817b52

+2029
+1
.gitignore
··· 1 + *.out
+245
client.go
··· 1 + package atp 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + 11 + "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + // Maximum PDS blob size (1 MB). 17 + const MaxBlobSize = 1024 * 1024 18 + 19 + // Client wraps an authenticated atclient.APIClient for PDS CRUD operations. 20 + // Create one per-user session via `ResumeSession` or [NewClient]. 21 + type Client struct { 22 + api *atclient.APIClient 23 + did syntax.DID 24 + } 25 + 26 + // NewClient wraps an existing indigo APIClient. Use this when you manage 27 + // OAuth sessions yourself (e.g. CLI apps that call ResumeSession directly). 28 + func NewClient(api *atclient.APIClient, did syntax.DID) *Client { 29 + return &Client{api: api, did: did} 30 + } 31 + 32 + // DID returns the authenticated user's DID. 33 + func (c *Client) DID() syntax.DID { return c.did } 34 + 35 + // APIClient returns the underlying indigo APIClient for advanced usage 36 + // (custom XRPC calls, service proxying, etc.). 37 + func (c *Client) APIClient() *atclient.APIClient { return c.api } 38 + 39 + // CreateRecord creates a new record with an auto-generated TID key. 40 + func (c *Client) CreateRecord(ctx context.Context, collection string, record any) (uri, cid string, err error) { 41 + body := map[string]any{ 42 + "repo": c.did.String(), 43 + "collection": collection, 44 + "record": record, 45 + } 46 + 47 + var result struct { 48 + URI string `json:"uri"` 49 + CID string `json:"cid"` 50 + } 51 + if err := c.api.Post(ctx, "com.atproto.repo.createRecord", body, &result); err != nil { 52 + return "", "", WrapPDSError(fmt.Errorf("create record in %s: %w", collection, err)) 53 + } 54 + return result.URI, result.CID, nil 55 + } 56 + 57 + // CreateRecordWithRKey creates a new record with a specific record key. 58 + func (c *Client) CreateRecordWithRKey(ctx context.Context, collection, rkey string, record any) (uri, cid string, err error) { 59 + body := map[string]any{ 60 + "repo": c.did.String(), 61 + "collection": collection, 62 + "rkey": rkey, 63 + "record": record, 64 + } 65 + 66 + var result struct { 67 + URI string `json:"uri"` 68 + CID string `json:"cid"` 69 + } 70 + if err := c.api.Post(ctx, "com.atproto.repo.createRecord", body, &result); err != nil { 71 + return "", "", WrapPDSError(fmt.Errorf("create record %s/%s: %w", collection, rkey, err)) 72 + } 73 + return result.URI, result.CID, nil 74 + } 75 + 76 + // GetRecord retrieves a single record by collection and record key. 77 + func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { 78 + params := map[string]any{ 79 + "repo": c.did.String(), 80 + "collection": collection, 81 + "rkey": rkey, 82 + } 83 + 84 + var result struct { 85 + URI string `json:"uri"` 86 + CID string `json:"cid"` 87 + Value map[string]any `json:"value"` 88 + } 89 + if err := c.api.Get(ctx, "com.atproto.repo.getRecord", params, &result); err != nil { 90 + return nil, WrapPDSError(fmt.Errorf("get record %s/%s: %w", collection, rkey, err)) 91 + } 92 + return &Record{URI: result.URI, CID: result.CID, Value: result.Value}, nil 93 + } 94 + 95 + // ListRecords retrieves a single page of records from a collection. 96 + // Pass limit <= 0 for the server default (usually 50). Pass empty cursor for the first page. 97 + func (c *Client) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*ListResult, error) { 98 + params := map[string]any{ 99 + "repo": c.did.String(), 100 + "collection": collection, 101 + } 102 + if limit > 0 { 103 + params["limit"] = limit 104 + } 105 + if cursor != "" { 106 + params["cursor"] = cursor 107 + } 108 + 109 + var result struct { 110 + Records []struct { 111 + URI string `json:"uri"` 112 + CID string `json:"cid"` 113 + Value map[string]any `json:"value"` 114 + } `json:"records"` 115 + Cursor *string `json:"cursor,omitempty"` 116 + } 117 + if err := c.api.Get(ctx, "com.atproto.repo.listRecords", params, &result); err != nil { 118 + return nil, WrapPDSError(fmt.Errorf("list records %s: %w", collection, err)) 119 + } 120 + 121 + records := make([]Record, len(result.Records)) 122 + for i, r := range result.Records { 123 + records[i] = Record{URI: r.URI, CID: r.CID, Value: r.Value} 124 + } 125 + 126 + out := &ListResult{Records: records} 127 + if result.Cursor != nil && *result.Cursor != "" { 128 + out.Cursor = *result.Cursor 129 + } 130 + return out, nil 131 + } 132 + 133 + // ListAllRecords fetches every record in a collection, handling cursor 134 + // pagination automatically. Returns all records at once. 135 + func (c *Client) ListAllRecords(ctx context.Context, collection string) ([]Record, error) { 136 + var all []Record 137 + cursor := "" 138 + 139 + for { 140 + select { 141 + case <-ctx.Done(): 142 + return nil, ctx.Err() 143 + default: 144 + } 145 + 146 + page, err := c.ListRecords(ctx, collection, 100, cursor) 147 + if err != nil { 148 + return nil, err 149 + } 150 + all = append(all, page.Records...) 151 + 152 + if page.Cursor == "" { 153 + break 154 + } 155 + cursor = page.Cursor 156 + } 157 + return all, nil 158 + } 159 + 160 + // PutRecord creates or updates a record at a specific record key. 161 + func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (uri, cid string, err error) { 162 + body := map[string]any{ 163 + "repo": c.did.String(), 164 + "collection": collection, 165 + "rkey": rkey, 166 + "record": record, 167 + } 168 + 169 + var result struct { 170 + URI string `json:"uri"` 171 + CID string `json:"cid"` 172 + } 173 + if err := c.api.Post(ctx, "com.atproto.repo.putRecord", body, &result); err != nil { 174 + return "", "", WrapPDSError(fmt.Errorf("put record %s/%s: %w", collection, rkey, err)) 175 + } 176 + return result.URI, result.CID, nil 177 + } 178 + 179 + // DeleteRecord removes a record from the user's repository. 180 + func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { 181 + body := map[string]any{ 182 + "repo": c.did.String(), 183 + "collection": collection, 184 + "rkey": rkey, 185 + } 186 + 187 + var result struct{} 188 + if err := c.api.Post(ctx, "com.atproto.repo.deleteRecord", body, &result); err != nil { 189 + return WrapPDSError(fmt.Errorf("delete record %s/%s: %w", collection, rkey, err)) 190 + } 191 + return nil 192 + } 193 + 194 + // UploadBlob uploads a blob to the user's PDS. 195 + // Data must be at most 1 MB (MaxBlobSize). The mimeType should match the blob content. 196 + func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*BlobRef, error) { 197 + if len(data) > MaxBlobSize { 198 + return nil, fmt.Errorf("blob size %d exceeds maximum %d bytes", len(data), MaxBlobSize) 199 + } 200 + 201 + endpoint, err := syntax.ParseNSID("com.atproto.repo.uploadBlob") 202 + if err != nil { 203 + return nil, err 204 + } 205 + 206 + reader := bytes.NewReader(data) 207 + req := atclient.NewAPIRequest(http.MethodPost, endpoint, reader) 208 + req.Headers = http.Header{} 209 + req.Headers.Set("Content-Type", mimeType) 210 + req.GetBody = func() (io.ReadCloser, error) { 211 + return io.NopCloser(bytes.NewReader(data)), nil 212 + } 213 + 214 + resp, err := c.api.Do(ctx, req) 215 + if err != nil { 216 + return nil, WrapPDSError(fmt.Errorf("upload blob: %w", err)) 217 + } 218 + defer resp.Body.Close() 219 + 220 + respBody, err := io.ReadAll(resp.Body) 221 + if err != nil { 222 + return nil, fmt.Errorf("read upload response: %w", err) 223 + } 224 + 225 + var output atproto.RepoUploadBlob_Output 226 + if err := json.Unmarshal(respBody, &output); err != nil { 227 + return nil, fmt.Errorf("unmarshal upload response: %w", err) 228 + } 229 + 230 + return &BlobRef{ 231 + Type: "blob", 232 + MimeType: output.Blob.MimeType, 233 + Size: int(output.Blob.Size), 234 + Ref: CIDLink{Link: output.Blob.Ref.String()}, 235 + }, nil 236 + } 237 + 238 + // GetBlob downloads a blob from the user's PDS by its CID. 239 + func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) { 240 + data, err := atproto.SyncGetBlob(ctx, c.api, cid, c.did.String()) 241 + if err != nil { 242 + return nil, WrapPDSError(fmt.Errorf("get blob %s: %w", cid, err)) 243 + } 244 + return data, nil 245 + }
+27
client_test.go
··· 1 + package atp 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + func TestNewClient(t *testing.T) { 10 + did, err := syntax.ParseDID("did:plc:testuser123") 11 + if err != nil { 12 + t.Fatal(err) 13 + } 14 + 15 + c := NewClient(nil, did) 16 + if c.DID() != did { 17 + t.Fatalf("got DID %v, want %v", c.DID(), did) 18 + } 19 + } 20 + 21 + func TestClient_APIClient(t *testing.T) { 22 + did, _ := syntax.ParseDID("did:plc:testuser123") 23 + c := NewClient(nil, did) 24 + if c.APIClient() != nil { 25 + t.Fatal("expected nil APIClient") 26 + } 27 + }
+26
errors.go
··· 1 + package atp 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "strings" 7 + ) 8 + 9 + // ErrSessionExpired indicates the OAuth session can no longer be resumed. 10 + // Callers should prompt the user to re-authenticate. 11 + var ErrSessionExpired = errors.New("oauth session expired") 12 + 13 + // WrapPDSError inspects an XRPC error for signals that the OAuth grant is no 14 + // longer valid and, if so, wraps it with ErrSessionExpired. 15 + func WrapPDSError(err error) error { 16 + if err == nil { 17 + return nil 18 + } 19 + msg := err.Error() 20 + if strings.Contains(msg, "invalid_grant") || 21 + strings.Contains(msg, "failed to refresh OAuth tokens") || 22 + strings.Contains(msg, "token is expired") { 23 + return fmt.Errorf("%w: %w", ErrSessionExpired, err) 24 + } 25 + return err 26 + }
+39
errors_test.go
··· 1 + package atp 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "testing" 7 + ) 8 + 9 + func TestWrapPDSError(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + input error 13 + wantNil bool 14 + wantExpired bool 15 + }{ 16 + {"nil input", nil, true, false}, 17 + {"non-expired error", fmt.Errorf("some network error"), false, false}, 18 + {"invalid_grant", fmt.Errorf("token request: invalid_grant"), false, true}, 19 + {"failed refresh", fmt.Errorf("failed to refresh OAuth tokens"), false, true}, 20 + {"token expired", fmt.Errorf("token is expired"), false, true}, 21 + } 22 + for _, tc := range tests { 23 + t.Run(tc.name, func(t *testing.T) { 24 + err := WrapPDSError(tc.input) 25 + if tc.wantNil { 26 + if err != nil { 27 + t.Fatalf("expected nil, got %v", err) 28 + } 29 + return 30 + } 31 + if tc.wantExpired && !errors.Is(err, ErrSessionExpired) { 32 + t.Fatalf("expected ErrSessionExpired, got %v", err) 33 + } 34 + if !tc.wantExpired && errors.Is(err, ErrSessionExpired) { 35 + t.Fatal("should not wrap non-expired error as ErrSessionExpired") 36 + } 37 + }) 38 + } 39 + }
+68
go.mod
··· 1 + module tangled.org/pdewey.com/atp 2 + 3 + go 1.25.5 4 + 5 + require ( 6 + github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd 7 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 8 + go.etcd.io/bbolt v1.4.3 9 + go.opentelemetry.io/otel v1.43.0 10 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 11 + go.opentelemetry.io/otel/sdk v1.43.0 12 + go.opentelemetry.io/otel/trace v1.43.0 13 + modernc.org/sqlite v1.48.1 14 + ) 15 + 16 + require ( 17 + github.com/beorn7/perks v1.0.1 // indirect 18 + github.com/cenkalti/backoff/v5 v5.0.3 // indirect 19 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 20 + github.com/dustin/go-humanize v1.0.1 // indirect 21 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 22 + github.com/go-logr/logr v1.4.3 // indirect 23 + github.com/go-logr/stdr v1.2.2 // indirect 24 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 25 + github.com/google/go-querystring v1.1.0 // indirect 26 + github.com/google/uuid v1.6.0 // indirect 27 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect 28 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 29 + github.com/ipfs/go-cid v0.4.1 // indirect 30 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 31 + github.com/mattn/go-isatty v0.0.20 // indirect 32 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 33 + github.com/minio/sha256-simd v1.0.1 // indirect 34 + github.com/mr-tron/base58 v1.2.0 // indirect 35 + github.com/multiformats/go-base32 v0.1.0 // indirect 36 + github.com/multiformats/go-base36 v0.2.0 // indirect 37 + github.com/multiformats/go-multibase v0.2.0 // indirect 38 + github.com/multiformats/go-multihash v0.2.3 // indirect 39 + github.com/multiformats/go-varint v0.0.7 // indirect 40 + github.com/ncruces/go-strftime v1.0.0 // indirect 41 + github.com/prometheus/client_golang v1.17.0 // indirect 42 + github.com/prometheus/client_model v0.5.0 // indirect 43 + github.com/prometheus/common v0.45.0 // indirect 44 + github.com/prometheus/procfs v0.12.0 // indirect 45 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 46 + github.com/spaolacci/murmur3 v1.1.0 // indirect 47 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 48 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 49 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 50 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect 51 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect 52 + go.opentelemetry.io/otel/metric v1.43.0 // indirect 53 + go.opentelemetry.io/proto/otlp v1.10.0 // indirect 54 + golang.org/x/crypto v0.49.0 // indirect 55 + golang.org/x/net v0.52.0 // indirect 56 + golang.org/x/sys v0.42.0 // indirect 57 + golang.org/x/text v0.35.0 // indirect 58 + golang.org/x/time v0.3.0 // indirect 59 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 60 + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect 61 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect 62 + google.golang.org/grpc v1.80.0 // indirect 63 + google.golang.org/protobuf v1.36.11 // indirect 64 + lukechampine.com/blake3 v1.2.1 // indirect 65 + modernc.org/libc v1.70.0 // indirect 66 + modernc.org/mathutil v1.7.1 // indirect 67 + modernc.org/memory v1.11.0 // indirect 68 + )
+170
go.sum
··· 1 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 + github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd h1:FZSMlxClfm7jCA6A/vwTNw5EPxSngPPpK09MxuEx9l0= 4 + github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 5 + github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= 6 + github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 7 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 12 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 13 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 14 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 15 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 16 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 17 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 18 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 19 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 20 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 21 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 22 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 23 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 24 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 25 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 26 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 27 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 28 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 29 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 30 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 31 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= 34 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= 35 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 36 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 37 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 38 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 39 + github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 40 + github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 41 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 42 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 43 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 44 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 45 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 46 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 47 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 48 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 49 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 50 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 51 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 52 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 53 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 54 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 55 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 56 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 57 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 58 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 59 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 60 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 61 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 62 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 63 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 64 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 + github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 66 + github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 67 + github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 68 + github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 69 + github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 70 + github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 71 + github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 72 + github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 73 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 74 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 75 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 76 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 77 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 78 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 79 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 80 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 81 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 82 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 83 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 84 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 85 + go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= 86 + go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 87 + go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 88 + go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 89 + go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= 90 + go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= 91 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= 92 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= 93 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= 94 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= 95 + go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= 96 + go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= 97 + go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= 98 + go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= 99 + go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= 100 + go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= 101 + go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= 102 + go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= 103 + go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= 104 + go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= 105 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 106 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 107 + golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= 108 + golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 109 + golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 110 + golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 111 + golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= 112 + golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 113 + golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 114 + golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 115 + golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 + golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 119 + golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 120 + golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= 121 + golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 122 + golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 123 + golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 124 + golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 125 + golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 126 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 127 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 128 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 129 + gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= 130 + gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 131 + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= 132 + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= 133 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= 134 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= 135 + google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= 136 + google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= 137 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 138 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 139 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 140 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 141 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 142 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 143 + modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= 144 + modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 145 + modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= 146 + modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= 147 + modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= 148 + modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 149 + modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 150 + modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 151 + modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= 152 + modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 153 + modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 154 + modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 155 + modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= 156 + modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= 157 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 158 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 159 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 160 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 161 + modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 162 + modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 163 + modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 164 + modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 165 + modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= 166 + modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= 167 + modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 168 + modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 169 + modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 170 + modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+93
middleware/auth.go
··· 1 + // Package middleware provides HTTP middleware for AT Protocol authentication. 2 + package middleware 3 + 4 + import ( 5 + "context" 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + atp "tangled.org/pdewey.com/atp" 10 + ) 11 + 12 + type contextKey string 13 + 14 + const ( 15 + ctxKeyDID contextKey = "atp_did" 16 + ctxKeySessionID contextKey = "atp_session_id" 17 + ) 18 + 19 + // CookieAuthConfig configures the [CookieAuth] middleware. 20 + type CookieAuthConfig struct { 21 + // OAuthApp is the OAuth application to validate sessions against. 22 + OAuthApp *atp.OAuthApp 23 + 24 + // DIDCookieName is the cookie holding the user's DID. 25 + // Defaults to "account_did". 26 + DIDCookieName string 27 + 28 + // SessCookieName is the cookie holding the session ID. 29 + // Defaults to "session_id". 30 + SessCookieName string 31 + 32 + // OnAuth is called when a valid session is found. Implementations should 33 + // be idempotent (this is called on every authenticated request). 34 + OnAuth func(did string) 35 + } 36 + 37 + // CookieAuth returns middleware that reads DID + session cookies, validates the 38 + // session against the store, and adds auth info to the request context. 39 + // Unauthenticated requests pass through without error. 40 + func CookieAuth(cfg CookieAuthConfig) func(http.Handler) http.Handler { 41 + didCookie := cfg.DIDCookieName 42 + if didCookie == "" { 43 + didCookie = "account_did" 44 + } 45 + sessCookie := cfg.SessCookieName 46 + if sessCookie == "" { 47 + sessCookie = "session_id" 48 + } 49 + 50 + return func(next http.Handler) http.Handler { 51 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 + dc, err1 := r.Cookie(didCookie) 53 + sc, err2 := r.Cookie(sessCookie) 54 + if err1 != nil || err2 != nil { 55 + next.ServeHTTP(w, r) 56 + return 57 + } 58 + 59 + did, err := syntax.ParseDID(dc.Value) 60 + if err != nil { 61 + next.ServeHTTP(w, r) 62 + return 63 + } 64 + 65 + // Validate session exists in store 66 + _, err = cfg.OAuthApp.Store().GetSession(r.Context(), did, sc.Value) 67 + if err != nil { 68 + next.ServeHTTP(w, r) 69 + return 70 + } 71 + 72 + if cfg.OnAuth != nil { 73 + cfg.OnAuth(did.String()) 74 + } 75 + 76 + ctx := context.WithValue(r.Context(), ctxKeyDID, did.String()) 77 + ctx = context.WithValue(ctx, ctxKeySessionID, sc.Value) 78 + next.ServeHTTP(w, r.WithContext(ctx)) 79 + }) 80 + } 81 + } 82 + 83 + // GetDID retrieves the authenticated user's DID from the request context. 84 + func GetDID(ctx context.Context) (string, bool) { 85 + did, ok := ctx.Value(ctxKeyDID).(string) 86 + return did, ok && did != "" 87 + } 88 + 89 + // GetSessionID retrieves the session ID from the request context. 90 + func GetSessionID(ctx context.Context) (string, bool) { 91 + sid, ok := ctx.Value(ctxKeySessionID).(string) 92 + return sid, ok && sid != "" 93 + }
+178
middleware/auth_test.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "testing" 7 + 8 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + atp "tangled.org/pdewey.com/atp" 11 + ) 12 + 13 + func TestGetDID_NoContext(t *testing.T) { 14 + r := httptest.NewRequest("GET", "/", nil) 15 + did, ok := GetDID(r.Context()) 16 + if ok || did != "" { 17 + t.Fatal("expected no DID in empty context") 18 + } 19 + } 20 + 21 + func TestGetSessionID_NoContext(t *testing.T) { 22 + r := httptest.NewRequest("GET", "/", nil) 23 + sid, ok := GetSessionID(r.Context()) 24 + if ok || sid != "" { 25 + t.Fatal("expected no session ID in empty context") 26 + } 27 + } 28 + 29 + func TestCookieAuth_NoCookies(t *testing.T) { 30 + store := oauth.NewMemStore() 31 + app, _ := atp.NewOAuthApp(atp.OAuthConfig{ 32 + RedirectURI: "http://127.0.0.1:9999/cb", 33 + Scopes: []string{"atproto"}, 34 + Store: store, 35 + }) 36 + 37 + var gotDID string 38 + handler := CookieAuth(CookieAuthConfig{OAuthApp: app})( 39 + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 + did, _ := GetDID(r.Context()) 41 + gotDID = did 42 + }), 43 + ) 44 + 45 + r := httptest.NewRequest("GET", "/", nil) 46 + w := httptest.NewRecorder() 47 + handler.ServeHTTP(w, r) 48 + 49 + if gotDID != "" { 50 + t.Fatalf("expected empty DID without cookies, got %q", gotDID) 51 + } 52 + } 53 + 54 + func TestCookieAuth_ValidSession(t *testing.T) { 55 + store := oauth.NewMemStore() 56 + did, _ := syntax.ParseDID("did:plc:test123") 57 + 58 + store.SaveSession(nil, oauth.ClientSessionData{ 59 + AccountDID: did, 60 + SessionID: "sess-1", 61 + }) 62 + 63 + app, _ := atp.NewOAuthApp(atp.OAuthConfig{ 64 + RedirectURI: "http://127.0.0.1:9999/cb", 65 + Scopes: []string{"atproto"}, 66 + Store: store, 67 + }) 68 + 69 + var gotDID, gotSID string 70 + handler := CookieAuth(CookieAuthConfig{OAuthApp: app})( 71 + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 + gotDID, _ = GetDID(r.Context()) 73 + gotSID, _ = GetSessionID(r.Context()) 74 + }), 75 + ) 76 + 77 + r := httptest.NewRequest("GET", "/", nil) 78 + r.AddCookie(&http.Cookie{Name: "account_did", Value: "did:plc:test123"}) 79 + r.AddCookie(&http.Cookie{Name: "session_id", Value: "sess-1"}) 80 + w := httptest.NewRecorder() 81 + handler.ServeHTTP(w, r) 82 + 83 + if gotDID != "did:plc:test123" { 84 + t.Fatalf("got DID %q, want did:plc:test123", gotDID) 85 + } 86 + if gotSID != "sess-1" { 87 + t.Fatalf("got session ID %q, want sess-1", gotSID) 88 + } 89 + } 90 + 91 + func TestCookieAuth_InvalidDID(t *testing.T) { 92 + store := oauth.NewMemStore() 93 + app, _ := atp.NewOAuthApp(atp.OAuthConfig{ 94 + RedirectURI: "http://127.0.0.1:9999/cb", 95 + Scopes: []string{"atproto"}, 96 + Store: store, 97 + }) 98 + 99 + var gotDID string 100 + handler := CookieAuth(CookieAuthConfig{OAuthApp: app})( 101 + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 102 + gotDID, _ = GetDID(r.Context()) 103 + }), 104 + ) 105 + 106 + r := httptest.NewRequest("GET", "/", nil) 107 + r.AddCookie(&http.Cookie{Name: "account_did", Value: "not-a-did"}) 108 + r.AddCookie(&http.Cookie{Name: "session_id", Value: "sess-1"}) 109 + w := httptest.NewRecorder() 110 + handler.ServeHTTP(w, r) 111 + 112 + if gotDID != "" { 113 + t.Fatalf("expected empty DID for invalid cookie, got %q", gotDID) 114 + } 115 + } 116 + 117 + func TestCookieAuth_OnAuth(t *testing.T) { 118 + store := oauth.NewMemStore() 119 + did, _ := syntax.ParseDID("did:plc:test123") 120 + store.SaveSession(nil, oauth.ClientSessionData{ 121 + AccountDID: did, 122 + SessionID: "sess-1", 123 + }) 124 + 125 + app, _ := atp.NewOAuthApp(atp.OAuthConfig{ 126 + RedirectURI: "http://127.0.0.1:9999/cb", 127 + Scopes: []string{"atproto"}, 128 + Store: store, 129 + }) 130 + 131 + var calledWith string 132 + handler := CookieAuth(CookieAuthConfig{ 133 + OAuthApp: app, 134 + OnAuth: func(did string) { calledWith = did }, 135 + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 136 + 137 + r := httptest.NewRequest("GET", "/", nil) 138 + r.AddCookie(&http.Cookie{Name: "account_did", Value: "did:plc:test123"}) 139 + r.AddCookie(&http.Cookie{Name: "session_id", Value: "sess-1"}) 140 + handler.ServeHTTP(httptest.NewRecorder(), r) 141 + 142 + if calledWith != "did:plc:test123" { 143 + t.Fatalf("OnAuth called with %q, want did:plc:test123", calledWith) 144 + } 145 + } 146 + 147 + func TestCookieAuth_CustomCookieNames(t *testing.T) { 148 + store := oauth.NewMemStore() 149 + did, _ := syntax.ParseDID("did:plc:test123") 150 + store.SaveSession(nil, oauth.ClientSessionData{ 151 + AccountDID: did, 152 + SessionID: "sess-1", 153 + }) 154 + 155 + app, _ := atp.NewOAuthApp(atp.OAuthConfig{ 156 + RedirectURI: "http://127.0.0.1:9999/cb", 157 + Scopes: []string{"atproto"}, 158 + Store: store, 159 + }) 160 + 161 + var gotDID string 162 + handler := CookieAuth(CookieAuthConfig{ 163 + OAuthApp: app, 164 + DIDCookieName: "my_did", 165 + SessCookieName: "my_sess", 166 + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 + gotDID, _ = GetDID(r.Context()) 168 + })) 169 + 170 + r := httptest.NewRequest("GET", "/", nil) 171 + r.AddCookie(&http.Cookie{Name: "my_did", Value: "did:plc:test123"}) 172 + r.AddCookie(&http.Cookie{Name: "my_sess", Value: "sess-1"}) 173 + handler.ServeHTTP(httptest.NewRecorder(), r) 174 + 175 + if gotDID != "did:plc:test123" { 176 + t.Fatalf("got DID %q with custom cookie names", gotDID) 177 + } 178 + }
+189
oauth.go
··· 1 + package atp 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/pkg/browser" 13 + ) 14 + 15 + // OAuthConfig configures an [OAuthApp]. 16 + type OAuthConfig struct { 17 + // ClientID is the OAuth client ID (a URL). Empty string or a localhost/127.0.0.1 18 + // prefix triggers development mode (localhost OAuth with automatic PKCE). 19 + ClientID string 20 + 21 + // RedirectURI is the fully-qualified OAuth callback URL. 22 + RedirectURI string 23 + 24 + // Scopes for the OAuth grant. Use [ScopesForCollections] to build these 25 + // from collection NSIDs. Must include "atproto". 26 + Scopes []string 27 + 28 + // Store persists OAuth sessions and auth request state. If nil, an 29 + // in-memory store is used (sessions lost on restart). 30 + Store oauth.ClientAuthStore 31 + 32 + // AppName is an optional human-readable name included in client metadata. 33 + AppName string 34 + } 35 + 36 + // SessionInfo is returned after a successful OAuth callback. 37 + type SessionInfo struct { 38 + DID syntax.DID 39 + SessionID string 40 + Scopes []string 41 + } 42 + 43 + // OAuthApp manages AT Protocol OAuth flows. 44 + type OAuthApp struct { 45 + app *oauth.ClientApp 46 + appName string 47 + } 48 + 49 + // NewOAuthApp creates a new OAuth application from the given config. 50 + func NewOAuthApp(cfg OAuthConfig) (*OAuthApp, error) { 51 + var config oauth.ClientConfig 52 + 53 + if cfg.ClientID == "" || 54 + strings.HasPrefix(cfg.ClientID, "http://localhost") || 55 + strings.HasPrefix(cfg.ClientID, "http://127.0.0.1") { 56 + config = oauth.NewLocalhostConfig(cfg.RedirectURI, cfg.Scopes) 57 + } else { 58 + config = oauth.NewPublicConfig(cfg.ClientID, cfg.RedirectURI, cfg.Scopes) 59 + } 60 + 61 + store := cfg.Store 62 + if store == nil { 63 + store = oauth.NewMemStore() 64 + } 65 + 66 + app := oauth.NewClientApp(&config, store) 67 + 68 + return &OAuthApp{ 69 + app: app, 70 + appName: cfg.AppName, 71 + }, nil 72 + } 73 + 74 + // StartLogin begins the web OAuth flow. Returns the authorization URL to 75 + // redirect the user to. Use [HandleCallback] when the user returns. 76 + func (a *OAuthApp) StartLogin(ctx context.Context, handle string) (string, error) { 77 + authURL, err := a.app.StartAuthFlow(ctx, handle) 78 + if err != nil { 79 + return "", fmt.Errorf("start auth flow: %w", err) 80 + } 81 + return authURL, nil 82 + } 83 + 84 + // HandleCallback processes the OAuth callback after user authorization. 85 + // Pass r.URL.Query() from the callback request. 86 + func (a *OAuthApp) HandleCallback(ctx context.Context, params url.Values) (*SessionInfo, error) { 87 + sessData, err := a.app.ProcessCallback(ctx, params) 88 + if err != nil { 89 + return nil, fmt.Errorf("process callback: %w", err) 90 + } 91 + return &SessionInfo{ 92 + DID: sessData.AccountDID, 93 + SessionID: sessData.SessionID, 94 + Scopes: sessData.Scopes, 95 + }, nil 96 + } 97 + 98 + // LoginCLI runs a complete loopback OAuth flow for CLI applications. 99 + // It opens the user's browser, starts a temporary HTTP server to receive the 100 + // callback, and blocks until authentication completes. 101 + func (a *OAuthApp) LoginCLI(ctx context.Context, handle string) (*SessionInfo, error) { 102 + authURL, err := a.app.StartAuthFlow(ctx, handle) 103 + if err != nil { 104 + return nil, fmt.Errorf("start auth flow: %w", err) 105 + } 106 + 107 + // Parse callback path and port from the configured redirect URI 108 + redirectURL, err := url.Parse(a.app.Config.CallbackURL) 109 + if err != nil { 110 + return nil, fmt.Errorf("parse redirect URI: %w", err) 111 + } 112 + 113 + if err := browser.OpenURL(authURL); err != nil { 114 + fmt.Printf("Open this URL in your browser:\n%s\n", authURL) 115 + } 116 + 117 + type result struct { 118 + info *SessionInfo 119 + err error 120 + } 121 + ch := make(chan result, 1) 122 + 123 + mux := http.NewServeMux() 124 + mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { 125 + sessData, err := a.app.ProcessCallback(r.Context(), r.URL.Query()) 126 + if err != nil { 127 + w.WriteHeader(http.StatusInternalServerError) 128 + fmt.Fprint(w, "Authentication failed") 129 + ch <- result{err: fmt.Errorf("process callback: %w", err)} 130 + return 131 + } 132 + 133 + w.Header().Set("Content-Type", "text/html") 134 + fmt.Fprint(w, "<html><body><h1>Authenticated!</h1><p>You can close this tab.</p></body></html>") 135 + ch <- result{info: &SessionInfo{ 136 + DID: sessData.AccountDID, 137 + SessionID: sessData.SessionID, 138 + Scopes: sessData.Scopes, 139 + }} 140 + }) 141 + 142 + addr := redirectURL.Host 143 + server := &http.Server{Addr: addr, Handler: mux} 144 + go server.ListenAndServe() 145 + 146 + res := <-ch 147 + server.Close() 148 + 149 + if res.err != nil { 150 + return nil, res.err 151 + } 152 + return res.info, nil 153 + } 154 + 155 + // ResumeSession restores an existing OAuth session and returns a [Client] 156 + // ready for PDS CRUD operations. 157 + func (a *OAuthApp) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*Client, error) { 158 + session, err := a.app.ResumeSession(ctx, did, sessionID) 159 + if err != nil { 160 + return nil, fmt.Errorf("%w: %w", ErrSessionExpired, err) 161 + } 162 + return NewClient(session.APIClient(), did), nil 163 + } 164 + 165 + // DeleteSession removes an OAuth session (for logout). 166 + func (a *OAuthApp) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 167 + return a.app.Store.DeleteSession(ctx, did, sessionID) 168 + } 169 + 170 + // Logout revokes the OAuth tokens and deletes the session. 171 + func (a *OAuthApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error { 172 + return a.app.Logout(ctx, did, sessionID) 173 + } 174 + 175 + // ClientMetadata returns the OAuth client metadata document. Serve this 176 + // at your client_id URL and at /.well-known/oauth-client-metadata. 177 + func (a *OAuthApp) ClientMetadata() oauth.ClientMetadata { 178 + meta := a.app.Config.ClientMetadata() 179 + if a.appName != "" { 180 + meta.ClientName = &a.appName 181 + } 182 + return meta 183 + } 184 + 185 + // Store returns the underlying session store, useful for implementing 186 + // features like "list all sessions" or session cleanup. 187 + func (a *OAuthApp) Store() oauth.ClientAuthStore { 188 + return a.app.Store 189 + }
+100
oauth_test.go
··· 1 + package atp 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 7 + ) 8 + 9 + func TestNewOAuthApp_Localhost(t *testing.T) { 10 + app, err := NewOAuthApp(OAuthConfig{ 11 + ClientID: "", 12 + RedirectURI: "http://127.0.0.1:12345/callback", 13 + Scopes: []string{"atproto"}, 14 + Store: oauth.NewMemStore(), 15 + }) 16 + if err != nil { 17 + t.Fatal(err) 18 + } 19 + if app == nil { 20 + t.Fatal("expected non-nil app") 21 + } 22 + } 23 + 24 + func TestNewOAuthApp_LocalhostPrefix(t *testing.T) { 25 + app, err := NewOAuthApp(OAuthConfig{ 26 + ClientID: "http://localhost:8080", 27 + RedirectURI: "http://localhost:8080/oauth/callback", 28 + Scopes: []string{"atproto"}, 29 + Store: oauth.NewMemStore(), 30 + }) 31 + if err != nil { 32 + t.Fatal(err) 33 + } 34 + if app == nil { 35 + t.Fatal("expected non-nil app") 36 + } 37 + } 38 + 39 + func TestNewOAuthApp_Public(t *testing.T) { 40 + app, err := NewOAuthApp(OAuthConfig{ 41 + ClientID: "https://example.com/client-metadata.json", 42 + RedirectURI: "https://example.com/oauth/callback", 43 + Scopes: ScopesForCollections("x.y.bean"), 44 + Store: oauth.NewMemStore(), 45 + }) 46 + if err != nil { 47 + t.Fatal(err) 48 + } 49 + if app == nil { 50 + t.Fatal("expected non-nil app") 51 + } 52 + } 53 + 54 + func TestNewOAuthApp_NilStore(t *testing.T) { 55 + app, err := NewOAuthApp(OAuthConfig{ 56 + RedirectURI: "http://127.0.0.1:12345/callback", 57 + Scopes: []string{"atproto"}, 58 + }) 59 + if err != nil { 60 + t.Fatal(err) 61 + } 62 + if app == nil { 63 + t.Fatal("expected non-nil app") 64 + } 65 + } 66 + 67 + func TestOAuthApp_ClientMetadata(t *testing.T) { 68 + app, _ := NewOAuthApp(OAuthConfig{ 69 + RedirectURI: "http://127.0.0.1:12345/callback", 70 + Scopes: []string{"atproto"}, 71 + }) 72 + meta := app.ClientMetadata() 73 + if meta.ClientID == "" { 74 + t.Fatal("expected non-empty client ID in metadata") 75 + } 76 + } 77 + 78 + func TestOAuthApp_ClientMetadata_AppName(t *testing.T) { 79 + app, _ := NewOAuthApp(OAuthConfig{ 80 + RedirectURI: "http://127.0.0.1:12345/callback", 81 + Scopes: []string{"atproto"}, 82 + AppName: "TestApp", 83 + }) 84 + meta := app.ClientMetadata() 85 + if meta.ClientName == nil || *meta.ClientName != "TestApp" { 86 + t.Fatal("expected AppName in metadata") 87 + } 88 + } 89 + 90 + func TestOAuthApp_Store(t *testing.T) { 91 + memStore := oauth.NewMemStore() 92 + app, _ := NewOAuthApp(OAuthConfig{ 93 + RedirectURI: "http://127.0.0.1:12345/callback", 94 + Scopes: []string{"atproto"}, 95 + Store: memStore, 96 + }) 97 + if app.Store() == nil { 98 + t.Fatal("expected non-nil store") 99 + } 100 + }
+27
record.go
··· 1 + package atp 2 + 3 + // Record represents a single record returned from a PDS. 4 + type Record struct { 5 + URI string 6 + CID string 7 + Value map[string]any 8 + } 9 + 10 + // ListResult holds a page of records and an optional cursor for the next page. 11 + type ListResult struct { 12 + Records []Record 13 + Cursor string // empty when there are no more pages 14 + } 15 + 16 + // BlobRef represents a reference to an uploaded blob. 17 + type BlobRef struct { 18 + Type string `json:"$type"` 19 + Ref CIDLink `json:"ref"` 20 + MimeType string `json:"mimeType"` 21 + Size int `json:"size"` 22 + } 23 + 24 + // CIDLink is the ref field inside a BlobRef. 25 + type CIDLink struct { 26 + Link string `json:"$link"` 27 + }
+16
scopes.go
··· 1 + package atp 2 + 3 + // ScopesForCollections builds an OAuth scope list from collection NSIDs. 4 + // It always includes "atproto" as the first scope, then adds "repo:<collection>" 5 + // for each collection provided. 6 + // 7 + // ScopesForCollections("x.y.bean", "x.y.brew") 8 + // // => ["atproto", "repo:x.y.bean", "repo:x.y.brew"] 9 + func ScopesForCollections(collections ...string) []string { 10 + scopes := make([]string, 0, 1+len(collections)) 11 + scopes = append(scopes, "atproto") 12 + for _, c := range collections { 13 + scopes = append(scopes, "repo:"+c) 14 + } 15 + return scopes 16 + }
+30
scopes_test.go
··· 1 + package atp 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + ) 7 + 8 + func TestScopesForCollections(t *testing.T) { 9 + got := ScopesForCollections("social.arabica.alpha.bean", "social.arabica.alpha.brew") 10 + want := []string{"atproto", "repo:social.arabica.alpha.bean", "repo:social.arabica.alpha.brew"} 11 + if !slices.Equal(got, want) { 12 + t.Fatalf("got %v, want %v", got, want) 13 + } 14 + } 15 + 16 + func TestScopesForCollections_Empty(t *testing.T) { 17 + got := ScopesForCollections() 18 + want := []string{"atproto"} 19 + if !slices.Equal(got, want) { 20 + t.Fatalf("got %v, want %v", got, want) 21 + } 22 + } 23 + 24 + func TestScopesForCollections_ExtraScopes(t *testing.T) { 25 + got := ScopesForCollections("social.arabica.alpha.bean") 26 + got = append(got, "transition:generic") 27 + if len(got) != 3 { 28 + t.Fatalf("expected 3 scopes, got %d", len(got)) 29 + } 30 + }
+149
store/bolt/bolt.go
··· 1 + // Package bolt provides an oauth.ClientAuthStore backed by BoltDB. 2 + package bolt 3 + 4 + import ( 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "os" 9 + "path/filepath" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + bbolt "go.etcd.io/bbolt" 15 + ) 16 + 17 + var ( 18 + bucketSessions = []byte("oauth_sessions") 19 + bucketAuthRequests = []byte("oauth_auth_requests") 20 + ) 21 + 22 + // Verify interface compliance at compile time. 23 + var _ oauth.ClientAuthStore = (*Store)(nil) 24 + 25 + // Store implements [oauth.ClientAuthStore] using BoltDB. 26 + type Store struct { 27 + db *bbolt.DB 28 + } 29 + 30 + // Open creates or opens a BoltDB store at the given path. 31 + // Parent directories are created if they don't exist. 32 + func Open(path string) (*Store, error) { 33 + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 34 + return nil, fmt.Errorf("create database directory: %w", err) 35 + } 36 + 37 + db, err := bbolt.Open(path, 0600, &bbolt.Options{Timeout: 5 * time.Second}) 38 + if err != nil { 39 + return nil, fmt.Errorf("open database: %w", err) 40 + } 41 + 42 + err = db.Update(func(tx *bbolt.Tx) error { 43 + for _, b := range [][]byte{bucketSessions, bucketAuthRequests} { 44 + if _, err := tx.CreateBucketIfNotExists(b); err != nil { 45 + return fmt.Errorf("create bucket %s: %w", b, err) 46 + } 47 + } 48 + return nil 49 + }) 50 + if err != nil { 51 + db.Close() 52 + return nil, err 53 + } 54 + 55 + return &Store{db: db}, nil 56 + } 57 + 58 + // Close closes the BoltDB database. 59 + func (s *Store) Close() error { 60 + return s.db.Close() 61 + } 62 + 63 + func sessionKey(did syntax.DID, sessionID string) []byte { 64 + return []byte(did.String() + ":" + sessionID) 65 + } 66 + 67 + // GetSession retrieves an OAuth session by DID and session ID. 68 + func (s *Store) GetSession(_ context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 69 + var sess oauth.ClientSessionData 70 + err := s.db.View(func(tx *bbolt.Tx) error { 71 + data := tx.Bucket(bucketSessions).Get(sessionKey(did, sessionID)) 72 + if data == nil { 73 + return fmt.Errorf("session not found") 74 + } 75 + return json.Unmarshal(data, &sess) 76 + }) 77 + if err != nil { 78 + return nil, err 79 + } 80 + return &sess, nil 81 + } 82 + 83 + // SaveSession persists an OAuth session (upsert). 84 + func (s *Store) SaveSession(_ context.Context, sess oauth.ClientSessionData) error { 85 + data, err := json.Marshal(sess) 86 + if err != nil { 87 + return fmt.Errorf("marshal session: %w", err) 88 + } 89 + return s.db.Update(func(tx *bbolt.Tx) error { 90 + return tx.Bucket(bucketSessions).Put(sessionKey(sess.AccountDID, sess.SessionID), data) 91 + }) 92 + } 93 + 94 + // DeleteSession removes an OAuth session. 95 + func (s *Store) DeleteSession(_ context.Context, did syntax.DID, sessionID string) error { 96 + return s.db.Update(func(tx *bbolt.Tx) error { 97 + return tx.Bucket(bucketSessions).Delete(sessionKey(did, sessionID)) 98 + }) 99 + } 100 + 101 + // GetAuthRequestInfo retrieves pending auth request data by state token. 102 + func (s *Store) GetAuthRequestInfo(_ context.Context, state string) (*oauth.AuthRequestData, error) { 103 + var info oauth.AuthRequestData 104 + err := s.db.View(func(tx *bbolt.Tx) error { 105 + data := tx.Bucket(bucketAuthRequests).Get([]byte(state)) 106 + if data == nil { 107 + return fmt.Errorf("auth request not found") 108 + } 109 + return json.Unmarshal(data, &info) 110 + }) 111 + if err != nil { 112 + return nil, err 113 + } 114 + return &info, nil 115 + } 116 + 117 + // SaveAuthRequestInfo stores auth request data keyed by state token. 118 + func (s *Store) SaveAuthRequestInfo(_ context.Context, info oauth.AuthRequestData) error { 119 + data, err := json.Marshal(info) 120 + if err != nil { 121 + return fmt.Errorf("marshal auth request: %w", err) 122 + } 123 + return s.db.Update(func(tx *bbolt.Tx) error { 124 + return tx.Bucket(bucketAuthRequests).Put([]byte(info.State), data) 125 + }) 126 + } 127 + 128 + // DeleteAuthRequestInfo removes auth request data by state token. 129 + func (s *Store) DeleteAuthRequestInfo(_ context.Context, state string) error { 130 + return s.db.Update(func(tx *bbolt.Tx) error { 131 + return tx.Bucket(bucketAuthRequests).Delete([]byte(state)) 132 + }) 133 + } 134 + 135 + // ListSessions returns all stored sessions. 136 + func (s *Store) ListSessions(_ context.Context) ([]oauth.ClientSessionData, error) { 137 + var sessions []oauth.ClientSessionData 138 + err := s.db.View(func(tx *bbolt.Tx) error { 139 + return tx.Bucket(bucketSessions).ForEach(func(_, v []byte) error { 140 + var sess oauth.ClientSessionData 141 + if err := json.Unmarshal(v, &sess); err != nil { 142 + return nil // skip malformed 143 + } 144 + sessions = append(sessions, sess) 145 + return nil 146 + }) 147 + }) 148 + return sessions, err 149 + }
+143
store/bolt/bolt_test.go
··· 1 + package bolt 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + func testStore(t *testing.T) *Store { 14 + t.Helper() 15 + path := filepath.Join(t.TempDir(), "test.db") 16 + s, err := Open(path) 17 + if err != nil { 18 + t.Fatal(err) 19 + } 20 + t.Cleanup(func() { s.Close() }) 21 + return s 22 + } 23 + 24 + func TestOpen(t *testing.T) { 25 + s := testStore(t) 26 + if s == nil { 27 + t.Fatal("expected non-nil store") 28 + } 29 + } 30 + 31 + func TestOpen_CreatesDir(t *testing.T) { 32 + dir := filepath.Join(t.TempDir(), "nested", "dir") 33 + path := filepath.Join(dir, "test.db") 34 + s, err := Open(path) 35 + if err != nil { 36 + t.Fatal(err) 37 + } 38 + s.Close() 39 + 40 + if _, err := os.Stat(path); err != nil { 41 + t.Fatalf("expected db file at %s", path) 42 + } 43 + } 44 + 45 + func TestSessionCRUD(t *testing.T) { 46 + s := testStore(t) 47 + ctx := context.Background() 48 + did, _ := syntax.ParseDID("did:plc:test123") 49 + 50 + sess := oauth.ClientSessionData{ 51 + AccountDID: did, 52 + SessionID: "sess-1", 53 + HostURL: "https://pds.example.com", 54 + } 55 + 56 + // Save 57 + if err := s.SaveSession(ctx, sess); err != nil { 58 + t.Fatal(err) 59 + } 60 + 61 + // Get 62 + got, err := s.GetSession(ctx, did, "sess-1") 63 + if err != nil { 64 + t.Fatal(err) 65 + } 66 + if got.HostURL != "https://pds.example.com" { 67 + t.Fatalf("got HostURL %q, want %q", got.HostURL, "https://pds.example.com") 68 + } 69 + 70 + // Get non-existent 71 + _, err = s.GetSession(ctx, did, "no-such-session") 72 + if err == nil { 73 + t.Fatal("expected error for missing session") 74 + } 75 + 76 + // Delete 77 + if err := s.DeleteSession(ctx, did, "sess-1"); err != nil { 78 + t.Fatal(err) 79 + } 80 + _, err = s.GetSession(ctx, did, "sess-1") 81 + if err == nil { 82 + t.Fatal("expected error after delete") 83 + } 84 + } 85 + 86 + func TestAuthRequestCRUD(t *testing.T) { 87 + s := testStore(t) 88 + ctx := context.Background() 89 + 90 + info := oauth.AuthRequestData{ 91 + State: "state-abc", 92 + AuthServerURL: "https://auth.example.com", 93 + PKCEVerifier: "verifier123", 94 + } 95 + 96 + // Save 97 + if err := s.SaveAuthRequestInfo(ctx, info); err != nil { 98 + t.Fatal(err) 99 + } 100 + 101 + // Get 102 + got, err := s.GetAuthRequestInfo(ctx, "state-abc") 103 + if err != nil { 104 + t.Fatal(err) 105 + } 106 + if got.PKCEVerifier != "verifier123" { 107 + t.Fatalf("got verifier %q", got.PKCEVerifier) 108 + } 109 + 110 + // Get non-existent 111 + _, err = s.GetAuthRequestInfo(ctx, "no-such-state") 112 + if err == nil { 113 + t.Fatal("expected error for missing auth request") 114 + } 115 + 116 + // Delete 117 + if err := s.DeleteAuthRequestInfo(ctx, "state-abc"); err != nil { 118 + t.Fatal(err) 119 + } 120 + _, err = s.GetAuthRequestInfo(ctx, "state-abc") 121 + if err == nil { 122 + t.Fatal("expected error after delete") 123 + } 124 + } 125 + 126 + func TestListSessions(t *testing.T) { 127 + s := testStore(t) 128 + ctx := context.Background() 129 + 130 + did1, _ := syntax.ParseDID("did:plc:user1") 131 + did2, _ := syntax.ParseDID("did:plc:user2") 132 + 133 + s.SaveSession(ctx, oauth.ClientSessionData{AccountDID: did1, SessionID: "s1"}) 134 + s.SaveSession(ctx, oauth.ClientSessionData{AccountDID: did2, SessionID: "s2"}) 135 + 136 + sessions, err := s.ListSessions(ctx) 137 + if err != nil { 138 + t.Fatal(err) 139 + } 140 + if len(sessions) != 2 { 141 + t.Fatalf("expected 2 sessions, got %d", len(sessions)) 142 + } 143 + }
+185
store/sqlite/sqlite.go
··· 1 + // Package sqlite provides an oauth.ClientAuthStore backed by SQLite. 2 + package sqlite 3 + 4 + import ( 5 + "context" 6 + "database/sql" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "sync" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + var ( 17 + ErrSessionNotFound = errors.New("session not found") 18 + ErrStateNotFound = errors.New("auth state not found") 19 + ) 20 + 21 + // Verify interface compliance at compile time. 22 + var _ oauth.ClientAuthStore = (*Store)(nil) 23 + 24 + // Store implements [oauth.ClientAuthStore] using SQLite. 25 + type Store struct { 26 + db *sql.DB 27 + mu sync.RWMutex 28 + } 29 + 30 + // New creates a new SQLite-backed auth store. The caller owns the *sql.DB 31 + // and is responsible for closing it. 32 + func New(db *sql.DB) (*Store, error) { 33 + s := &Store{db: db} 34 + if err := s.migrate(); err != nil { 35 + return nil, fmt.Errorf("migrate auth tables: %w", err) 36 + } 37 + return s, nil 38 + } 39 + 40 + func (s *Store) migrate() error { 41 + _, err := s.db.Exec(` 42 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 43 + did TEXT NOT NULL, 44 + session_id TEXT NOT NULL, 45 + data TEXT NOT NULL, 46 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 47 + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 48 + PRIMARY KEY (did, session_id) 49 + ); 50 + 51 + CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 52 + state TEXT PRIMARY KEY, 53 + data TEXT NOT NULL, 54 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 55 + ); 56 + 57 + CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created_at 58 + ON oauth_auth_requests(created_at); 59 + `) 60 + return err 61 + } 62 + 63 + // GetSession retrieves an OAuth session by DID and session ID. 64 + func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 65 + s.mu.RLock() 66 + defer s.mu.RUnlock() 67 + 68 + var dataJSON string 69 + err := s.db.QueryRowContext(ctx, 70 + `SELECT data FROM oauth_sessions WHERE did = ? AND session_id = ?`, 71 + did.String(), sessionID, 72 + ).Scan(&dataJSON) 73 + 74 + if errors.Is(err, sql.ErrNoRows) { 75 + return nil, ErrSessionNotFound 76 + } 77 + if err != nil { 78 + return nil, fmt.Errorf("query session: %w", err) 79 + } 80 + 81 + var data oauth.ClientSessionData 82 + if err := json.Unmarshal([]byte(dataJSON), &data); err != nil { 83 + return nil, fmt.Errorf("unmarshal session: %w", err) 84 + } 85 + return &data, nil 86 + } 87 + 88 + // SaveSession persists an OAuth session (upsert). 89 + func (s *Store) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 90 + s.mu.Lock() 91 + defer s.mu.Unlock() 92 + 93 + dataJSON, err := json.Marshal(sess) 94 + if err != nil { 95 + return fmt.Errorf("marshal session: %w", err) 96 + } 97 + 98 + _, err = s.db.ExecContext(ctx, ` 99 + INSERT INTO oauth_sessions (did, session_id, data, updated_at) 100 + VALUES (?, ?, ?, CURRENT_TIMESTAMP) 101 + ON CONFLICT(did, session_id) DO UPDATE SET 102 + data = excluded.data, 103 + updated_at = CURRENT_TIMESTAMP 104 + `, sess.AccountDID.String(), sess.SessionID, string(dataJSON)) 105 + return err 106 + } 107 + 108 + // DeleteSession removes an OAuth session. 109 + func (s *Store) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 110 + s.mu.Lock() 111 + defer s.mu.Unlock() 112 + 113 + _, err := s.db.ExecContext(ctx, 114 + `DELETE FROM oauth_sessions WHERE did = ? AND session_id = ?`, 115 + did.String(), sessionID, 116 + ) 117 + return err 118 + } 119 + 120 + // GetAuthRequestInfo retrieves pending auth request data by state token. 121 + func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 122 + s.mu.RLock() 123 + defer s.mu.RUnlock() 124 + 125 + var dataJSON string 126 + err := s.db.QueryRowContext(ctx, 127 + `SELECT data FROM oauth_auth_requests WHERE state = ?`, 128 + state, 129 + ).Scan(&dataJSON) 130 + 131 + if errors.Is(err, sql.ErrNoRows) { 132 + return nil, ErrStateNotFound 133 + } 134 + if err != nil { 135 + return nil, fmt.Errorf("query auth request: %w", err) 136 + } 137 + 138 + var data oauth.AuthRequestData 139 + if err := json.Unmarshal([]byte(dataJSON), &data); err != nil { 140 + return nil, fmt.Errorf("unmarshal auth request: %w", err) 141 + } 142 + return &data, nil 143 + } 144 + 145 + // SaveAuthRequestInfo stores auth request data keyed by state token. 146 + func (s *Store) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 147 + s.mu.Lock() 148 + defer s.mu.Unlock() 149 + 150 + dataJSON, err := json.Marshal(info) 151 + if err != nil { 152 + return fmt.Errorf("marshal auth request: %w", err) 153 + } 154 + 155 + _, err = s.db.ExecContext(ctx, 156 + `INSERT INTO oauth_auth_requests (state, data) VALUES (?, ?)`, 157 + info.State, string(dataJSON), 158 + ) 159 + return err 160 + } 161 + 162 + // DeleteAuthRequestInfo removes auth request data by state token. 163 + func (s *Store) DeleteAuthRequestInfo(ctx context.Context, state string) error { 164 + s.mu.Lock() 165 + defer s.mu.Unlock() 166 + 167 + _, err := s.db.ExecContext(ctx, 168 + `DELETE FROM oauth_auth_requests WHERE state = ?`, 169 + state, 170 + ) 171 + return err 172 + } 173 + 174 + // CleanupExpiredRequests removes auth requests older than the given duration. 175 + // Call this periodically (e.g. every 10 minutes) to prevent unbounded growth. 176 + func (s *Store) CleanupExpiredRequests(ctx context.Context, olderThanMinutes int) error { 177 + s.mu.Lock() 178 + defer s.mu.Unlock() 179 + 180 + _, err := s.db.ExecContext(ctx, 181 + `DELETE FROM oauth_auth_requests WHERE created_at < datetime('now', ?)`, 182 + fmt.Sprintf("-%d minutes", olderThanMinutes), 183 + ) 184 + return err 185 + }
+119
store/sqlite/sqlite_test.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + 8 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + _ "modernc.org/sqlite" 11 + ) 12 + 13 + func testStore(t *testing.T) *Store { 14 + t.Helper() 15 + db, err := sql.Open("sqlite", ":memory:") 16 + if err != nil { 17 + t.Fatal(err) 18 + } 19 + s, err := New(db) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + t.Cleanup(func() { db.Close() }) 24 + return s 25 + } 26 + 27 + func TestNew(t *testing.T) { 28 + s := testStore(t) 29 + if s == nil { 30 + t.Fatal("expected non-nil store") 31 + } 32 + } 33 + 34 + func TestSessionCRUD(t *testing.T) { 35 + s := testStore(t) 36 + ctx := context.Background() 37 + did, _ := syntax.ParseDID("did:plc:test123") 38 + 39 + sess := oauth.ClientSessionData{ 40 + AccountDID: did, 41 + SessionID: "sess-1", 42 + HostURL: "https://pds.example.com", 43 + } 44 + 45 + // Save 46 + if err := s.SaveSession(ctx, sess); err != nil { 47 + t.Fatal(err) 48 + } 49 + 50 + // Get 51 + got, err := s.GetSession(ctx, did, "sess-1") 52 + if err != nil { 53 + t.Fatal(err) 54 + } 55 + if got.HostURL != "https://pds.example.com" { 56 + t.Fatalf("got HostURL %q, want %q", got.HostURL, "https://pds.example.com") 57 + } 58 + 59 + // Upsert 60 + sess.HostURL = "https://pds2.example.com" 61 + if err := s.SaveSession(ctx, sess); err != nil { 62 + t.Fatal(err) 63 + } 64 + got, _ = s.GetSession(ctx, did, "sess-1") 65 + if got.HostURL != "https://pds2.example.com" { 66 + t.Fatalf("upsert failed: got %q", got.HostURL) 67 + } 68 + 69 + // Get non-existent 70 + _, err = s.GetSession(ctx, did, "no-such") 71 + if err == nil { 72 + t.Fatal("expected error for missing session") 73 + } 74 + 75 + // Delete 76 + if err := s.DeleteSession(ctx, did, "sess-1"); err != nil { 77 + t.Fatal(err) 78 + } 79 + _, err = s.GetSession(ctx, did, "sess-1") 80 + if err == nil { 81 + t.Fatal("expected error after delete") 82 + } 83 + } 84 + 85 + func TestAuthRequestCRUD(t *testing.T) { 86 + s := testStore(t) 87 + ctx := context.Background() 88 + 89 + info := oauth.AuthRequestData{ 90 + State: "state-abc", 91 + AuthServerURL: "https://auth.example.com", 92 + PKCEVerifier: "verifier123", 93 + } 94 + 95 + if err := s.SaveAuthRequestInfo(ctx, info); err != nil { 96 + t.Fatal(err) 97 + } 98 + 99 + got, err := s.GetAuthRequestInfo(ctx, "state-abc") 100 + if err != nil { 101 + t.Fatal(err) 102 + } 103 + if got.PKCEVerifier != "verifier123" { 104 + t.Fatalf("got verifier %q", got.PKCEVerifier) 105 + } 106 + 107 + _, err = s.GetAuthRequestInfo(ctx, "no-such") 108 + if err == nil { 109 + t.Fatal("expected error for missing auth request") 110 + } 111 + 112 + if err := s.DeleteAuthRequestInfo(ctx, "state-abc"); err != nil { 113 + t.Fatal(err) 114 + } 115 + _, err = s.GetAuthRequestInfo(ctx, "state-abc") 116 + if err == nil { 117 + t.Fatal("expected error after delete") 118 + } 119 + }
+116
tracing/tracing.go
··· 1 + // Package tracing provides OpenTelemetry span helpers for AT Protocol operations. 2 + // Call Init to set up a tracer provider, or bring your own and the span helpers 3 + // will use whatever global provider is registered. 4 + package tracing 5 + 6 + import ( 7 + "context" 8 + "os" 9 + 10 + "go.opentelemetry.io/otel" 11 + "go.opentelemetry.io/otel/attribute" 12 + "go.opentelemetry.io/otel/codes" 13 + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" 14 + "go.opentelemetry.io/otel/propagation" 15 + "go.opentelemetry.io/otel/sdk/resource" 16 + sdktrace "go.opentelemetry.io/otel/sdk/trace" 17 + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" 18 + "go.opentelemetry.io/otel/trace" 19 + ) 20 + 21 + func tracer() trace.Tracer { 22 + return otel.Tracer("tangled.org/pdewey.com/atp") 23 + } 24 + 25 + // Init creates and registers a tracer provider with an OTLP HTTP exporter. 26 + // It reads OTEL_EXPORTER_OTLP_ENDPOINT (default: localhost:4318). 27 + // The serviceName appears in your tracing backend (e.g. "arabica", "solanum"). 28 + // Returns the provider so the caller can defer provider.Shutdown(ctx). 29 + func Init(ctx context.Context, serviceName string) (*sdktrace.TracerProvider, error) { 30 + endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") 31 + if endpoint == "" { 32 + endpoint = "localhost:4318" 33 + } 34 + 35 + exp, err := otlptracehttp.New(ctx, 36 + otlptracehttp.WithEndpoint(endpoint), 37 + otlptracehttp.WithInsecure(), 38 + ) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + tp := sdktrace.NewTracerProvider( 44 + sdktrace.WithBatcher(exp), 45 + sdktrace.WithResource(resource.NewWithAttributes( 46 + semconv.SchemaURL, 47 + semconv.ServiceNameKey.String(serviceName), 48 + )), 49 + ) 50 + 51 + otel.SetTracerProvider(tp) 52 + otel.SetTextMapPropagator(propagation.TraceContext{}) 53 + 54 + return tp, nil 55 + } 56 + 57 + // BoltSpan starts a span for a BoltDB operation. 58 + // Returns a no-op span when there is no parent span in ctx (background goroutines). 59 + func BoltSpan(ctx context.Context, op, bucket string) (context.Context, trace.Span) { 60 + if !trace.SpanFromContext(ctx).SpanContext().IsValid() { 61 + return ctx, trace.SpanFromContext(ctx) 62 + } 63 + return tracer().Start(ctx, "bolt."+op, 64 + trace.WithAttributes( 65 + attribute.String("db.system", "boltdb"), 66 + attribute.String("db.operation", op), 67 + attribute.String("bolt.bucket", bucket), 68 + ), 69 + ) 70 + } 71 + 72 + // SqliteSpan starts a span for a SQLite operation. 73 + // Use for complex multi-statement operations (transactions, batches). 74 + // For simple queries, prefer using QueryContext/ExecContext directly with otelsql. 75 + // Returns a no-op span when there is no parent span in ctx. 76 + func SqliteSpan(ctx context.Context, op, table string) (context.Context, trace.Span) { 77 + if !trace.SpanFromContext(ctx).SpanContext().IsValid() { 78 + return ctx, trace.SpanFromContext(ctx) 79 + } 80 + return tracer().Start(ctx, "sqlite."+op, 81 + trace.WithAttributes( 82 + attribute.String("db.system", "sqlite"), 83 + attribute.String("db.operation", op), 84 + attribute.String("db.sql.table", table), 85 + ), 86 + ) 87 + } 88 + 89 + // PdsSpan starts a span for a PDS XRPC operation. 90 + // Returns a no-op span when there is no parent span in ctx. 91 + func PdsSpan(ctx context.Context, method, collection, did string) (context.Context, trace.Span) { 92 + if !trace.SpanFromContext(ctx).SpanContext().IsValid() { 93 + return ctx, trace.SpanFromContext(ctx) 94 + } 95 + return tracer().Start(ctx, "pds."+method, 96 + trace.WithAttributes( 97 + attribute.String("pds.method", method), 98 + attribute.String("pds.collection", collection), 99 + attribute.String("pds.did", did), 100 + ), 101 + ) 102 + } 103 + 104 + // HandlerSpan starts a span for a logical operation within a handler. 105 + func HandlerSpan(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { 106 + return tracer().Start(ctx, name, trace.WithAttributes(attrs...)) 107 + } 108 + 109 + // EndWithError records an error on a span and sets its status to Error. 110 + // No-op if err is nil. 111 + func EndWithError(span trace.Span, err error) { 112 + if err != nil { 113 + span.RecordError(err) 114 + span.SetStatus(codes.Error, err.Error()) 115 + } 116 + }
+39
tracing/tracing_test.go
··· 1 + package tracing 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "go.opentelemetry.io/otel/trace" 8 + ) 9 + 10 + func TestBoltSpan_NoParent(t *testing.T) { 11 + ctx := context.Background() 12 + // Without a parent span, should return a no-op span 13 + _, span := BoltSpan(ctx, "GetSession", "oauth_sessions") 14 + if span.SpanContext().IsValid() { 15 + t.Fatal("expected no-op span without parent") 16 + } 17 + } 18 + 19 + func TestSqliteSpan_NoParent(t *testing.T) { 20 + ctx := context.Background() 21 + _, span := SqliteSpan(ctx, "query", "records") 22 + if span.SpanContext().IsValid() { 23 + t.Fatal("expected no-op span without parent") 24 + } 25 + } 26 + 27 + func TestPdsSpan_NoParent(t *testing.T) { 28 + ctx := context.Background() 29 + _, span := PdsSpan(ctx, "createRecord", "x.y.z", "did:plc:test") 30 + if span.SpanContext().IsValid() { 31 + t.Fatal("expected no-op span without parent") 32 + } 33 + } 34 + 35 + func TestEndWithError_Nil(t *testing.T) { 36 + // Should not panic on nil error 37 + span := trace.SpanFromContext(context.Background()) 38 + EndWithError(span, nil) 39 + }
+27
uri.go
··· 1 + package atp 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + func BuildATURI(did, collection, rkey string) string { 10 + return fmt.Sprintf("at://%s/%s/%s", did, collection, rkey) 11 + } 12 + 13 + func ParseATURI(uri string) (did, collection, rkey string, err error) { 14 + atURI, err := syntax.ParseATURI(uri) 15 + if err != nil { 16 + return "", "", "", fmt.Errorf("invalid AT-URI %q: %w", uri, err) 17 + } 18 + return atURI.Authority().String(), atURI.Collection().String(), atURI.RecordKey().String(), nil 19 + } 20 + 21 + func RKeyFromURI(uri string) string { 22 + _, _, rkey, err := ParseATURI(uri) 23 + if err != nil { 24 + return "" 25 + } 26 + return rkey 27 + }
+42
uri_test.go
··· 1 + package atp 2 + 3 + import "testing" 4 + 5 + func TestBuildATURI(t *testing.T) { 6 + got := BuildATURI("did:plc:abc", "app.bsky.feed.post", "3jxy") 7 + want := "at://did:plc:abc/app.bsky.feed.post/3jxy" 8 + if got != want { 9 + t.Fatalf("got %q, want %q", got, want) 10 + } 11 + } 12 + 13 + func TestParseATURI(t *testing.T) { 14 + did, collection, rkey, err := ParseATURI("at://did:plc:abc/app.bsky.feed.post/3jxy") 15 + if err != nil { 16 + t.Fatal(err) 17 + } 18 + if did != "did:plc:abc" || collection != "app.bsky.feed.post" || rkey != "3jxy" { 19 + t.Fatalf("got did=%q collection=%q rkey=%q", did, collection, rkey) 20 + } 21 + } 22 + 23 + func TestParseATURI_Invalid(t *testing.T) { 24 + _, _, _, err := ParseATURI("not-a-uri") 25 + if err == nil { 26 + t.Fatal("expected error for invalid URI") 27 + } 28 + } 29 + 30 + func TestRKeyFromURI(t *testing.T) { 31 + got := RKeyFromURI("at://did:plc:abc/app.bsky.feed.post/3jxy") 32 + if got != "3jxy" { 33 + t.Fatalf("got %q, want %q", got, "3jxy") 34 + } 35 + } 36 + 37 + func TestRKeyFromURI_Invalid(t *testing.T) { 38 + got := RKeyFromURI("garbage") 39 + if got != "" { 40 + t.Fatalf("expected empty string for invalid URI, got %q", got) 41 + } 42 + }