A community based topic aggregation platform built on atproto

feat(labels): implement com.atproto.label.defs#selfLabels structure

Implements proper atproto label structure with optional 'neg' field for negating labels.
This fixes client-supplied labels being dropped and ensures full round-trip compatibility.

Changes:
- Add SelfLabels and SelfLabel structs per com.atproto.label.defs spec
- SelfLabel includes Val (required) and Neg (optional bool pointer) fields
- Update CreatePostRequest.Labels from []string to *SelfLabels
- Update PostRecord.Labels to structured format
- Update validation logic to iterate over Labels.Values
- Update jetstream consumer to use structured labels

Before: Labels were []string, breaking in 3 ways:
1. Client-supplied structured labels ignored (JSON decoder drops object)
2. PDS rejects unknown contentLabels array field
3. Jetstream consumer marshals incorrectly

After: Full com.atproto.label.defs#selfLabels support with neg field preservation.

Changed files
+50 -35
internal
atproto
jetstream
core
+11 -11
internal/atproto/jetstream/post_consumer.go
···
)
// PostEventConsumer consumes post-related events from Jetstream
-
// Currently handles only CREATE operations for social.coves.post.record
+
// Currently handles only CREATE operations for social.coves.community.post
// UPDATE and DELETE handlers will be added when those features are implemented
-
type PostEventConsumer struct {
+
type PostEventConsumer struct{
postRepo posts.Repository
communityRepo communities.Repository
userService users.UserService
···
// Only handle post record creation for now
// UPDATE and DELETE will be added when we implement those features
-
if commit.Collection == "social.coves.post.record" && commit.Operation == "create" {
+
if commit.Collection == "social.coves.community.post" && commit.Operation == "create" {
return c.createPost(ctx, event.Did, commit)
}
···
}
// Build AT-URI for this post
-
// Format: at://community_did/social.coves.post.record/rkey
-
uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey)
+
// Format: at://community_did/social.coves.community.post/rkey
+
uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey)
// Parse timestamp from record
createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt)
···
}
}
-
if len(postRecord.ContentLabels) > 0 {
-
labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels)
+
if postRecord.Labels != nil {
+
labelsJSON, marshalErr := json.Marshal(postRecord.Labels)
if marshalErr == nil {
labelsStr := string(labelsJSON)
post.ContentLabels = &labelsStr
···
// This prevents users from creating posts that appear to be from communities they don't control
//
// Example attack prevented:
-
// - User creates post in their own repo (at://user_did/social.coves.post.record/xyz)
+
// - User creates post in their own repo (at://user_did/social.coves.community.post/xyz)
// - Claims it's for community X (community field = community_did)
// - Without this check, fake post would be indexed
//
···
}
// PostRecordFromJetstream represents a post record as received from Jetstream
-
// Matches the structure written to PDS via social.coves.post.record
-
type PostRecordFromJetstream struct {
+
// Matches the structure written to PDS via social.coves.community.post
+
type PostRecordFromJetstream struct{
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
Location interface{} `json:"location,omitempty"`
···
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
-
ContentLabels []string `json:"contentLabels,omitempty"`
+
Labels *posts.SelfLabels `json:"labels,omitempty"`
}
// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+20 -7
internal/core/posts/post.go
···
"time"
)
+
// SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels
+
// This is the structured format used in atProto for content warnings
+
type SelfLabels struct {
+
Values []SelfLabel `json:"values"`
+
}
+
+
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
+
// Neg is optional and negates the label when true
+
type SelfLabel struct {
+
Val string `json:"val"` // Required: label value (max 128 chars)
+
Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
+
}
+
// Post represents a post in the AppView database
// Posts are indexed from the firehose after being written to community repositories
type Post struct {
···
EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"`
Embed *string `json:"embed,omitempty" db:"embed"`
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
-
ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"`
+
ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
Title *string `json:"title,omitempty" db:"title"`
Content *string `json:"content,omitempty" db:"content"`
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
···
}
// CreatePostRequest represents input for creating a new post
-
// Matches social.coves.post.create lexicon input schema
+
// Matches social.coves.community.post.create lexicon input schema
type CreatePostRequest struct {
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
···
Community string `json:"community"`
AuthorDID string `json:"authorDid"`
Facets []interface{} `json:"facets,omitempty"`
-
ContentLabels []string `json:"contentLabels,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
}
// CreatePostResponse represents the response from creating a post
-
// Matches social.coves.post.create lexicon output schema
-
type CreatePostResponse struct {
+
// Matches social.coves.community.post.create lexicon output schema
+
type CreatePostResponse struct{
URI string `json:"uri"` // AT-URI of created post
CID string `json:"cid"` // CID of created post
}
···
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
-
ContentLabels []string `json:"contentLabels,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
}
// PostView represents the full view of a post with all metadata
-
// Matches social.coves.post.get#postView lexicon
+
// Matches social.coves.community.post.get#postView lexicon
// Used in feeds and get endpoints
type PostView struct {
IndexedAt time.Time `json:"indexedAt"`
+19 -17
internal/core/posts/service.go
···
// 8. Build post record for PDS
postRecord := PostRecord{
-
Type: "social.coves.post.record",
+
Type: "social.coves.community.post",
Community: communityDID,
Author: req.AuthorDID,
Title: req.Title,
Content: req.Content,
Facets: req.Facets,
Embed: req.Embed,
-
ContentLabels: req.ContentLabels,
+
Labels: req.Labels,
OriginalAuthor: req.OriginalAuthor,
FederatedFrom: req.FederatedFrom,
Location: req.Location,
···
func (s *postService) validateCreateRequest(req CreatePostRequest) error {
// Global content limits (from lexicon)
const (
-
maxContentLength = 50000 // 50k characters
-
maxTitleLength = 3000 // 3k bytes
-
maxTitleGraphemes = 300 // 300 graphemes (simplified check)
+
maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon
+
maxTitleLength = 3000 // 3k bytes
+
maxTitleGraphemes = 300 // 300 graphemes (simplified check)
)
// Validate community required
···
}
// Validate content labels are from known values
-
validLabels := map[string]bool{
-
"nsfw": true,
-
"spoiler": true,
-
"violence": true,
-
}
-
for _, label := range req.ContentLabels {
-
if !validLabels[label] {
-
return NewValidationError("contentLabels",
-
fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label))
+
if req.Labels != nil {
+
validLabels := map[string]bool{
+
"nsfw": true,
+
"spoiler": true,
+
"violence": true,
+
}
+
for _, label := range req.Labels.Values {
+
if !validLabels[label.Val] {
+
return NewValidationError("labels",
+
fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val))
+
}
}
}
···
// IMPORTANT: repo is set to community DID, not author DID
// This writes the post to the community's repository
payload := map[string]interface{}{
-
"repo": community.DID, // Community's repository
-
"collection": "social.coves.post.record", // Collection type
-
"record": record, // The post record
+
"repo": community.DID, // Community's repository
+
"collection": "social.coves.community.post", // Collection type
+
"record": record, // The post record
// "rkey" omitted - PDS will auto-generate TID
}