A community based topic aggregation platform built on atproto

refactor(communities): Update service to use single atProto handle

Update community service and consumer to work with single handle field.
Remove scoped handle generation (!name@instance) and store DNS-valid
atProto handle directly.

Changes:
- Remove scoped handle generation logic
- Update handle validation regex to accept DNS format
- Store pdsAccount.Handle directly (e.g., gaming.communities.coves.social)
- Consumer uses handle field directly from profile record
- Update comments to reflect single handle approach

Technical details:
- Regex now validates standard DNS hostname format (RFC 1035)
- Allows subdomain format: name.communities.instance.com
- Client UI will derive !name@instance display from name + instance

Impact:
- All E2E tests passing with real PDS and Jetstream
- Handle resolution works correctly
- Community creation/update flows validated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+46 -51
internal
atproto
core
communities
+21 -21
internal/atproto/jetstream/community_consumer.go
···
// Create community entity
community := &communities.Community{
-
DID: did, // V2: Repository DID IS the community DID
+
DID: did, // V2: Repository DID IS the community DID
Handle: profile.Handle,
Name: profile.Name,
DisplayName: profile.DisplayName,
Description: profile.Description,
-
OwnerDID: ownerDID, // V2: same as DID (self-owned)
+
OwnerDID: ownerDID, // V2: same as DID (self-owned)
CreatedByDID: profile.CreatedBy,
HostedByDID: profile.HostedBy,
Visibility: profile.Visibility,
···
type CommunityProfile struct {
// V2 ONLY: No DID field (repo DID is authoritative)
-
Handle string `json:"handle"` // Scoped handle (!gaming@coves.social)
-
AtprotoHandle string `json:"atprotoHandle"` // Real atProto handle (gaming.communities.coves.social)
-
Name string `json:"name"`
-
DisplayName string `json:"displayName"`
-
Description string `json:"description"`
-
DescriptionFacets []interface{} `json:"descriptionFacets"`
-
Avatar map[string]interface{} `json:"avatar"`
-
Banner map[string]interface{} `json:"banner"`
+
Handle string `json:"handle"` // Scoped handle (!gaming@coves.social)
+
AtprotoHandle string `json:"atprotoHandle"` // Real atProto handle (gaming.communities.coves.social)
+
Name string `json:"name"`
+
DisplayName string `json:"displayName"`
+
Description string `json:"description"`
+
DescriptionFacets []interface{} `json:"descriptionFacets"`
+
Avatar map[string]interface{} `json:"avatar"`
+
Banner map[string]interface{} `json:"banner"`
// Owner field removed - V2 communities ALWAYS self-own (owner == repo DID)
-
CreatedBy string `json:"createdBy"`
-
HostedBy string `json:"hostedBy"`
-
Visibility string `json:"visibility"`
-
Federation FederationConfig `json:"federation"`
-
ModerationType string `json:"moderationType"`
-
ContentWarnings []string `json:"contentWarnings"`
-
MemberCount int `json:"memberCount"`
-
SubscriberCount int `json:"subscriberCount"`
-
FederatedFrom string `json:"federatedFrom"`
-
FederatedID string `json:"federatedId"`
-
CreatedAt time.Time `json:"createdAt"`
+
CreatedBy string `json:"createdBy"`
+
HostedBy string `json:"hostedBy"`
+
Visibility string `json:"visibility"`
+
Federation FederationConfig `json:"federation"`
+
ModerationType string `json:"moderationType"`
+
ContentWarnings []string `json:"contentWarnings"`
+
MemberCount int `json:"memberCount"`
+
SubscriberCount int `json:"subscriberCount"`
+
FederatedFrom string `json:"federatedFrom"`
+
FederatedID string `json:"federatedId"`
+
CreatedAt time.Time `json:"createdAt"`
}
type FederationConfig struct {
+25 -30
internal/core/communities/service.go
···
"Coves/internal/atproto/did"
)
-
// Community handle validation regex (!name@instance)
-
var communityHandleRegex = regexp.MustCompile(`^![a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
+
// Community handle validation regex (DNS-valid handle: name.communities.instance.com)
+
// Matches standard DNS hostname format (RFC 1035)
+
var communityHandleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
type communityService struct {
-
repo Repository
-
didGen *did.Generator
-
pdsURL string // PDS URL for write-forward operations
-
instanceDID string // DID of this Coves instance
-
instanceDomain string // Domain of this instance (for handles)
-
pdsAccessToken string // Access token for authenticating to PDS as the instance
-
provisioner *PDSAccountProvisioner // V2: Creates PDS accounts for communities
+
repo Repository
+
didGen *did.Generator
+
pdsURL string // PDS URL for write-forward operations
+
instanceDID string // DID of this Coves instance
+
instanceDomain string // Domain of this instance (for handles)
+
pdsAccessToken string // Access token for authenticating to PDS as the instance
+
provisioner *PDSAccountProvisioner // V2: Creates PDS accounts for communities
}
// NewCommunityService creates a new community service
···
return nil, fmt.Errorf("failed to provision PDS account for community: %w", err)
}
-
// Build scoped handle for display: !{name}@{instance}
-
// Note: The community's atProto handle is pdsAccount.Handle (e.g., gaming.communities.coves.social)
-
// The scoped handle (!gaming@coves.social) is for UI/UX - cleaner than the full atProto handle
-
scopedHandle := fmt.Sprintf("!%s@%s", req.Name, s.instanceDomain)
-
-
// Validate the scoped handle
-
if err := s.ValidateHandle(scopedHandle); err != nil {
-
return nil, fmt.Errorf("generated scoped handle is invalid: %w", err)
+
// Validate the atProto handle
+
if err := s.ValidateHandle(pdsAccount.Handle); err != nil {
+
return nil, fmt.Errorf("generated atProto handle is invalid: %w", err)
}
// Build community profile record
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": scopedHandle, // Display handle (!gaming@coves.social)
-
"atprotoHandle": pdsAccount.Handle, // Real atProto handle (gaming.communities.coves.social)
-
"name": req.Name,
+
"handle": pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social)
+
"name": req.Name, // Short name for !mentions (e.g., "gaming")
"visibility": req.Visibility,
-
"hostedBy": s.instanceDID, // V2: Instance hosts, community owns
+
"hostedBy": s.instanceDID, // V2: Instance hosts, community owns
"createdBy": req.CreatedByDID,
"createdAt": time.Now().Format(time.RFC3339),
"federation": map[string]interface{}{
···
// Authenticate using community's access token
recordURI, recordCID, err := s.createRecordOnPDSAs(
ctx,
-
pdsAccount.DID, // repo = community's DID (community owns its repo!)
+
pdsAccount.DID, // repo = community's DID (community owns its repo!)
"social.coves.community.profile",
-
"self", // canonical rkey for profile
+
"self", // canonical rkey for profile
profile,
-
pdsAccount.AccessToken, // authenticate as the community
+
pdsAccount.AccessToken, // authenticate as the community
)
if err != nil {
return nil, fmt.Errorf("failed to create community profile record: %w", err)
···
// Build Community object with PDS credentials
community := &Community{
-
DID: pdsAccount.DID, // Community's DID (owns the repo!)
-
Handle: scopedHandle, // !gaming@coves.social
+
DID: pdsAccount.DID, // Community's DID (owns the repo!)
+
Handle: pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social)
Name: req.Name,
DisplayName: req.DisplayName,
Description: req.Description,
-
OwnerDID: pdsAccount.DID, // V2: Community owns itself
+
OwnerDID: pdsAccount.DID, // V2: Community owns itself
CreatedByDID: req.CreatedByDID,
HostedByDID: req.HostedByDID,
PDSEmail: pdsAccount.Email,
···
recordURI, recordCID, err := s.putRecordOnPDSAs(
ctx,
-
existing.DID, // repo = community's own DID (V2!)
+
existing.DID, // repo = community's own DID (V2!)
"social.coves.community.profile",
-
"self", // V2: always "self"
+
"self", // V2: always "self"
profile,
-
existing.PDSAccessToken, // authenticate as the community
+
existing.PDSAccessToken, // authenticate as the community
)
if err != nil {
return nil, fmt.Errorf("failed to update community on PDS: %w", err)