A community based topic aggregation platform built on atproto
1package communities 2 3import ( 4 "Coves/internal/atproto/utils" 5 "bytes" 6 "context" 7 "encoding/json" 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "regexp" 13 "strings" 14 "time" 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 provisioner *PDSAccountProvisioner 24 pdsURL string 25 instanceDID string 26 instanceDomain string 27 pdsAccessToken string 28} 29 30// NewCommunityService creates a new community service 31func NewCommunityService(repo Repository, pdsURL, instanceDID, instanceDomain string, provisioner *PDSAccountProvisioner) Service { 32 // SECURITY: Basic validation that did:web domain matches configured instanceDomain 33 // This catches honest configuration mistakes but NOT malicious code modifications 34 // Full verification (Phase 2) requires fetching DID document from domain 35 // See: docs/PRD_BACKLOG.md - "did:web Domain Verification" 36 if strings.HasPrefix(instanceDID, "did:web:") { 37 didDomain := strings.TrimPrefix(instanceDID, "did:web:") 38 if didDomain != instanceDomain { 39 log.Printf("⚠️ SECURITY WARNING: Instance DID domain (%s) doesn't match configured domain (%s)", 40 didDomain, instanceDomain) 41 log.Printf(" This could indicate a configuration error or potential domain spoofing attempt") 42 log.Printf(" Communities will be hosted by: %s", instanceDID) 43 } 44 } 45 46 return &communityService{ 47 repo: repo, 48 pdsURL: pdsURL, 49 instanceDID: instanceDID, 50 instanceDomain: instanceDomain, 51 provisioner: provisioner, 52 } 53} 54 55// SetPDSAccessToken sets the PDS access token for authentication 56// This should be called after creating a session for the Coves instance DID on the PDS 57func (s *communityService) SetPDSAccessToken(token string) { 58 s.pdsAccessToken = token 59} 60 61// CreateCommunity creates a new community via write-forward to PDS 62// V2 Flow: 63// 1. Service creates PDS account for community (PDS generates signing keypair) 64// 2. Service writes community profile to COMMUNITY's own repository 65// 3. Firehose emits event 66// 4. Consumer indexes to AppView DB 67// 68// V2 Architecture: 69// - Community owns its own repository (at://community_did/social.coves.community.profile/self) 70// - PDS manages the signing keypair (we never see it) 71// - We store PDS credentials to act on behalf of the community 72// - Community can migrate to other instances (future V2.1 with rotation keys) 73func (s *communityService) CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error) { 74 // Apply defaults before validation 75 if req.Visibility == "" { 76 req.Visibility = "public" 77 } 78 79 // SECURITY: Auto-populate hostedByDID from instance configuration 80 // Clients MUST NOT provide this field - it's derived from the instance receiving the request 81 // This prevents malicious instances from claiming to host communities for domains they don't own 82 req.HostedByDID = s.instanceDID 83 84 // Validate request 85 if err := s.validateCreateRequest(req); err != nil { 86 return nil, err 87 } 88 89 // V2: Provision a real PDS account for this community 90 // This calls com.atproto.server.createAccount internally 91 // The PDS will: 92 // 1. Generate a signing keypair (stored in PDS, we never see it) 93 // 2. Create a DID (did:plc:xxx) 94 // 3. Return credentials (DID, tokens) 95 pdsAccount, err := s.provisioner.ProvisionCommunityAccount(ctx, req.Name) 96 if err != nil { 97 return nil, fmt.Errorf("failed to provision PDS account for community: %w", err) 98 } 99 100 // Validate the atProto handle 101 if validateErr := s.ValidateHandle(pdsAccount.Handle); validateErr != nil { 102 return nil, fmt.Errorf("generated atProto handle is invalid: %w", validateErr) 103 } 104 105 // Build community profile record 106 profile := map[string]interface{}{ 107 "$type": "social.coves.community.profile", 108 "handle": pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social) 109 "name": req.Name, // Short name for !mentions (e.g., "gaming") 110 "visibility": req.Visibility, 111 "hostedBy": s.instanceDID, // V2: Instance hosts, community owns 112 "createdBy": req.CreatedByDID, 113 "createdAt": time.Now().Format(time.RFC3339), 114 "federation": map[string]interface{}{ 115 "allowExternalDiscovery": req.AllowExternalDiscovery, 116 }, 117 } 118 119 // Add optional fields 120 if req.DisplayName != "" { 121 profile["displayName"] = req.DisplayName 122 } 123 if req.Description != "" { 124 profile["description"] = req.Description 125 } 126 if len(req.Rules) > 0 { 127 profile["rules"] = req.Rules 128 } 129 if len(req.Categories) > 0 { 130 profile["categories"] = req.Categories 131 } 132 if req.Language != "" { 133 profile["language"] = req.Language 134 } 135 136 // Initialize counts 137 profile["memberCount"] = 0 138 profile["subscriberCount"] = 0 139 140 // TODO: Handle avatar and banner blobs 141 // For now, we'll skip blob uploads. This would require: 142 // 1. Upload blob to PDS via com.atproto.repo.uploadBlob 143 // 2. Get blob ref (CID) 144 // 3. Add to profile record 145 146 // V2: Write to COMMUNITY's own repository (not instance repo!) 147 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self 148 // Authenticate using community's access token 149 recordURI, recordCID, err := s.createRecordOnPDSAs( 150 ctx, 151 pdsAccount.DID, // repo = community's DID (community owns its repo!) 152 "social.coves.community.profile", 153 "self", // canonical rkey for profile 154 profile, 155 pdsAccount.AccessToken, // authenticate as the community 156 ) 157 if err != nil { 158 return nil, fmt.Errorf("failed to create community profile record: %w", err) 159 } 160 161 // Build Community object with PDS credentials AND cryptographic keys 162 community := &Community{ 163 DID: pdsAccount.DID, // Community's DID (owns the repo!) 164 Handle: pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social) 165 Name: req.Name, 166 DisplayName: req.DisplayName, 167 Description: req.Description, 168 OwnerDID: pdsAccount.DID, // V2: Community owns itself 169 CreatedByDID: req.CreatedByDID, 170 HostedByDID: req.HostedByDID, 171 PDSEmail: pdsAccount.Email, 172 PDSPassword: pdsAccount.Password, 173 PDSAccessToken: pdsAccount.AccessToken, 174 PDSRefreshToken: pdsAccount.RefreshToken, 175 PDSURL: pdsAccount.PDSURL, 176 Visibility: req.Visibility, 177 AllowExternalDiscovery: req.AllowExternalDiscovery, 178 MemberCount: 0, 179 SubscriberCount: 0, 180 CreatedAt: time.Now(), 181 UpdatedAt: time.Now(), 182 RecordURI: recordURI, 183 RecordCID: recordCID, 184 // V2: Cryptographic keys for portability (will be encrypted by repository) 185 RotationKeyPEM: pdsAccount.RotationKeyPEM, // CRITICAL: Enables DID migration 186 SigningKeyPEM: pdsAccount.SigningKeyPEM, // For atproto operations 187 } 188 189 // CRITICAL: Persist PDS credentials immediately to database 190 // The Jetstream consumer will eventually index the community profile from the firehose, 191 // but it won't have the PDS credentials. We must store them now so we can: 192 // 1. Update the community profile later (using its own credentials) 193 // 2. Re-authenticate if access tokens expire 194 _, err = s.repo.Create(ctx, community) 195 if err != nil { 196 return nil, fmt.Errorf("failed to persist community with credentials: %w", err) 197 } 198 199 return community, nil 200} 201 202// GetCommunity retrieves a community from AppView DB 203// identifier can be either a DID or handle 204func (s *communityService) GetCommunity(ctx context.Context, identifier string) (*Community, error) { 205 if identifier == "" { 206 return nil, ErrInvalidInput 207 } 208 209 // Determine if identifier is DID or handle 210 if strings.HasPrefix(identifier, "did:") { 211 return s.repo.GetByDID(ctx, identifier) 212 } 213 214 if strings.HasPrefix(identifier, "!") { 215 return s.repo.GetByHandle(ctx, identifier) 216 } 217 218 return nil, NewValidationError("identifier", "must be a DID or handle") 219} 220 221// UpdateCommunity updates a community via write-forward to PDS 222func (s *communityService) UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error) { 223 if req.CommunityDID == "" { 224 return nil, NewValidationError("communityDid", "required") 225 } 226 227 if req.UpdatedByDID == "" { 228 return nil, NewValidationError("updatedByDid", "required") 229 } 230 231 // Get existing community 232 existing, err := s.repo.GetByDID(ctx, req.CommunityDID) 233 if err != nil { 234 return nil, err 235 } 236 237 // Authorization: verify user is the creator 238 // TODO(Communities-Auth): Add moderator check when moderation system is implemented 239 if existing.CreatedByDID != req.UpdatedByDID { 240 return nil, ErrUnauthorized 241 } 242 243 // Build updated profile record (start with existing) 244 profile := map[string]interface{}{ 245 "$type": "social.coves.community.profile", 246 "handle": existing.Handle, 247 "name": existing.Name, 248 "owner": existing.OwnerDID, 249 "createdBy": existing.CreatedByDID, 250 "hostedBy": existing.HostedByDID, 251 "createdAt": existing.CreatedAt.Format(time.RFC3339), 252 } 253 254 // Apply updates 255 if req.DisplayName != nil { 256 profile["displayName"] = *req.DisplayName 257 } else { 258 profile["displayName"] = existing.DisplayName 259 } 260 261 if req.Description != nil { 262 profile["description"] = *req.Description 263 } else { 264 profile["description"] = existing.Description 265 } 266 267 if req.Visibility != nil { 268 profile["visibility"] = *req.Visibility 269 } else { 270 profile["visibility"] = existing.Visibility 271 } 272 273 if req.AllowExternalDiscovery != nil { 274 profile["federation"] = map[string]interface{}{ 275 "allowExternalDiscovery": *req.AllowExternalDiscovery, 276 } 277 } else { 278 profile["federation"] = map[string]interface{}{ 279 "allowExternalDiscovery": existing.AllowExternalDiscovery, 280 } 281 } 282 283 // Preserve moderation settings (even if empty) 284 // These fields are optional but should not be erased on update 285 if req.ModerationType != nil { 286 profile["moderationType"] = *req.ModerationType 287 } else if existing.ModerationType != "" { 288 profile["moderationType"] = existing.ModerationType 289 } 290 291 if len(req.ContentWarnings) > 0 { 292 profile["contentWarnings"] = req.ContentWarnings 293 } else if len(existing.ContentWarnings) > 0 { 294 profile["contentWarnings"] = existing.ContentWarnings 295 } 296 297 // Preserve counts 298 profile["memberCount"] = existing.MemberCount 299 profile["subscriberCount"] = existing.SubscriberCount 300 301 // V2: Community profiles always use "self" as rkey 302 // (No need to extract from URI - it's always "self" for V2 communities) 303 304 // V2 CRITICAL FIX: Write-forward using COMMUNITY's own DID and credentials 305 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self 306 // Authenticate as the community (not as instance!) 307 if existing.PDSAccessToken == "" { 308 return nil, fmt.Errorf("community %s missing PDS credentials - cannot update", existing.DID) 309 } 310 311 recordURI, recordCID, err := s.putRecordOnPDSAs( 312 ctx, 313 existing.DID, // repo = community's own DID (V2!) 314 "social.coves.community.profile", 315 "self", // V2: always "self" 316 profile, 317 existing.PDSAccessToken, // authenticate as the community 318 ) 319 if err != nil { 320 return nil, fmt.Errorf("failed to update community on PDS: %w", err) 321 } 322 323 // Return updated community representation 324 // Actual AppView DB update happens via Jetstream consumer 325 updated := *existing 326 if req.DisplayName != nil { 327 updated.DisplayName = *req.DisplayName 328 } 329 if req.Description != nil { 330 updated.Description = *req.Description 331 } 332 if req.Visibility != nil { 333 updated.Visibility = *req.Visibility 334 } 335 if req.AllowExternalDiscovery != nil { 336 updated.AllowExternalDiscovery = *req.AllowExternalDiscovery 337 } 338 if req.ModerationType != nil { 339 updated.ModerationType = *req.ModerationType 340 } 341 if len(req.ContentWarnings) > 0 { 342 updated.ContentWarnings = req.ContentWarnings 343 } 344 updated.RecordURI = recordURI 345 updated.RecordCID = recordCID 346 updated.UpdatedAt = time.Now() 347 348 return &updated, nil 349} 350 351// ListCommunities queries AppView DB for communities with filters 352func (s *communityService) ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) { 353 // Set defaults 354 if req.Limit <= 0 || req.Limit > 100 { 355 req.Limit = 50 356 } 357 358 return s.repo.List(ctx, req) 359} 360 361// SearchCommunities performs fuzzy search in AppView DB 362func (s *communityService) SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error) { 363 if req.Query == "" { 364 return nil, 0, NewValidationError("query", "search query is required") 365 } 366 367 // Set defaults 368 if req.Limit <= 0 || req.Limit > 100 { 369 req.Limit = 50 370 } 371 372 return s.repo.Search(ctx, req) 373} 374 375// SubscribeToCommunity creates a subscription via write-forward to PDS 376func (s *communityService) SubscribeToCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string, contentVisibility int) (*Subscription, error) { 377 if userDID == "" { 378 return nil, NewValidationError("userDid", "required") 379 } 380 if userAccessToken == "" { 381 return nil, NewValidationError("userAccessToken", "required") 382 } 383 384 // Clamp contentVisibility to valid range (1-5), default to 3 if 0 or invalid 385 if contentVisibility <= 0 || contentVisibility > 5 { 386 contentVisibility = 3 387 } 388 389 // Resolve community identifier to DID 390 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 391 if err != nil { 392 return nil, err 393 } 394 395 // Verify community exists 396 community, err := s.repo.GetByDID(ctx, communityDID) 397 if err != nil { 398 return nil, err 399 } 400 401 // Check visibility - can't subscribe to private communities without invitation (TODO) 402 if community.Visibility == "private" { 403 return nil, ErrUnauthorized 404 } 405 406 // Build subscription record 407 // CRITICAL: Collection is social.coves.community.subscription (RECORD TYPE), not social.coves.community.subscribe (XRPC procedure) 408 // This record will be created in the USER's repository: at://user_did/social.coves.community.subscription/{tid} 409 // Following atProto conventions, we use "subject" field to reference the community 410 subRecord := map[string]interface{}{ 411 "$type": "social.coves.community.subscription", 412 "subject": communityDID, // atProto convention: "subject" for entity references 413 "createdAt": time.Now().Format(time.RFC3339), 414 "contentVisibility": contentVisibility, 415 } 416 417 // Write-forward: create subscription record in user's repo using their access token 418 // The collection parameter refers to the record type in the repository 419 recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, userDID, "social.coves.community.subscription", "", subRecord, userAccessToken) 420 if err != nil { 421 return nil, fmt.Errorf("failed to create subscription on PDS: %w", err) 422 } 423 424 // Return subscription representation 425 subscription := &Subscription{ 426 UserDID: userDID, 427 CommunityDID: communityDID, 428 ContentVisibility: contentVisibility, 429 SubscribedAt: time.Now(), 430 RecordURI: recordURI, 431 RecordCID: recordCID, 432 } 433 434 return subscription, nil 435} 436 437// UnsubscribeFromCommunity removes a subscription via PDS delete 438func (s *communityService) UnsubscribeFromCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error { 439 if userDID == "" { 440 return NewValidationError("userDid", "required") 441 } 442 if userAccessToken == "" { 443 return NewValidationError("userAccessToken", "required") 444 } 445 446 // Resolve community identifier 447 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 448 if err != nil { 449 return err 450 } 451 452 // Get the subscription from AppView to find the record key 453 subscription, err := s.repo.GetSubscription(ctx, userDID, communityDID) 454 if err != nil { 455 return err 456 } 457 458 // Extract rkey from record URI (at://did/collection/rkey) 459 rkey := utils.ExtractRKeyFromURI(subscription.RecordURI) 460 if rkey == "" { 461 return fmt.Errorf("invalid subscription record URI") 462 } 463 464 // Write-forward: delete record from PDS using user's access token 465 // CRITICAL: Delete from social.coves.community.subscription (RECORD TYPE), not social.coves.community.unsubscribe 466 if err := s.deleteRecordOnPDSAs(ctx, userDID, "social.coves.community.subscription", rkey, userAccessToken); err != nil { 467 return fmt.Errorf("failed to delete subscription on PDS: %w", err) 468 } 469 470 return nil 471} 472 473// GetUserSubscriptions queries AppView DB for user's subscriptions 474func (s *communityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*Subscription, error) { 475 if limit <= 0 || limit > 100 { 476 limit = 50 477 } 478 479 return s.repo.ListSubscriptions(ctx, userDID, limit, offset) 480} 481 482// GetCommunitySubscribers queries AppView DB for community subscribers 483func (s *communityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Subscription, error) { 484 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 485 if err != nil { 486 return nil, err 487 } 488 489 if limit <= 0 || limit > 100 { 490 limit = 50 491 } 492 493 return s.repo.ListSubscribers(ctx, communityDID, limit, offset) 494} 495 496// GetMembership retrieves membership info from AppView DB 497func (s *communityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*Membership, error) { 498 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 499 if err != nil { 500 return nil, err 501 } 502 503 return s.repo.GetMembership(ctx, userDID, communityDID) 504} 505 506// ListCommunityMembers queries AppView DB for members 507func (s *communityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*Membership, error) { 508 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 509 if err != nil { 510 return nil, err 511 } 512 513 if limit <= 0 || limit > 100 { 514 limit = 50 515 } 516 517 return s.repo.ListMembers(ctx, communityDID, limit, offset) 518} 519 520// BlockCommunity blocks a community via write-forward to PDS 521func (s *communityService) BlockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) (*CommunityBlock, error) { 522 if userDID == "" { 523 return nil, NewValidationError("userDid", "required") 524 } 525 if userAccessToken == "" { 526 return nil, NewValidationError("userAccessToken", "required") 527 } 528 529 // Resolve community identifier (also verifies community exists) 530 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 531 if err != nil { 532 return nil, err 533 } 534 535 // Build block record 536 // CRITICAL: Collection is social.coves.community.block (RECORD TYPE) 537 // This record will be created in the USER's repository: at://user_did/social.coves.community.block/{tid} 538 // Following atProto conventions and Bluesky's app.bsky.graph.block pattern 539 blockRecord := map[string]interface{}{ 540 "$type": "social.coves.community.block", 541 "subject": communityDID, // DID of community being blocked 542 "createdAt": time.Now().Format(time.RFC3339), 543 } 544 545 // Write-forward: create block record in user's repo using their access token 546 // Note: We don't check for existing blocks first because: 547 // 1. The PDS may reject duplicates (depending on implementation) 548 // 2. The repository layer handles idempotency with ON CONFLICT DO NOTHING 549 // 3. This avoids a race condition where two concurrent requests both pass the check 550 recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, userDID, "social.coves.community.block", "", blockRecord, userAccessToken) 551 if err != nil { 552 // Check if this is a duplicate/conflict error from PDS 553 // PDS should return 409 Conflict for duplicate records, but we also check common error messages 554 // for compatibility with different PDS implementations 555 errMsg := err.Error() 556 isDuplicate := strings.Contains(errMsg, "status 409") || // HTTP 409 Conflict 557 strings.Contains(errMsg, "duplicate") || 558 strings.Contains(errMsg, "already exists") || 559 strings.Contains(errMsg, "AlreadyExists") 560 561 if isDuplicate { 562 // Fetch and return existing block from our indexed view 563 existingBlock, getErr := s.repo.GetBlock(ctx, userDID, communityDID) 564 if getErr == nil { 565 // Block exists in our index - return it 566 return existingBlock, nil 567 } 568 // Race condition: PDS has the block but Jetstream hasn't indexed it yet 569 // Return typed conflict error so handler can return 409 instead of 500 570 // This is normal in eventually-consistent systems 571 return nil, ErrBlockAlreadyExists 572 } 573 return nil, fmt.Errorf("failed to create block on PDS: %w", err) 574 } 575 576 // Return block representation 577 block := &CommunityBlock{ 578 UserDID: userDID, 579 CommunityDID: communityDID, 580 BlockedAt: time.Now(), 581 RecordURI: recordURI, 582 RecordCID: recordCID, 583 } 584 585 return block, nil 586} 587 588// UnblockCommunity removes a block via PDS delete 589func (s *communityService) UnblockCommunity(ctx context.Context, userDID, userAccessToken, communityIdentifier string) error { 590 if userDID == "" { 591 return NewValidationError("userDid", "required") 592 } 593 if userAccessToken == "" { 594 return NewValidationError("userAccessToken", "required") 595 } 596 597 // Resolve community identifier 598 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 599 if err != nil { 600 return err 601 } 602 603 // Get the block from AppView to find the record key 604 block, err := s.repo.GetBlock(ctx, userDID, communityDID) 605 if err != nil { 606 return err 607 } 608 609 // Extract rkey from record URI (at://did/collection/rkey) 610 rkey := utils.ExtractRKeyFromURI(block.RecordURI) 611 if rkey == "" { 612 return fmt.Errorf("invalid block record URI") 613 } 614 615 // Write-forward: delete record from PDS using user's access token 616 if err := s.deleteRecordOnPDSAs(ctx, userDID, "social.coves.community.block", rkey, userAccessToken); err != nil { 617 return fmt.Errorf("failed to delete block on PDS: %w", err) 618 } 619 620 return nil 621} 622 623// GetBlockedCommunities queries AppView DB for user's blocks 624func (s *communityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*CommunityBlock, error) { 625 if limit <= 0 || limit > 100 { 626 limit = 50 627 } 628 629 return s.repo.ListBlockedCommunities(ctx, userDID, limit, offset) 630} 631 632// IsBlocked checks if a user has blocked a community 633func (s *communityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 634 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 635 if err != nil { 636 return false, err 637 } 638 639 return s.repo.IsBlocked(ctx, userDID, communityDID) 640} 641 642// ValidateHandle checks if a community handle is valid 643func (s *communityService) ValidateHandle(handle string) error { 644 if handle == "" { 645 return NewValidationError("handle", "required") 646 } 647 648 if !communityHandleRegex.MatchString(handle) { 649 return ErrInvalidHandle 650 } 651 652 return nil 653} 654 655// ResolveCommunityIdentifier converts a handle or DID to a DID 656func (s *communityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 657 if identifier == "" { 658 return "", ErrInvalidInput 659 } 660 661 // If it's already a DID, verify the community exists 662 if strings.HasPrefix(identifier, "did:") { 663 _, err := s.repo.GetByDID(ctx, identifier) 664 if err != nil { 665 if IsNotFound(err) { 666 return "", fmt.Errorf("community not found: %w", err) 667 } 668 return "", fmt.Errorf("failed to verify community DID: %w", err) 669 } 670 return identifier, nil 671 } 672 673 // If it's a handle, look it up in AppView DB 674 if strings.HasPrefix(identifier, "!") { 675 community, err := s.repo.GetByHandle(ctx, identifier) 676 if err != nil { 677 return "", err 678 } 679 return community.DID, nil 680 } 681 682 return "", NewValidationError("identifier", "must be a DID or handle") 683} 684 685// Validation helpers 686 687func (s *communityService) validateCreateRequest(req CreateCommunityRequest) error { 688 if req.Name == "" { 689 return NewValidationError("name", "required") 690 } 691 692 // DNS label limit: 63 characters per label 693 // Community handle format: {name}.communities.{instanceDomain} 694 // The first label is just req.Name, so it must be <= 63 chars 695 if len(req.Name) > 63 { 696 return NewValidationError("name", "must be 63 characters or less (DNS label limit)") 697 } 698 699 // Name can only contain alphanumeric and hyphens 700 // Must start and end with alphanumeric (not hyphen) 701 nameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 702 if !nameRegex.MatchString(req.Name) { 703 return NewValidationError("name", "must contain only alphanumeric characters and hyphens") 704 } 705 706 if req.Description != "" && len(req.Description) > 3000 { 707 return NewValidationError("description", "must be 3000 characters or less") 708 } 709 710 // Visibility should already be set with default in CreateCommunity 711 if req.Visibility != "public" && req.Visibility != "unlisted" && req.Visibility != "private" { 712 return ErrInvalidVisibility 713 } 714 715 if req.CreatedByDID == "" { 716 return NewValidationError("createdByDid", "required") 717 } 718 719 // hostedByDID is auto-populated by the service layer, no validation needed 720 // The handler ensures clients cannot provide this field 721 722 return nil 723} 724 725// PDS write-forward helpers 726 727func (s *communityService) createRecordOnPDS(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}) (string, string, error) { 728 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/")) 729 730 payload := map[string]interface{}{ 731 "repo": repoDID, 732 "collection": collection, 733 "record": record, 734 } 735 736 if rkey != "" { 737 payload["rkey"] = rkey 738 } 739 740 return s.callPDS(ctx, "POST", endpoint, payload) 741} 742 743// createRecordOnPDSAs creates a record with a specific access token (for V2 community auth) 744func (s *communityService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) { 745 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/")) 746 747 payload := map[string]interface{}{ 748 "repo": repoDID, 749 "collection": collection, 750 "record": record, 751 } 752 753 if rkey != "" { 754 payload["rkey"] = rkey 755 } 756 757 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 758} 759 760// putRecordOnPDSAs updates a record with a specific access token (for V2 community auth) 761func (s *communityService) putRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) { 762 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", strings.TrimSuffix(s.pdsURL, "/")) 763 764 payload := map[string]interface{}{ 765 "repo": repoDID, 766 "collection": collection, 767 "rkey": rkey, 768 "record": record, 769 } 770 771 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 772} 773 774func (s *communityService) deleteRecordOnPDS(ctx context.Context, repoDID, collection, rkey string) error { 775 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/")) 776 777 payload := map[string]interface{}{ 778 "repo": repoDID, 779 "collection": collection, 780 "rkey": rkey, 781 } 782 783 _, _, err := s.callPDS(ctx, "POST", endpoint, payload) 784 return err 785} 786 787// deleteRecordOnPDSAs deletes a record with a specific access token (for user-scoped deletions) 788func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, accessToken string) error { 789 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/")) 790 791 payload := map[string]interface{}{ 792 "repo": repoDID, 793 "collection": collection, 794 "rkey": rkey, 795 } 796 797 _, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 798 return err 799} 800 801func (s *communityService) callPDS(ctx context.Context, method, endpoint string, payload map[string]interface{}) (string, string, error) { 802 // Use instance's access token 803 return s.callPDSWithAuth(ctx, method, endpoint, payload, s.pdsAccessToken) 804} 805 806// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication) 807func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) { 808 jsonData, err := json.Marshal(payload) 809 if err != nil { 810 return "", "", fmt.Errorf("failed to marshal payload: %w", err) 811 } 812 813 req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewBuffer(jsonData)) 814 if err != nil { 815 return "", "", fmt.Errorf("failed to create request: %w", err) 816 } 817 req.Header.Set("Content-Type", "application/json") 818 819 // Add authentication with provided access token 820 if accessToken != "" { 821 req.Header.Set("Authorization", "Bearer "+accessToken) 822 } 823 824 // Dynamic timeout based on operation type 825 // Write operations (createAccount, createRecord, putRecord) are slower due to: 826 // - Keypair generation 827 // - DID PLC registration 828 // - Database writes on PDS 829 timeout := 10 * time.Second // Default for read operations 830 if strings.Contains(endpoint, "createAccount") || 831 strings.Contains(endpoint, "createRecord") || 832 strings.Contains(endpoint, "putRecord") { 833 timeout = 30 * time.Second // Extended timeout for write operations 834 } 835 836 client := &http.Client{Timeout: timeout} 837 resp, err := client.Do(req) 838 if err != nil { 839 return "", "", fmt.Errorf("failed to call PDS: %w", err) 840 } 841 defer func() { 842 if closeErr := resp.Body.Close(); closeErr != nil { 843 log.Printf("Failed to close response body: %v", closeErr) 844 } 845 }() 846 847 body, err := io.ReadAll(resp.Body) 848 if err != nil { 849 return "", "", fmt.Errorf("failed to read response: %w", err) 850 } 851 852 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 853 return "", "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) 854 } 855 856 // Parse response to extract URI and CID 857 var result struct { 858 URI string `json:"uri"` 859 CID string `json:"cid"` 860 } 861 if err := json.Unmarshal(body, &result); err != nil { 862 // For delete operations, there might not be a response body 863 if method == "POST" && strings.Contains(endpoint, "deleteRecord") { 864 return "", "", nil 865 } 866 return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 867 } 868 869 return result.URI, result.CID, nil 870} 871 872// Helper functions 873