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