A community based topic aggregation platform built on atproto
1package communities
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "regexp"
12 "strings"
13 "time"
14)
15
16// Community handle validation regex (DNS-valid handle: name.communities.instance.com)
17// Matches standard DNS hostname format (RFC 1035)
18var communityHandleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
19
20type communityService struct {
21 repo Repository
22 provisioner *PDSAccountProvisioner
23 pdsURL string
24 instanceDID string
25 instanceDomain string
26 pdsAccessToken string
27}
28
29// NewCommunityService creates a new community service
30func NewCommunityService(repo Repository, pdsURL, instanceDID, instanceDomain string, provisioner *PDSAccountProvisioner) Service {
31 return &communityService{
32 repo: repo,
33 pdsURL: pdsURL,
34 instanceDID: instanceDID,
35 instanceDomain: instanceDomain,
36 provisioner: provisioner,
37 }
38}
39
40// SetPDSAccessToken sets the PDS access token for authentication
41// This should be called after creating a session for the Coves instance DID on the PDS
42func (s *communityService) SetPDSAccessToken(token string) {
43 s.pdsAccessToken = token
44}
45
46// CreateCommunity creates a new community via write-forward to PDS
47// V2 Flow:
48// 1. Service creates PDS account for community (PDS generates signing keypair)
49// 2. Service writes community profile to COMMUNITY's own repository
50// 3. Firehose emits event
51// 4. Consumer indexes to AppView DB
52//
53// V2 Architecture:
54// - Community owns its own repository (at://community_did/social.coves.community.profile/self)
55// - PDS manages the signing keypair (we never see it)
56// - We store PDS credentials to act on behalf of the community
57// - Community can migrate to other instances (future V2.1 with rotation keys)
58func (s *communityService) CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error) {
59 // Apply defaults before validation
60 if req.Visibility == "" {
61 req.Visibility = "public"
62 }
63
64 // Validate request
65 if err := s.validateCreateRequest(req); err != nil {
66 return nil, err
67 }
68
69 // V2: Provision a real PDS account for this community
70 // This calls com.atproto.server.createAccount internally
71 // The PDS will:
72 // 1. Generate a signing keypair (stored in PDS, we never see it)
73 // 2. Create a DID (did:plc:xxx)
74 // 3. Return credentials (DID, tokens)
75 pdsAccount, err := s.provisioner.ProvisionCommunityAccount(ctx, req.Name)
76 if err != nil {
77 return nil, fmt.Errorf("failed to provision PDS account for community: %w", err)
78 }
79
80 // Validate the atProto handle
81 if validateErr := s.ValidateHandle(pdsAccount.Handle); validateErr != nil {
82 return nil, fmt.Errorf("generated atProto handle is invalid: %w", validateErr)
83 }
84
85 // Build community profile record
86 profile := map[string]interface{}{
87 "$type": "social.coves.community.profile",
88 "handle": pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social)
89 "name": req.Name, // Short name for !mentions (e.g., "gaming")
90 "visibility": req.Visibility,
91 "hostedBy": s.instanceDID, // V2: Instance hosts, community owns
92 "createdBy": req.CreatedByDID,
93 "createdAt": time.Now().Format(time.RFC3339),
94 "federation": map[string]interface{}{
95 "allowExternalDiscovery": req.AllowExternalDiscovery,
96 },
97 }
98
99 // Add optional fields
100 if req.DisplayName != "" {
101 profile["displayName"] = req.DisplayName
102 }
103 if req.Description != "" {
104 profile["description"] = req.Description
105 }
106 if len(req.Rules) > 0 {
107 profile["rules"] = req.Rules
108 }
109 if len(req.Categories) > 0 {
110 profile["categories"] = req.Categories
111 }
112 if req.Language != "" {
113 profile["language"] = req.Language
114 }
115
116 // Initialize counts
117 profile["memberCount"] = 0
118 profile["subscriberCount"] = 0
119
120 // TODO: Handle avatar and banner blobs
121 // For now, we'll skip blob uploads. This would require:
122 // 1. Upload blob to PDS via com.atproto.repo.uploadBlob
123 // 2. Get blob ref (CID)
124 // 3. Add to profile record
125
126 // V2: Write to COMMUNITY's own repository (not instance repo!)
127 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self
128 // Authenticate using community's access token
129 recordURI, recordCID, err := s.createRecordOnPDSAs(
130 ctx,
131 pdsAccount.DID, // repo = community's DID (community owns its repo!)
132 "social.coves.community.profile",
133 "self", // canonical rkey for profile
134 profile,
135 pdsAccount.AccessToken, // authenticate as the community
136 )
137 if err != nil {
138 return nil, fmt.Errorf("failed to create community profile record: %w", err)
139 }
140
141 // Build Community object with PDS credentials AND cryptographic keys
142 community := &Community{
143 DID: pdsAccount.DID, // Community's DID (owns the repo!)
144 Handle: pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social)
145 Name: req.Name,
146 DisplayName: req.DisplayName,
147 Description: req.Description,
148 OwnerDID: pdsAccount.DID, // V2: Community owns itself
149 CreatedByDID: req.CreatedByDID,
150 HostedByDID: req.HostedByDID,
151 PDSEmail: pdsAccount.Email,
152 PDSPassword: pdsAccount.Password,
153 PDSAccessToken: pdsAccount.AccessToken,
154 PDSRefreshToken: pdsAccount.RefreshToken,
155 PDSURL: pdsAccount.PDSURL,
156 Visibility: req.Visibility,
157 AllowExternalDiscovery: req.AllowExternalDiscovery,
158 MemberCount: 0,
159 SubscriberCount: 0,
160 CreatedAt: time.Now(),
161 UpdatedAt: time.Now(),
162 RecordURI: recordURI,
163 RecordCID: recordCID,
164 // V2: Cryptographic keys for portability (will be encrypted by repository)
165 RotationKeyPEM: pdsAccount.RotationKeyPEM, // CRITICAL: Enables DID migration
166 SigningKeyPEM: pdsAccount.SigningKeyPEM, // For atproto operations
167 }
168
169 // CRITICAL: Persist PDS credentials immediately to database
170 // The Jetstream consumer will eventually index the community profile from the firehose,
171 // but it won't have the PDS credentials. We must store them now so we can:
172 // 1. Update the community profile later (using its own credentials)
173 // 2. Re-authenticate if access tokens expire
174 _, err = s.repo.Create(ctx, community)
175 if err != nil {
176 return nil, fmt.Errorf("failed to persist community with credentials: %w", err)
177 }
178
179 return community, nil
180}
181
182// GetCommunity retrieves a community from AppView DB
183// identifier can be either a DID or handle
184func (s *communityService) GetCommunity(ctx context.Context, identifier string) (*Community, error) {
185 if identifier == "" {
186 return nil, ErrInvalidInput
187 }
188
189 // Determine if identifier is DID or handle
190 if strings.HasPrefix(identifier, "did:") {
191 return s.repo.GetByDID(ctx, identifier)
192 }
193
194 if strings.HasPrefix(identifier, "!") {
195 return s.repo.GetByHandle(ctx, identifier)
196 }
197
198 return nil, NewValidationError("identifier", "must be a DID or handle")
199}
200
201// UpdateCommunity updates a community via write-forward to PDS
202func (s *communityService) UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error) {
203 if req.CommunityDID == "" {
204 return nil, NewValidationError("communityDid", "required")
205 }
206
207 if req.UpdatedByDID == "" {
208 return nil, NewValidationError("updatedByDid", "required")
209 }
210
211 // Get existing community
212 existing, err := s.repo.GetByDID(ctx, req.CommunityDID)
213 if err != nil {
214 return nil, err
215 }
216
217 // Authorization: verify user is the creator
218 // TODO(Communities-Auth): Add moderator check when moderation system is implemented
219 if existing.CreatedByDID != req.UpdatedByDID {
220 return nil, ErrUnauthorized
221 }
222
223 // Build updated profile record (start with existing)
224 profile := map[string]interface{}{
225 "$type": "social.coves.community.profile",
226 "handle": existing.Handle,
227 "name": existing.Name,
228 "owner": existing.OwnerDID,
229 "createdBy": existing.CreatedByDID,
230 "hostedBy": existing.HostedByDID,
231 "createdAt": existing.CreatedAt.Format(time.RFC3339),
232 }
233
234 // Apply updates
235 if req.DisplayName != nil {
236 profile["displayName"] = *req.DisplayName
237 } else {
238 profile["displayName"] = existing.DisplayName
239 }
240
241 if req.Description != nil {
242 profile["description"] = *req.Description
243 } else {
244 profile["description"] = existing.Description
245 }
246
247 if req.Visibility != nil {
248 profile["visibility"] = *req.Visibility
249 } else {
250 profile["visibility"] = existing.Visibility
251 }
252
253 if req.AllowExternalDiscovery != nil {
254 profile["federation"] = map[string]interface{}{
255 "allowExternalDiscovery": *req.AllowExternalDiscovery,
256 }
257 } else {
258 profile["federation"] = map[string]interface{}{
259 "allowExternalDiscovery": existing.AllowExternalDiscovery,
260 }
261 }
262
263 // Preserve moderation settings (even if empty)
264 // These fields are optional but should not be erased on update
265 if req.ModerationType != nil {
266 profile["moderationType"] = *req.ModerationType
267 } else if existing.ModerationType != "" {
268 profile["moderationType"] = existing.ModerationType
269 }
270
271 if len(req.ContentWarnings) > 0 {
272 profile["contentWarnings"] = req.ContentWarnings
273 } else if len(existing.ContentWarnings) > 0 {
274 profile["contentWarnings"] = existing.ContentWarnings
275 }
276
277 // Preserve counts
278 profile["memberCount"] = existing.MemberCount
279 profile["subscriberCount"] = existing.SubscriberCount
280
281 // V2: Community profiles always use "self" as rkey
282 // (No need to extract from URI - it's always "self" for V2 communities)
283
284 // V2 CRITICAL FIX: Write-forward using COMMUNITY's own DID and credentials
285 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self
286 // Authenticate as the community (not as instance!)
287 if existing.PDSAccessToken == "" {
288 return nil, fmt.Errorf("community %s missing PDS credentials - cannot update", existing.DID)
289 }
290
291 recordURI, recordCID, err := s.putRecordOnPDSAs(
292 ctx,
293 existing.DID, // repo = community's own DID (V2!)
294 "social.coves.community.profile",
295 "self", // V2: always "self"
296 profile,
297 existing.PDSAccessToken, // authenticate as the community
298 )
299 if err != nil {
300 return nil, fmt.Errorf("failed to update community on PDS: %w", err)
301 }
302
303 // Return updated community representation
304 // Actual AppView DB update happens via Jetstream consumer
305 updated := *existing
306 if req.DisplayName != nil {
307 updated.DisplayName = *req.DisplayName
308 }
309 if req.Description != nil {
310 updated.Description = *req.Description
311 }
312 if req.Visibility != nil {
313 updated.Visibility = *req.Visibility
314 }
315 if req.AllowExternalDiscovery != nil {
316 updated.AllowExternalDiscovery = *req.AllowExternalDiscovery
317 }
318 if req.ModerationType != nil {
319 updated.ModerationType = *req.ModerationType
320 }
321 if len(req.ContentWarnings) > 0 {
322 updated.ContentWarnings = req.ContentWarnings
323 }
324 updated.RecordURI = recordURI
325 updated.RecordCID = recordCID
326 updated.UpdatedAt = time.Now()
327
328 return &updated, nil
329}
330
331// ListCommunities queries AppView DB for communities with filters
332func (s *communityService) ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) {
333 // Set defaults
334 if req.Limit <= 0 || req.Limit > 100 {
335 req.Limit = 50
336 }
337
338 return s.repo.List(ctx, req)
339}
340
341// SearchCommunities performs fuzzy search in AppView DB
342func (s *communityService) SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error) {
343 if req.Query == "" {
344 return nil, 0, NewValidationError("query", "search query is required")
345 }
346
347 // Set defaults
348 if req.Limit <= 0 || req.Limit > 100 {
349 req.Limit = 50
350 }
351
352 return s.repo.Search(ctx, req)
353}
354
355// SubscribeToCommunity creates a subscription via write-forward to PDS
356func (s *communityService) SubscribeToCommunity(ctx context.Context, userDID, communityIdentifier string) (*Subscription, error) {
357 if userDID == "" {
358 return nil, NewValidationError("userDid", "required")
359 }
360
361 // Resolve community identifier to DID
362 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
363 if err != nil {
364 return nil, err
365 }
366
367 // Verify community exists
368 community, err := s.repo.GetByDID(ctx, communityDID)
369 if err != nil {
370 return nil, err
371 }
372
373 // Check visibility - can't subscribe to private communities without invitation (TODO)
374 if community.Visibility == "private" {
375 return nil, ErrUnauthorized
376 }
377
378 // Build subscription record
379 subRecord := map[string]interface{}{
380 "$type": "social.coves.community.subscribe",
381 "community": communityDID,
382 }
383
384 // Write-forward: create subscription record in user's repo
385 recordURI, recordCID, err := s.createRecordOnPDS(ctx, userDID, "social.coves.community.subscribe", "", subRecord)
386 if err != nil {
387 return nil, fmt.Errorf("failed to create subscription on PDS: %w", err)
388 }
389
390 // Return subscription representation
391 subscription := &Subscription{
392 UserDID: userDID,
393 CommunityDID: communityDID,
394 SubscribedAt: time.Now(),
395 RecordURI: recordURI,
396 RecordCID: recordCID,
397 }
398
399 return subscription, nil
400}
401
402// UnsubscribeFromCommunity removes a subscription via PDS delete
403func (s *communityService) UnsubscribeFromCommunity(ctx context.Context, userDID, communityIdentifier string) error {
404 if userDID == "" {
405 return NewValidationError("userDid", "required")
406 }
407
408 // Resolve community identifier
409 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
410 if err != nil {
411 return err
412 }
413
414 // Get the subscription from AppView to find the record key
415 subscription, err := s.repo.GetSubscription(ctx, userDID, communityDID)
416 if err != nil {
417 return err
418 }
419
420 // Extract rkey from record URI (at://did/collection/rkey)
421 rkey := extractRKeyFromURI(subscription.RecordURI)
422 if rkey == "" {
423 return fmt.Errorf("invalid subscription record URI")
424 }
425
426 // Write-forward: delete record from PDS
427 if err := s.deleteRecordOnPDS(ctx, userDID, "social.coves.community.subscribe", rkey); err != nil {
428 return fmt.Errorf("failed to delete subscription on PDS: %w", err)
429 }
430
431 return nil
432}
433
434// GetUserSubscriptions queries AppView DB for user's subscriptions
435func (s *communityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error) {
436 if limit <= 0 || limit > 100 {
437 limit = 50
438 }
439
440 return s.repo.ListSubscriptions(ctx, userDID, limit, offset)
441}
442
443// GetCommunitySubscribers queries AppView DB for community subscribers
444func (s *communityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Subscription, error) {
445 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
446 if err != nil {
447 return nil, err
448 }
449
450 if limit <= 0 || limit > 100 {
451 limit = 50
452 }
453
454 return s.repo.ListSubscribers(ctx, communityDID, limit, offset)
455}
456
457// GetMembership retrieves membership info from AppView DB
458func (s *communityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*Membership, error) {
459 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
460 if err != nil {
461 return nil, err
462 }
463
464 return s.repo.GetMembership(ctx, userDID, communityDID)
465}
466
467// ListCommunityMembers queries AppView DB for members
468func (s *communityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Membership, error) {
469 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
470 if err != nil {
471 return nil, err
472 }
473
474 if limit <= 0 || limit > 100 {
475 limit = 50
476 }
477
478 return s.repo.ListMembers(ctx, communityDID, limit, offset)
479}
480
481// ValidateHandle checks if a community handle is valid
482func (s *communityService) ValidateHandle(handle string) error {
483 if handle == "" {
484 return NewValidationError("handle", "required")
485 }
486
487 if !communityHandleRegex.MatchString(handle) {
488 return ErrInvalidHandle
489 }
490
491 return nil
492}
493
494// ResolveCommunityIdentifier converts a handle or DID to a DID
495func (s *communityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
496 if identifier == "" {
497 return "", ErrInvalidInput
498 }
499
500 // If it's already a DID, return it
501 if strings.HasPrefix(identifier, "did:") {
502 return identifier, nil
503 }
504
505 // If it's a handle, look it up in AppView DB
506 if strings.HasPrefix(identifier, "!") {
507 community, err := s.repo.GetByHandle(ctx, identifier)
508 if err != nil {
509 return "", err
510 }
511 return community.DID, nil
512 }
513
514 return "", NewValidationError("identifier", "must be a DID or handle")
515}
516
517// Validation helpers
518
519func (s *communityService) validateCreateRequest(req CreateCommunityRequest) error {
520 if req.Name == "" {
521 return NewValidationError("name", "required")
522 }
523
524 // DNS label limit: 63 characters per label
525 // Community handle format: {name}.communities.{instanceDomain}
526 // The first label is just req.Name, so it must be <= 63 chars
527 if len(req.Name) > 63 {
528 return NewValidationError("name", "must be 63 characters or less (DNS label limit)")
529 }
530
531 // Name can only contain alphanumeric and hyphens
532 // Must start and end with alphanumeric (not hyphen)
533 nameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
534 if !nameRegex.MatchString(req.Name) {
535 return NewValidationError("name", "must contain only alphanumeric characters and hyphens")
536 }
537
538 if req.Description != "" && len(req.Description) > 3000 {
539 return NewValidationError("description", "must be 3000 characters or less")
540 }
541
542 // Visibility should already be set with default in CreateCommunity
543 if req.Visibility != "public" && req.Visibility != "unlisted" && req.Visibility != "private" {
544 return ErrInvalidVisibility
545 }
546
547 if req.CreatedByDID == "" {
548 return NewValidationError("createdByDid", "required")
549 }
550
551 if req.HostedByDID == "" {
552 return NewValidationError("hostedByDid", "required")
553 }
554
555 return nil
556}
557
558// PDS write-forward helpers
559
560func (s *communityService) createRecordOnPDS(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}) (string, string, error) {
561 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/"))
562
563 payload := map[string]interface{}{
564 "repo": repoDID,
565 "collection": collection,
566 "record": record,
567 }
568
569 if rkey != "" {
570 payload["rkey"] = rkey
571 }
572
573 return s.callPDS(ctx, "POST", endpoint, payload)
574}
575
576// createRecordOnPDSAs creates a record with a specific access token (for V2 community auth)
577func (s *communityService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) {
578 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/"))
579
580 payload := map[string]interface{}{
581 "repo": repoDID,
582 "collection": collection,
583 "record": record,
584 }
585
586 if rkey != "" {
587 payload["rkey"] = rkey
588 }
589
590 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
591}
592
593// putRecordOnPDSAs updates a record with a specific access token (for V2 community auth)
594func (s *communityService) putRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) {
595 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", strings.TrimSuffix(s.pdsURL, "/"))
596
597 payload := map[string]interface{}{
598 "repo": repoDID,
599 "collection": collection,
600 "rkey": rkey,
601 "record": record,
602 }
603
604 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
605}
606
607func (s *communityService) deleteRecordOnPDS(ctx context.Context, repoDID, collection, rkey string) error {
608 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))
609
610 payload := map[string]interface{}{
611 "repo": repoDID,
612 "collection": collection,
613 "rkey": rkey,
614 }
615
616 _, _, err := s.callPDS(ctx, "POST", endpoint, payload)
617 return err
618}
619
620func (s *communityService) callPDS(ctx context.Context, method, endpoint string, payload map[string]interface{}) (string, string, error) {
621 // Use instance's access token
622 return s.callPDSWithAuth(ctx, method, endpoint, payload, s.pdsAccessToken)
623}
624
625// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication)
626func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) {
627 jsonData, err := json.Marshal(payload)
628 if err != nil {
629 return "", "", fmt.Errorf("failed to marshal payload: %w", err)
630 }
631
632 req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewBuffer(jsonData))
633 if err != nil {
634 return "", "", fmt.Errorf("failed to create request: %w", err)
635 }
636 req.Header.Set("Content-Type", "application/json")
637
638 // Add authentication with provided access token
639 if accessToken != "" {
640 req.Header.Set("Authorization", "Bearer "+accessToken)
641 }
642
643 // Dynamic timeout based on operation type
644 // Write operations (createAccount, createRecord, putRecord) are slower due to:
645 // - Keypair generation
646 // - DID PLC registration
647 // - Database writes on PDS
648 timeout := 10 * time.Second // Default for read operations
649 if strings.Contains(endpoint, "createAccount") ||
650 strings.Contains(endpoint, "createRecord") ||
651 strings.Contains(endpoint, "putRecord") {
652 timeout = 30 * time.Second // Extended timeout for write operations
653 }
654
655 client := &http.Client{Timeout: timeout}
656 resp, err := client.Do(req)
657 if err != nil {
658 return "", "", fmt.Errorf("failed to call PDS: %w", err)
659 }
660 defer func() {
661 if closeErr := resp.Body.Close(); closeErr != nil {
662 log.Printf("Failed to close response body: %v", closeErr)
663 }
664 }()
665
666 body, err := io.ReadAll(resp.Body)
667 if err != nil {
668 return "", "", fmt.Errorf("failed to read response: %w", err)
669 }
670
671 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
672 return "", "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body))
673 }
674
675 // Parse response to extract URI and CID
676 var result struct {
677 URI string `json:"uri"`
678 CID string `json:"cid"`
679 }
680 if err := json.Unmarshal(body, &result); err != nil {
681 // For delete operations, there might not be a response body
682 if method == "POST" && strings.Contains(endpoint, "deleteRecord") {
683 return "", "", nil
684 }
685 return "", "", fmt.Errorf("failed to parse PDS response: %w", err)
686 }
687
688 return result.URI, result.CID, nil
689}
690
691// Helper functions
692
693func extractRKeyFromURI(uri string) string {
694 // at://did/collection/rkey -> rkey
695 parts := strings.Split(uri, "/")
696 if len(parts) >= 4 {
697 return parts[len(parts)-1]
698 }
699 return ""
700}