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}