A community based topic aggregation platform built on atproto
1package communities
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/base64"
7 "fmt"
8 "strings"
9
10 "Coves/internal/core/users"
11 "golang.org/x/crypto/bcrypt"
12)
13
14// CommunityPDSAccount represents PDS account credentials for a community
15type CommunityPDSAccount struct {
16 DID string // Community's DID (owns the repository)
17 Handle string // Community's handle (e.g., gaming.coves.social)
18 Email string // System email for PDS account
19 PasswordHash string // bcrypt hash of generated password
20 AccessToken string // JWT for making API calls as the community
21 RefreshToken string // For refreshing sessions
22 PDSURL string // PDS hosting this community
23}
24
25// PDSAccountProvisioner creates PDS accounts for communities
26type PDSAccountProvisioner struct {
27 userService users.UserService
28 instanceDomain string
29 pdsURL string
30}
31
32// NewPDSAccountProvisioner creates a new provisioner
33func NewPDSAccountProvisioner(userService users.UserService, instanceDomain string, pdsURL string) *PDSAccountProvisioner {
34 return &PDSAccountProvisioner{
35 userService: userService,
36 instanceDomain: instanceDomain,
37 pdsURL: pdsURL,
38 }
39}
40
41// ProvisionCommunityAccount creates a real PDS account for a community
42//
43// This function:
44// 1. Generates a unique handle (e.g., gaming.coves.social)
45// 2. Generates a system email (e.g., community-gaming@system.coves.social)
46// 3. Generates a secure random password
47// 4. Calls com.atproto.server.createAccount via the PDS
48// 5. The PDS automatically generates and stores the signing keypair
49// 6. Returns credentials for Coves to act on behalf of the community
50//
51// V2 Architecture:
52// - Community DID owns its own repository (at://community_did/...)
53// - PDS manages signing keys (we never see them)
54// - We store credentials to authenticate as the community
55// - Future: Add rotation key management for true portability (V2.1)
56func (p *PDSAccountProvisioner) ProvisionCommunityAccount(
57 ctx context.Context,
58 communityName string,
59) (*CommunityPDSAccount, error) {
60 if communityName == "" {
61 return nil, fmt.Errorf("community name is required")
62 }
63
64 // 1. Generate unique handle for the community using subdomain
65 // This makes it immediately clear these are communities, not user accounts
66 // Format: {name}.communities.{instance-domain}
67 handle := fmt.Sprintf("%s.communities.%s", strings.ToLower(communityName), p.instanceDomain)
68 // Example: "gaming.communities.coves.social" (much cleaner!)
69
70 // 2. Generate system email for PDS account management
71 // This email is used for account operations, not for user communication
72 email := fmt.Sprintf("community-%s@communities.%s", strings.ToLower(communityName), p.instanceDomain)
73 // Example: "community-gaming@communities.coves.social"
74
75 // 3. Generate secure random password (32 characters)
76 // This password is never shown to users - it's for Coves to authenticate as the community
77 password, err := generateSecurePassword(32)
78 if err != nil {
79 return nil, fmt.Errorf("failed to generate password: %w", err)
80 }
81
82 // 4. Call PDS com.atproto.server.createAccount
83 // The PDS will:
84 // - Generate a signing keypair (we never see the private key)
85 // - Create a DID (did:plc:xxx)
86 // - Store the private signing key securely
87 // - Return DID, handle, and authentication tokens
88 //
89 // Note: No inviteCode needed for our local PDS (configure PDS with invites disabled)
90 resp, err := p.userService.RegisterAccount(ctx, users.RegisterAccountRequest{
91 Handle: handle,
92 Email: email,
93 Password: password,
94 // InviteCode: "", // Not needed if PDS has open registration or we're admin
95 })
96 if err != nil {
97 return nil, fmt.Errorf("PDS account creation failed for community %s: %w", communityName, err)
98 }
99
100 // 5. Hash the password for storage
101 // We need to store the password hash so we can re-authenticate if tokens expire
102 // This is secure - bcrypt is industry standard
103 passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
104 if err != nil {
105 return nil, fmt.Errorf("failed to hash password: %w", err)
106 }
107
108 // 6. Return account credentials
109 return &CommunityPDSAccount{
110 DID: resp.DID, // The community's DID - it owns its own repository!
111 Handle: resp.Handle, // e.g., gaming.coves.social
112 Email: email, // community-gaming@system.coves.social
113 PasswordHash: string(passwordHash), // bcrypt hash for re-authentication
114 AccessToken: resp.AccessJwt, // JWT for making API calls as the community
115 RefreshToken: resp.RefreshJwt, // For refreshing sessions when access token expires
116 PDSURL: resp.PDSURL, // PDS hosting this community's repository
117 }, nil
118}
119
120// generateSecurePassword creates a cryptographically secure random password
121// Uses crypto/rand for security-critical randomness
122func generateSecurePassword(length int) (string, error) {
123 if length < 8 {
124 return "", fmt.Errorf("password length must be at least 8 characters")
125 }
126
127 // Generate random bytes
128 bytes := make([]byte, length)
129 if _, err := rand.Read(bytes); err != nil {
130 return "", fmt.Errorf("failed to generate random bytes: %w", err)
131 }
132
133 // Encode as base64 URL-safe (no special chars that need escaping)
134 password := base64.URLEncoding.EncodeToString(bytes)
135
136 // Trim to exact length
137 if len(password) > length {
138 password = password[:length]
139 }
140
141 return password, nil
142}