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