···
19
-
// Community handle validation regex (DNS-valid handle: name.communities.instance.com)
19
+
// Community handle validation regex (DNS-valid handle: name.community.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])?$`)
23
+
// DNS label validation (RFC 1035: 1-63 chars, alphanumeric + hyphen, can't start/end with hyphen)
24
+
var dnsLabelRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
26
+
// Domain validation (simplified - checks for valid DNS hostname structure)
27
+
var domainRegex = 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 {
// Interfaces and pointers first (better alignment)
···
// Build community profile record
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
128
-
"handle": pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social)
134
+
"handle": pdsAccount.Handle, // atProto handle (e.g., gaming.community.coves.social)
"name": req.Name, // Short name for !mentions (e.g., "gaming")
"visibility": req.Visibility,
"hostedBy": s.instanceDID, // V2: Instance hosts, community owns
···
// Build Community object with PDS credentials AND cryptographic keys
DID: pdsAccount.DID, // Community's DID (owns the repo!)
184
-
Handle: pdsAccount.Handle, // atProto handle (e.g., gaming.communities.coves.social)
190
+
Handle: pdsAccount.Handle, // atProto handle (e.g., gaming.community.coves.social)
DisplayName: req.DisplayName,
Description: req.Description,
···
827
-
// ResolveCommunityIdentifier converts a handle or DID to a DID
833
+
// ResolveCommunityIdentifier converts a community identifier to a DID
834
+
// Following Bluesky's pattern with Coves extensions:
836
+
// Accepts (like Bluesky's at-identifier):
837
+
// 1. DID: did:plc:abc123 (pass through)
838
+
// 2. Canonical handle: gardening.community.coves.social (atProto standard)
839
+
// 3. At-identifier: @gardening.community.coves.social (strip @ prefix)
841
+
// Coves-specific extensions:
842
+
// 4. Scoped format: !gardening@coves.social (parse and resolve)
844
+
// Returns: DID string
func (s *communityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
846
+
identifier = strings.TrimSpace(identifier)
return "", ErrInvalidInput
833
-
// If it's already a DID, verify the community exists
852
+
// 1. DID - verify it exists and return (Bluesky standard)
if strings.HasPrefix(identifier, "did:") {
_, err := s.repo.GetByDID(ctx, identifier)
838
-
return "", fmt.Errorf("community not found: %w", err)
857
+
return "", fmt.Errorf("community not found for DID %s: %w", identifier, err)
840
-
return "", fmt.Errorf("failed to verify community DID: %w", err)
859
+
return "", fmt.Errorf("failed to verify community DID %s: %w", identifier, err)
845
-
// If it's a handle, look it up in AppView DB
864
+
// 2. Scoped format: !name@instance (Coves-specific)
if strings.HasPrefix(identifier, "!") {
847
-
community, err := s.repo.GetByHandle(ctx, identifier)
866
+
return s.resolveScopedIdentifier(ctx, identifier)
869
+
// 3. At-identifier format: @handle (Bluesky standard - strip @ prefix)
870
+
if strings.HasPrefix(identifier, "@") {
871
+
identifier = strings.TrimPrefix(identifier, "@")
874
+
// 4. Canonical handle: name.community.instance.com (Bluesky standard)
875
+
if strings.Contains(identifier, ".") {
876
+
community, err := s.repo.GetByHandle(ctx, strings.ToLower(identifier))
878
+
return "", fmt.Errorf("community not found for handle %s: %w", identifier, err)
return community.DID, nil
854
-
return "", NewValidationError("identifier", "must be a DID or handle")
883
+
return "", NewValidationError("identifier", "must be a DID, handle, or scoped identifier (!name@instance)")
886
+
// resolveScopedIdentifier handles Coves-specific !name@instance format
887
+
// Formats accepted:
888
+
// !gardening@coves.social -> gardening.community.coves.social
889
+
func (s *communityService) resolveScopedIdentifier(ctx context.Context, scoped string) (string, error) {
891
+
scoped = strings.TrimPrefix(scoped, "!")
894
+
var instanceDomain string
896
+
// Parse !name@instance
897
+
if !strings.Contains(scoped, "@") {
898
+
return "", NewValidationError("identifier", "scoped identifier must include @ symbol (!name@instance)")
901
+
parts := strings.SplitN(scoped, "@", 2)
902
+
name = strings.TrimSpace(parts[0])
903
+
instanceDomain = strings.TrimSpace(parts[1])
905
+
// Validate name format
907
+
return "", NewValidationError("identifier", "community name cannot be empty")
910
+
// Validate name is a valid DNS label (RFC 1035)
911
+
// Must be 1-63 chars, alphanumeric + hyphen, can't start/end with hyphen
912
+
if !isValidDNSLabel(name) {
913
+
return "", NewValidationError("identifier", "community name must be valid DNS label (alphanumeric and hyphens only, 1-63 chars, cannot start or end with hyphen)")
916
+
// Validate instance domain format
917
+
if !isValidDomain(instanceDomain) {
918
+
return "", NewValidationError("identifier", "invalid instance domain format")
921
+
// Normalize domain to lowercase (DNS is case-insensitive)
922
+
// This fixes the bug where !gardening@Coves.social would fail lookup
923
+
instanceDomain = strings.ToLower(instanceDomain)
925
+
// Validate the instance matches this server
926
+
if !s.isLocalInstance(instanceDomain) {
927
+
return "", NewValidationError("identifier",
928
+
fmt.Sprintf("community is not hosted on this instance (expected @%s)", s.instanceDomain))
931
+
// Construct canonical handle: {name}.community.{instanceDomain}
932
+
// Both name and instanceDomain are normalized to lowercase for consistent DB lookup
933
+
canonicalHandle := fmt.Sprintf("%s.community.%s",
934
+
strings.ToLower(name),
935
+
instanceDomain) // Already normalized to lowercase on line 923
937
+
// Look up by canonical handle
938
+
community, err := s.repo.GetByHandle(ctx, canonicalHandle)
940
+
return "", fmt.Errorf("community not found for scoped identifier !%s@%s: %w", name, instanceDomain, err)
943
+
return community.DID, nil
946
+
// isLocalInstance checks if the provided domain matches this instance
947
+
func (s *communityService) isLocalInstance(domain string) bool {
948
+
// Normalize both domains
949
+
domain = strings.ToLower(strings.TrimSpace(domain))
950
+
instanceDomain := strings.ToLower(s.instanceDomain)
953
+
return domain == instanceDomain
958
+
// isValidDNSLabel validates that a string is a valid DNS label per RFC 1035
959
+
// - 1-63 characters
960
+
// - Alphanumeric and hyphens only
961
+
// - Cannot start or end with hyphen
962
+
func isValidDNSLabel(label string) bool {
963
+
return dnsLabelRegex.MatchString(label)
966
+
// isValidDomain validates that a string is a valid domain name
967
+
// Simplified validation - checks basic DNS hostname structure
968
+
func isValidDomain(domain string) bool {
969
+
if domain == "" || len(domain) > 253 {
972
+
return domainRegex.MatchString(domain)
func (s *communityService) validateCreateRequest(req CreateCommunityRequest) error {
return NewValidationError("name", "required")
// DNS label limit: 63 characters per label
865
-
// Community handle format: {name}.communities.{instanceDomain}
981
+
// Community handle format: {name}.community.{instanceDomain}
// The first label is just req.Name, so it must be <= 63 chars
return NewValidationError("name", "must be 63 characters or less (DNS label limit)")