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