···
pdsURL string // PDS URL for write-forward operations
instanceDID string // DID of this Coves instance
25
+
instanceDomain string // Domain of this instance (for handles)
pdsAccessToken string // Access token for authenticating to PDS as the instance
27
+
provisioner *PDSAccountProvisioner // V2: Creates PDS accounts for communities
// NewCommunityService creates a new community service
29
-
func NewCommunityService(repo Repository, didGen *did.Generator, pdsURL string, instanceDID string) Service {
31
+
func NewCommunityService(repo Repository, didGen *did.Generator, pdsURL string, instanceDID string, instanceDomain string, provisioner *PDSAccountProvisioner) Service {
return &communityService{
34
-
instanceDID: instanceDID,
36
+
instanceDID: instanceDID,
37
+
instanceDomain: instanceDomain,
38
+
provisioner: provisioner,
···
// CreateCommunity creates a new community via write-forward to PDS
45
-
// Flow: Service -> PDS (creates record) -> Firehose -> Consumer -> AppView DB
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
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)
func (s *communityService) CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error) {
// Apply defaults before validation
if req.Visibility == "" {
···
57
-
// Generate a unique DID for the community
58
-
communityDID, err := s.didGen.GenerateCommunityDID()
71
+
// V2: Provision a real PDS account for this community
72
+
// This calls com.atproto.server.createAccount internally
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)
60
-
return nil, fmt.Errorf("failed to generate community DID: %w", err)
79
+
return nil, fmt.Errorf("failed to provision PDS account for community: %w", err)
63
-
// Build scoped handle: !{name}@{instance}
64
-
instanceDomain := extractDomain(s.instanceDID)
65
-
if instanceDomain == "" {
66
-
instanceDomain = "coves.local" // Fallback for testing
68
-
handle := fmt.Sprintf("!%s@%s", req.Name, instanceDomain)
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)
70
-
// Validate the generated handle
71
-
if err := s.ValidateHandle(handle); err != nil {
72
-
return nil, fmt.Errorf("generated handle is invalid: %w", err)
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)
// Build community profile record
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
78
-
"did": communityDID, // Unique identifier for this community
95
+
"handle": scopedHandle, // Display handle (!gaming@coves.social)
96
+
"atprotoHandle": pdsAccount.Handle, // Real atProto handle (gaming.communities.coves.social)
"visibility": req.Visibility,
82
-
"owner": s.instanceDID, // V1: instance owns the community
99
+
"hostedBy": s.instanceDID, // V2: Instance hosts, community owns
"createdBy": req.CreatedByDID,
84
-
"hostedBy": req.HostedByDID,
"createdAt": time.Now().Format(time.RFC3339),
"federation": map[string]interface{}{
"allowExternalDiscovery": req.AllowExternalDiscovery,
···
// 3. Add to profile record
118
-
// Write-forward to PDS: create the community profile record in the INSTANCE's repository
119
-
// The instance owns all community records, community DID is just metadata in the record
120
-
// Record will be at: at://INSTANCE_DID/social.coves.community.profile/COMMUNITY_RKEY
121
-
recordURI, recordCID, err := s.createRecordOnPDS(ctx, s.instanceDID, "social.coves.community.profile", "", profile)
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(
139
+
pdsAccount.DID, // repo = community's DID (community owns its repo!)
140
+
"social.coves.community.profile",
141
+
"self", // canonical rkey for profile
143
+
pdsAccount.AccessToken, // authenticate as the community
123
-
return nil, fmt.Errorf("failed to create community on PDS: %w", err)
146
+
return nil, fmt.Errorf("failed to create community profile record: %w", err)
126
-
// Return a Community object representing what was created
127
-
// Note: This won't be in AppView DB until the Jetstream consumer processes it
149
+
// Build Community object with PDS credentials
151
+
DID: pdsAccount.DID, // Community's DID (owns the repo!)
152
+
Handle: scopedHandle, // !gaming@coves.social
DisplayName: req.DisplayName,
Description: req.Description,
134
-
OwnerDID: s.instanceDID,
156
+
OwnerDID: pdsAccount.DID, // V2: Community owns itself
CreatedByDID: req.CreatedByDID,
HostedByDID: req.HostedByDID,
159
+
PDSEmail: pdsAccount.Email,
160
+
PDSPasswordHash: pdsAccount.PasswordHash,
161
+
PDSAccessToken: pdsAccount.AccessToken,
162
+
PDSRefreshToken: pdsAccount.RefreshToken,
163
+
PDSURL: pdsAccount.PDSURL,
Visibility: req.Visibility,
AllowExternalDiscovery: req.AllowExternalDiscovery,
···
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)
181
+
return nil, fmt.Errorf("failed to persist community with credentials: %w", err)
···
profile["memberCount"] = existing.MemberCount
profile["subscriberCount"] = existing.SubscriberCount
243
-
// Extract rkey from existing record URI (communities live in instance's repo)
244
-
rkey := extractRKeyFromURI(existing.RecordURI)
246
-
return nil, fmt.Errorf("invalid community record URI: %s", existing.RecordURI)
280
+
// V2: Community profiles always use "self" as rkey
281
+
// (No need to extract from URI - it's always "self" for V2 communities)
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)
249
-
// Write-forward: update record on PDS using INSTANCE DID (communities are stored in instance repo)
250
-
recordURI, recordCID, err := s.putRecordOnPDS(ctx, s.instanceDID, "social.coves.community.profile", rkey, profile)
290
+
recordURI, recordCID, err := s.putRecordOnPDSAs(
292
+
existing.DID, // repo = community's own DID (V2!)
293
+
"social.coves.community.profile",
294
+
"self", // V2: always "self"
296
+
existing.PDSAccessToken, // authenticate as the community
return nil, fmt.Errorf("failed to update community on PDS: %w", err)
···
return s.callPDS(ctx, "POST", endpoint, payload)
571
+
// createRecordOnPDSAs creates a record with a specific access token (for V2 community auth)
572
+
func (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, "/"))
575
+
payload := map[string]interface{}{
577
+
"collection": collection,
582
+
payload["rkey"] = rkey
585
+
return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
func (s *communityService) putRecordOnPDS(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}) (string, string, error) {
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", strings.TrimSuffix(s.pdsURL, "/"))
···
return s.callPDS(ctx, "POST", endpoint, payload)
601
+
// putRecordOnPDSAs updates a record with a specific access token (for V2 community auth)
602
+
func (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, "/"))
605
+
payload := map[string]interface{}{
607
+
"collection": collection,
612
+
return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
func (s *communityService) deleteRecordOnPDS(ctx context.Context, repoDID, collection, rkey string) error {
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))
···
func (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)
633
+
// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication)
634
+
func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) {
jsonData, err := json.Marshal(payload)
return "", "", fmt.Errorf("failed to marshal payload: %w", err)
···
req.Header.Set("Content-Type", "application/json")
562
-
// Add authentication if we have an access token
563
-
if s.pdsAccessToken != "" {
564
-
req.Header.Set("Authorization", "Bearer "+s.pdsAccessToken)
646
+
// Add authentication with provided access token
647
+
if accessToken != "" {
648
+
req.Header.Set("Authorization", "Bearer "+accessToken)
567
-
client := &http.Client{Timeout: 10 * time.Second}
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
663
+
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
return "", "", fmt.Errorf("failed to call PDS: %w", err)