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}