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