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.

+50 -35
+11 -11
internal/atproto/jetstream/post_consumer.go
··· 13 13 ) 14 14 15 15 // PostEventConsumer consumes post-related events from Jetstream 16 - // Currently handles only CREATE operations for social.coves.post.record 16 + // Currently handles only CREATE operations for social.coves.community.post 17 17 // UPDATE and DELETE handlers will be added when those features are implemented 18 - type PostEventConsumer struct { 18 + type PostEventConsumer struct{ 19 19 postRepo posts.Repository 20 20 communityRepo communities.Repository 21 21 userService users.UserService ··· 46 46 47 47 // Only handle post record creation for now 48 48 // UPDATE and DELETE will be added when we implement those features 49 - if commit.Collection == "social.coves.post.record" && commit.Operation == "create" { 49 + if commit.Collection == "social.coves.community.post" && commit.Operation == "create" { 50 50 return c.createPost(ctx, event.Did, commit) 51 51 } 52 52 ··· 73 73 } 74 74 75 75 // Build AT-URI for this post 76 - // Format: at://community_did/social.coves.post.record/rkey 77 - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey) 76 + // Format: at://community_did/social.coves.community.post/rkey 77 + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey) 78 78 79 79 // Parse timestamp from record 80 80 createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt) ··· 119 119 } 120 120 } 121 121 122 - if len(postRecord.ContentLabels) > 0 { 123 - labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels) 122 + if postRecord.Labels != nil { 123 + labelsJSON, marshalErr := json.Marshal(postRecord.Labels) 124 124 if marshalErr == nil { 125 125 labelsStr := string(labelsJSON) 126 126 post.ContentLabels = &labelsStr ··· 151 151 // This prevents users from creating posts that appear to be from communities they don't control 152 152 // 153 153 // Example attack prevented: 154 - // - User creates post in their own repo (at://user_did/social.coves.post.record/xyz) 154 + // - User creates post in their own repo (at://user_did/social.coves.community.post/xyz) 155 155 // - Claims it's for community X (community field = community_did) 156 156 // - Without this check, fake post would be indexed 157 157 // ··· 199 199 } 200 200 201 201 // PostRecordFromJetstream represents a post record as received from Jetstream 202 - // Matches the structure written to PDS via social.coves.post.record 203 - type PostRecordFromJetstream struct { 202 + // Matches the structure written to PDS via social.coves.community.post 203 + type PostRecordFromJetstream struct{ 204 204 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 205 205 FederatedFrom interface{} `json:"federatedFrom,omitempty"` 206 206 Location interface{} `json:"location,omitempty"` ··· 212 212 Author string `json:"author"` 213 213 CreatedAt string `json:"createdAt"` 214 214 Facets []interface{} `json:"facets,omitempty"` 215 - ContentLabels []string `json:"contentLabels,omitempty"` 215 + Labels *posts.SelfLabels `json:"labels,omitempty"` 216 216 } 217 217 218 218 // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+20 -7
internal/core/posts/post.go
··· 4 4 "time" 5 5 ) 6 6 7 + // SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels 8 + // This is the structured format used in atProto for content warnings 9 + type SelfLabels struct { 10 + Values []SelfLabel `json:"values"` 11 + } 12 + 13 + // SelfLabel represents a single label value per com.atproto.label.defs#selfLabel 14 + // Neg is optional and negates the label when true 15 + type SelfLabel struct { 16 + Val string `json:"val"` // Required: label value (max 128 chars) 17 + Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true 18 + } 19 + 7 20 // Post represents a post in the AppView database 8 21 // Posts are indexed from the firehose after being written to community repositories 9 22 type Post struct { ··· 12 25 EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"` 13 26 Embed *string `json:"embed,omitempty" db:"embed"` 14 27 DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 15 - ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"` 28 + ContentLabels *string `json:"labels,omitempty" db:"content_labels"` 16 29 Title *string `json:"title,omitempty" db:"title"` 17 30 Content *string `json:"content,omitempty" db:"content"` 18 31 ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` ··· 29 42 } 30 43 31 44 // CreatePostRequest represents input for creating a new post 32 - // Matches social.coves.post.create lexicon input schema 45 + // Matches social.coves.community.post.create lexicon input schema 33 46 type CreatePostRequest struct { 34 47 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 35 48 FederatedFrom interface{} `json:"federatedFrom,omitempty"` ··· 40 53 Community string `json:"community"` 41 54 AuthorDID string `json:"authorDid"` 42 55 Facets []interface{} `json:"facets,omitempty"` 43 - ContentLabels []string `json:"contentLabels,omitempty"` 56 + Labels *SelfLabels `json:"labels,omitempty"` 44 57 } 45 58 46 59 // CreatePostResponse represents the response from creating a post 47 - // Matches social.coves.post.create lexicon output schema 48 - type CreatePostResponse struct { 60 + // Matches social.coves.community.post.create lexicon output schema 61 + type CreatePostResponse struct{ 49 62 URI string `json:"uri"` // AT-URI of created post 50 63 CID string `json:"cid"` // CID of created post 51 64 } ··· 64 77 Author string `json:"author"` 65 78 CreatedAt string `json:"createdAt"` 66 79 Facets []interface{} `json:"facets,omitempty"` 67 - ContentLabels []string `json:"contentLabels,omitempty"` 80 + Labels *SelfLabels `json:"labels,omitempty"` 68 81 } 69 82 70 83 // PostView represents the full view of a post with all metadata 71 - // Matches social.coves.post.get#postView lexicon 84 + // Matches social.coves.community.post.get#postView lexicon 72 85 // Used in feeds and get endpoints 73 86 type PostView struct { 74 87 IndexedAt time.Time `json:"indexedAt"`
+19 -17
internal/core/posts/service.go
··· 145 145 146 146 // 8. Build post record for PDS 147 147 postRecord := PostRecord{ 148 - Type: "social.coves.post.record", 148 + Type: "social.coves.community.post", 149 149 Community: communityDID, 150 150 Author: req.AuthorDID, 151 151 Title: req.Title, 152 152 Content: req.Content, 153 153 Facets: req.Facets, 154 154 Embed: req.Embed, 155 - ContentLabels: req.ContentLabels, 155 + Labels: req.Labels, 156 156 OriginalAuthor: req.OriginalAuthor, 157 157 FederatedFrom: req.FederatedFrom, 158 158 Location: req.Location, ··· 187 187 func (s *postService) validateCreateRequest(req CreatePostRequest) error { 188 188 // Global content limits (from lexicon) 189 189 const ( 190 - maxContentLength = 50000 // 50k characters 191 - maxTitleLength = 3000 // 3k bytes 192 - maxTitleGraphemes = 300 // 300 graphemes (simplified check) 190 + maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon 191 + maxTitleLength = 3000 // 3k bytes 192 + maxTitleGraphemes = 300 // 300 graphemes (simplified check) 193 193 ) 194 194 195 195 // Validate community required ··· 219 219 } 220 220 221 221 // Validate content labels are from known values 222 - validLabels := map[string]bool{ 223 - "nsfw": true, 224 - "spoiler": true, 225 - "violence": true, 226 - } 227 - for _, label := range req.ContentLabels { 228 - if !validLabels[label] { 229 - return NewValidationError("contentLabels", 230 - fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label)) 222 + if req.Labels != nil { 223 + validLabels := map[string]bool{ 224 + "nsfw": true, 225 + "spoiler": true, 226 + "violence": true, 227 + } 228 + for _, label := range req.Labels.Values { 229 + if !validLabels[label.Val] { 230 + return NewValidationError("labels", 231 + fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val)) 232 + } 231 233 } 232 234 } 233 235 ··· 257 259 // IMPORTANT: repo is set to community DID, not author DID 258 260 // This writes the post to the community's repository 259 261 payload := map[string]interface{}{ 260 - "repo": community.DID, // Community's repository 261 - "collection": "social.coves.post.record", // Collection type 262 - "record": record, // The post record 262 + "repo": community.DID, // Community's repository 263 + "collection": "social.coves.community.post", // Collection type 264 + "record": record, // The post record 263 265 // "rkey" omitted - PDS will auto-generate TID 264 266 } 265 267