···
4
-
"Coves/internal/core/users"
11
-
"golang.org/x/crypto/bcrypt"
10
+
"github.com/bluesky-social/indigo/api/atproto"
11
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/xrpc"
// CommunityPDSAccount represents PDS account credentials for a community
type 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
17
+
DID string // Community's DID (owns the repository)
18
+
Handle string // Community's handle (e.g., gaming.communities.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)
25
-
// PDSAccountProvisioner creates PDS accounts for communities
28
+
// PDSAccountProvisioner creates PDS accounts for communities with PDS-managed DIDs
type PDSAccountProvisioner struct {
27
-
userService users.UserService
31
+
pdsURL string // URL to call PDS (e.g., http://localhost:3001)
32
-
// NewPDSAccountProvisioner creates a new provisioner
33
-
func NewPDSAccountProvisioner(userService users.UserService, instanceDomain, pdsURL string) *PDSAccountProvisioner {
34
+
// NewPDSAccountProvisioner creates a new provisioner for V2.0 (PDS-managed keys)
35
+
func NewPDSAccountProvisioner(instanceDomain, pdsURL string) *PDSAccountProvisioner {
return &PDSAccountProvisioner{
35
-
userService: userService,
instanceDomain: instanceDomain,
41
-
// ProvisionCommunityAccount creates a real PDS account for a community
42
+
// ProvisionCommunityAccount creates a real PDS account for a community with PDS-managed keys
44
+
// V2.0 Architecture (PDS-Managed Keys):
45
+
// 1. Generates community handle and credentials
46
+
// 2. Calls com.atproto.server.createAccount (PDS generates DID and keys)
47
+
// 3. Returns credentials for storage
49
+
// V2.0 Design Philosophy:
50
+
// - PDS manages ALL cryptographic keys (signing + rotation)
51
+
// - Communities can migrate between Coves-controlled PDSs using standard atProto migration
52
+
// - Simpler, faster, ships immediately
53
+
// - Migration uses com.atproto.server.getServiceAuth + standard migration endpoints
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
55
+
// Future V2.1 (Optional Portability Enhancement):
56
+
// - Add Coves-controlled rotation key alongside PDS rotation key
57
+
// - Enables migration to non-Coves PDSs
58
+
// - Implement when actual external migration is needed
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)
60
+
// SECURITY: The returned credentials MUST be encrypted before database storage
func (p *PDSAccountProvisioner) ProvisionCommunityAccount(
···
return nil, fmt.Errorf("community name is required")
64
-
// 1. Generate unique handle for the community using subdomain
65
-
// This makes it immediately clear these are communities, not user accounts
69
+
// 1. Generate unique handle for the community
// Format: {name}.communities.{instance-domain}
71
+
// Example: "gaming.communities.coves.social"
handle := fmt.Sprintf("%s.communities.%s", strings.ToLower(communityName), p.instanceDomain)
68
-
// Example: "gaming.communities.coves.social" (much cleaner!)
// 2. Generate system email for PDS account management
// This email is used for account operations, not for user communication
email := fmt.Sprintf("community-%s@communities.%s", strings.ToLower(communityName), p.instanceDomain)
73
-
// Example: "community-gaming@communities.coves.social"
// 3. Generate secure random password (32 characters)
// This password is never shown to users - it's for Coves to authenticate as the community
···
return nil, fmt.Errorf("failed to generate password: %w", err)
82
-
// 4. Call PDS com.atproto.server.createAccount
85
+
// 4. Create PDS account - let PDS generate DID and all keys
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
89
-
// Note: No inviteCode needed for our local PDS (configure PDS with invites disabled)
90
-
resp, err := p.userService.RegisterAccount(ctx, users.RegisterAccountRequest{
87
+
// 1. Generate a signing keypair (stored in PDS, never exported)
88
+
// 2. Generate rotation keys (stored in PDS)
89
+
// 3. Create a DID (did:plc:xxx)
90
+
// 4. Register DID with PLC directory
91
+
// 5. Return credentials (DID, handle, tokens)
92
+
client := &xrpc.Client{
97
+
passwordStr := password
99
+
input := &atproto.ServerCreateAccount_Input{
94
-
// InviteCode: "", // Not needed if PDS has open registration or we're admin
97
-
return nil, fmt.Errorf("PDS account creation failed for community %s: %w", communityName, err)
102
+
Password: &passwordStr,
103
+
// No Did parameter - let PDS generate it
104
+
// No RecoveryKey - PDS manages rotation keys
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)
107
+
output, err := atproto.ServerCreateAccount(ctx, client, input)
105
-
return nil, fmt.Errorf("failed to hash password: %w", err)
109
+
return nil, fmt.Errorf("PDS account creation failed for community %s: %w", communityName, err)
108
-
// 6. Return account credentials
112
+
// 5. Return account credentials with cleartext password
113
+
// CRITICAL: The password MUST be encrypted (not hashed) before database storage
114
+
// We need to recover the plaintext password to call com.atproto.server.createSession
115
+
// when access/refresh tokens expire (90-day window on refresh tokens)
116
+
// The repository layer handles encryption using pgp_sym_encrypt()
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
118
+
DID: output.Did, // The community's DID (PDS-generated)
119
+
Handle: output.Handle, // e.g., gaming.communities.coves.social
120
+
Email: email, // community-gaming@communities.coves.social
121
+
Password: password, // Cleartext - will be encrypted by repository
122
+
AccessToken: output.AccessJwt, // JWT for making API calls
123
+
RefreshToken: output.RefreshJwt, // For refreshing sessions
124
+
PDSURL: p.pdsURL, // PDS hosting this community
125
+
RotationKeyPEM: "", // Empty - PDS manages keys (V2.1: add Coves rotation key)
126
+
SigningKeyPEM: "", // Empty - PDS manages keys
···
155
+
// FetchPDSDID queries the PDS to get its DID via com.atproto.server.describeServer
156
+
// This is the proper way to get the PDS DID rather than hardcoding it
157
+
// Works in both development (did:web:localhost) and production (did:web:pds.example.com)
158
+
func FetchPDSDID(ctx context.Context, pdsURL string) (string, error) {
159
+
client := &xrpc.Client{
163
+
resp, err := comatproto.ServerDescribeServer(ctx, client)
165
+
return "", fmt.Errorf("failed to describe server at %s: %w", pdsURL, err)
168
+
if resp.Did == "" {
169
+
return "", fmt.Errorf("PDS at %s did not return a DID", pdsURL)
172
+
return resp.Did, nil