A community based topic aggregation platform built on atproto

fix(handlers): enforce lexicon compliance - DIDs only, no handles

**Breaking Change**: XRPC endpoints now strictly enforce lexicon spec.

Changed endpoints to reject handles and accept ONLY DIDs:
- social.coves.community.blockCommunity
- social.coves.community.unblockCommunity
- social.coves.community.subscribe
- social.coves.community.unsubscribe

Rationale:
1. Lexicon defines "subject" field with format: "did" (not "at-identifier")
2. Records are immutable and content-addressed - must use permanent DIDs
3. Handles can change (they're DNS pointers), DIDs cannot
4. Bluesky's app.bsky.graph.block uses same pattern (DID-only)

Previous behavior accepted both DIDs and handles, resolving handles to
DIDs internally. This was convenient but violated the lexicon contract.

Impact:
- Clients must resolve handles to DIDs before calling these endpoints
- Matches standard atProto patterns for block/subscription records
- Ensures federation compatibility

This aligns our implementation with the lexicon specification and
atProto best practices.

Changed files
+54 -43
internal
api
handlers
+31 -39
internal/api/handlers/community/block.go
···
}
// HandleBlock blocks a community
-
// POST /xrpc/social.coves.community.block
-
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
// POST /xrpc/social.coves.community.blockCommunity
+
//
+
// Request body: { "community": "did:plc:xxx" }
+
// Note: Per lexicon spec, only DIDs are accepted (not handles).
+
// The block record's "subject" field requires format: "did".
func (h *BlockHandler) HandleBlock(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse request body
var req struct {
-
Community string `json:"community"` // DID or handle
+
Community string `json:"community"` // DID only (per lexicon)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
···
return
}
-
// Validate format (DID or handle) with proper regex patterns
-
if strings.HasPrefix(req.Community, "did:") {
-
// Validate DID format: did:method:identifier
-
// atProto supports did:plc and did:web
-
didRegex := regexp.MustCompile(`^did:(plc|web):[a-zA-Z0-9._:%-]+$`)
-
if !didRegex.MatchString(req.Community) {
-
writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
-
return
-
}
-
} else if strings.HasPrefix(req.Community, "!") {
-
// Validate handle format: !name@domain.tld
-
if !strings.Contains(req.Community, "@") {
-
writeError(w, http.StatusBadRequest, "InvalidRequest", "handle must contain @domain")
-
return
-
}
-
} else {
+
// Validate DID format (per lexicon: format must be "did")
+
if !strings.HasPrefix(req.Community, "did:") {
writeError(w, http.StatusBadRequest, "InvalidRequest",
-
"community must be a DID (did:plc:...) or handle (!name@instance.com)")
+
"community must be a DID (did:plc:... or did:web:...)")
+
return
+
}
+
+
// Validate DID format with regex: did:method:identifier
+
didRegex := regexp.MustCompile(`^did:(plc|web):[a-zA-Z0-9._:%-]+$`)
+
if !didRegex.MatchString(req.Community) {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
return
}
···
}
// HandleUnblock unblocks a community
-
// POST /xrpc/social.coves.community.unblock
-
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
// POST /xrpc/social.coves.community.unblockCommunity
+
//
+
// Request body: { "community": "did:plc:xxx" }
+
// Note: Per lexicon spec, only DIDs are accepted (not handles).
func (h *BlockHandler) HandleUnblock(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse request body
var req struct {
-
Community string `json:"community"`
+
Community string `json:"community"` // DID only (per lexicon)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
···
return
}
-
// Validate format (DID or handle) with proper regex patterns
-
if strings.HasPrefix(req.Community, "did:") {
-
// Validate DID format: did:method:identifier
-
didRegex := regexp.MustCompile(`^did:(plc|web):[a-zA-Z0-9._:%-]+$`)
-
if !didRegex.MatchString(req.Community) {
-
writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
-
return
-
}
-
} else if strings.HasPrefix(req.Community, "!") {
-
// Validate handle format: !name@domain.tld
-
if !strings.Contains(req.Community, "@") {
-
writeError(w, http.StatusBadRequest, "InvalidRequest", "handle must contain @domain")
-
return
-
}
-
} else {
+
// Validate DID format (per lexicon: format must be "did")
+
if !strings.HasPrefix(req.Community, "did:") {
writeError(w, http.StatusBadRequest, "InvalidRequest",
-
"community must be a DID (did:plc:...) or handle (!name@instance.com)")
+
"community must be a DID (did:plc:... or did:web:...)")
+
return
+
}
+
+
// Validate DID format with regex: did:method:identifier
+
didRegex := regexp.MustCompile(`^did:(plc|web):[a-zA-Z0-9._:%-]+$`)
+
if !didRegex.MatchString(req.Community) {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
return
}
+23 -4
internal/api/handlers/community/subscribe.go
···
"encoding/json"
"log"
"net/http"
+
"strings"
)
// SubscribeHandler handles community subscriptions
···
// HandleSubscribe subscribes a user to a community
// POST /xrpc/social.coves.community.subscribe
-
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
//
+
// Request body: { "community": "did:plc:xxx", "contentVisibility": 3 }
+
// Note: Per lexicon spec, only DIDs are accepted for the "subject" field (not handles).
func (h *SubscribeHandler) HandleSubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse request body
var req struct {
-
Community string `json:"community"`
+
Community string `json:"community"` // DID only (per lexicon)
ContentVisibility int `json:"contentVisibility"` // Optional: 1-5 scale, defaults to 3
}
···
if req.Community == "" {
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// Validate DID format (per lexicon: subject field requires format "did")
+
if !strings.HasPrefix(req.Community, "did:") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"community must be a DID (did:plc:... or did:web:...)")
return
}
···
// HandleUnsubscribe unsubscribes a user from a community
// POST /xrpc/social.coves.community.unsubscribe
-
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
//
+
// Request body: { "community": "did:plc:xxx" }
+
// Note: Per lexicon spec, only DIDs are accepted (not handles).
func (h *SubscribeHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···
// Parse request body
var req struct {
-
Community string `json:"community"`
+
Community string `json:"community"` // DID only (per lexicon)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
···
if req.Community == "" {
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// Validate DID format (per lexicon: subject field requires format "did")
+
if !strings.HasPrefix(req.Community, "did:") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"community must be a DID (did:plc:... or did:web:...)")
return
}