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