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