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 "github.com/bluesky-social/indigo/api/atproto"
11 comatproto "github.com/bluesky-social/indigo/api/atproto"
12 "github.com/bluesky-social/indigo/xrpc"
13)
14
15// CommunityPDSAccount represents PDS account credentials for a community
16type CommunityPDSAccount struct {
17 DID string // Community's DID (owns the repository)
18 Handle string // Community's handle (e.g., gaming.community.coves.social)
19 Email string // System email for PDS account
20 Password string // Cleartext password (MUST be encrypted before database storage)
21 AccessToken string // JWT for making API calls as the community
22 RefreshToken string // For refreshing sessions
23 PDSURL string // PDS hosting this community
24 RotationKeyPEM string // PEM-encoded rotation key (for portability)
25 SigningKeyPEM string // PEM-encoded signing key (for atproto operations)
26}
27
28// GetPDSURL implements blobs.BlobOwner interface.
29// Returns the community's PDS URL for blob uploads.
30func (c *CommunityPDSAccount) GetPDSURL() string {
31 return c.PDSURL
32}
33
34// GetPDSAccessToken implements blobs.BlobOwner interface.
35// Returns the community's PDS access token for blob upload authentication.
36func (c *CommunityPDSAccount) GetPDSAccessToken() string {
37 return c.AccessToken
38}
39
40// PDSAccountProvisioner creates PDS accounts for communities with PDS-managed DIDs
41type PDSAccountProvisioner struct {
42 instanceDomain string
43 pdsURL string // URL to call PDS (e.g., http://localhost:3001)
44}
45
46// NewPDSAccountProvisioner creates a new provisioner for V2.0 (PDS-managed keys)
47func NewPDSAccountProvisioner(instanceDomain, pdsURL string) *PDSAccountProvisioner {
48 return &PDSAccountProvisioner{
49 instanceDomain: instanceDomain,
50 pdsURL: pdsURL,
51 }
52}
53
54// ProvisionCommunityAccount creates a real PDS account for a community with PDS-managed keys
55//
56// V2.0 Architecture (PDS-Managed Keys):
57// 1. Generates community handle and credentials
58// 2. Calls com.atproto.server.createAccount (PDS generates DID and keys)
59// 3. Returns credentials for storage
60//
61// V2.0 Design Philosophy:
62// - PDS manages ALL cryptographic keys (signing + rotation)
63// - Communities can migrate between Coves-controlled PDSs using standard atProto migration
64// - Simpler, faster, ships immediately
65// - Migration uses com.atproto.server.getServiceAuth + standard migration endpoints
66//
67// Future V2.1 (Optional Portability Enhancement):
68// - Add Coves-controlled rotation key alongside PDS rotation key
69// - Enables migration to non-Coves PDSs
70// - Implement when actual external migration is needed
71//
72// SECURITY: The returned credentials MUST be encrypted before database storage
73func (p *PDSAccountProvisioner) ProvisionCommunityAccount(
74 ctx context.Context,
75 communityName string,
76) (*CommunityPDSAccount, error) {
77 if communityName == "" {
78 return nil, fmt.Errorf("community name is required")
79 }
80
81 // 1. Generate unique handle for the community
82 // Format: c-{name}.{instance-domain}
83 // Example: "c-gaming.coves.social"
84 // Uses c- prefix to distinguish from user handles while keeping single-level subdomain
85 handle := fmt.Sprintf("c-%s.%s", strings.ToLower(communityName), p.instanceDomain)
86
87 // 2. Generate system email for PDS account management
88 // This email is used for account operations, not for user communication
89 email := fmt.Sprintf("c-%s@%s", strings.ToLower(communityName), p.instanceDomain)
90
91 // 3. Generate secure random password (32 characters)
92 // This password is never shown to users - it's for Coves to authenticate as the community
93 password, err := generateSecurePassword(32)
94 if err != nil {
95 return nil, fmt.Errorf("failed to generate password: %w", err)
96 }
97
98 // 4. Create PDS account - let PDS generate DID and all keys
99 // The PDS will:
100 // 1. Generate a signing keypair (stored in PDS, never exported)
101 // 2. Generate rotation keys (stored in PDS)
102 // 3. Create a DID (did:plc:xxx)
103 // 4. Register DID with PLC directory
104 // 5. Return credentials (DID, handle, tokens)
105 client := &xrpc.Client{
106 Host: p.pdsURL,
107 }
108
109 emailStr := email
110 passwordStr := password
111
112 input := &atproto.ServerCreateAccount_Input{
113 Handle: handle,
114 Email: &emailStr,
115 Password: &passwordStr,
116 // No Did parameter - let PDS generate it
117 // No RecoveryKey - PDS manages rotation keys
118 }
119
120 output, err := atproto.ServerCreateAccount(ctx, client, input)
121 if err != nil {
122 return nil, fmt.Errorf("PDS account creation failed for community %s: %w", communityName, err)
123 }
124
125 // 5. Return account credentials with cleartext password
126 // CRITICAL: The password MUST be encrypted (not hashed) before database storage
127 // We need to recover the plaintext password to call com.atproto.server.createSession
128 // when access/refresh tokens expire (90-day window on refresh tokens)
129 // The repository layer handles encryption using pgp_sym_encrypt()
130 return &CommunityPDSAccount{
131 DID: output.Did, // The community's DID (PDS-generated)
132 Handle: output.Handle, // e.g., gaming.community.coves.social
133 Email: email, // community-gaming@community.coves.social
134 Password: password, // Cleartext - will be encrypted by repository
135 AccessToken: output.AccessJwt, // JWT for making API calls
136 RefreshToken: output.RefreshJwt, // For refreshing sessions
137 PDSURL: p.pdsURL, // PDS hosting this community
138 RotationKeyPEM: "", // Empty - PDS manages keys (V2.1: add Coves rotation key)
139 SigningKeyPEM: "", // Empty - PDS manages keys
140 }, nil
141}
142
143// generateSecurePassword creates a cryptographically secure random password
144// Uses crypto/rand for security-critical randomness
145func generateSecurePassword(length int) (string, error) {
146 if length < 8 {
147 return "", fmt.Errorf("password length must be at least 8 characters")
148 }
149
150 // Generate random bytes
151 bytes := make([]byte, length)
152 if _, err := rand.Read(bytes); err != nil {
153 return "", fmt.Errorf("failed to generate random bytes: %w", err)
154 }
155
156 // Encode as base64 URL-safe (no special chars that need escaping)
157 password := base64.URLEncoding.EncodeToString(bytes)
158
159 // Trim to exact length
160 if len(password) > length {
161 password = password[:length]
162 }
163
164 return password, nil
165}
166
167// FetchPDSDID queries the PDS to get its DID via com.atproto.server.describeServer
168// This is the proper way to get the PDS DID rather than hardcoding it
169// Works in both development (did:web:localhost) and production (did:web:pds.example.com)
170func FetchPDSDID(ctx context.Context, pdsURL string) (string, error) {
171 client := &xrpc.Client{
172 Host: pdsURL,
173 }
174
175 resp, err := comatproto.ServerDescribeServer(ctx, client)
176 if err != nil {
177 return "", fmt.Errorf("failed to describe server at %s: %w", pdsURL, err)
178 }
179
180 if resp.Did == "" {
181 return "", fmt.Errorf("PDS at %s did not return a DID", pdsURL)
182 }
183
184 return resp.Did, nil
185}