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