A community based topic aggregation platform built on atproto

Merge branch 'feat/lexicon-labels-jsonb-storage'

+612 -770
+3 -3
aggregators/kagi-news/src/coves_client.py
··· 17 17 18 18 Handles: 19 19 - Authentication with aggregator credentials 20 - - Creating posts in communities (social.coves.post.create) 20 + - Creating posts in communities (social.coves.community.post.create) 21 21 - External embed formatting 22 22 """ 23 23 ··· 94 94 self.authenticate() 95 95 96 96 try: 97 - # Prepare post data for social.coves.post.create endpoint 97 + # Prepare post data for social.coves.community.post.create endpoint 98 98 post_data = { 99 99 "community": community_handle, 100 100 "content": content, ··· 114 114 logger.info(f"Creating post in community: {community_handle}") 115 115 116 116 # Make direct HTTP request to XRPC endpoint 117 - url = f"{self.api_url}/xrpc/social.coves.post.create" 117 + url = f"{self.api_url}/xrpc/social.coves.community.post.create" 118 118 headers = { 119 119 "Authorization": f"Bearer {self.client._session.access_jwt}", 120 120 "Content-Type": "application/json"
+2 -2
cmd/server/main.go
··· 306 306 postJetstreamURL := os.Getenv("POST_JETSTREAM_URL") 307 307 if postJetstreamURL == "" { 308 308 // Listen to post record creation events 309 - postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.post.record" 309 + postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.post" 310 310 } 311 311 312 312 postEventConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService) ··· 319 319 }() 320 320 321 321 log.Printf("Started Jetstream post consumer: %s", postJetstreamURL) 322 - log.Println(" - Indexing: social.coves.post.record CREATE operations") 322 + log.Println(" - Indexing: social.coves.community.post CREATE operations") 323 323 log.Println(" - UPDATE/DELETE indexing deferred until those features are implemented") 324 324 325 325 // Start Jetstream consumer for aggregators
+17 -10
cmd/validate-lexicon/main.go
··· 119 119 } 120 120 121 121 for i, schemaID := range schemaIDs { 122 + // Skip validation for definition-only files (*.defs) - they don't need a "main" section 123 + // These files only contain shared type definitions referenced by other schemas 124 + if strings.HasSuffix(schemaID, ".defs") { 125 + if verbose { 126 + fmt.Printf(" ⏭️ %s (defs-only file, skipping main validation)\n", schemaID) 127 + } 128 + continue 129 + } 130 + 122 131 if _, err := catalog.Resolve(schemaID); err != nil { 123 132 validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err)) 124 133 } else if verbose { ··· 415 424 "social.coves.richtext.facet#spoiler", 416 425 417 426 // Post types and views 418 - "social.coves.post.get#postView", 419 - "social.coves.post.get#authorView", 420 - "social.coves.post.get#communityRef", 421 - "social.coves.post.get#imageView", 422 - "social.coves.post.get#videoView", 423 - "social.coves.post.get#externalView", 424 - "social.coves.post.get#postStats", 425 - "social.coves.post.get#viewerState", 427 + "social.coves.community.post.get#postView", 428 + "social.coves.community.post.get#authorView", 429 + "social.coves.community.post.get#communityRef", 430 + "social.coves.community.post.get#postStats", 431 + "social.coves.community.post.get#viewerState", 432 + "social.coves.community.post.get#notFoundPost", 433 + "social.coves.community.post.get#blockedPost", 426 434 427 - // Post record types 428 - "social.coves.post.record#originalAuthor", 435 + // Post record types (removed - no longer exists in new structure) 429 436 430 437 // Actor definitions 431 438 "social.coves.actor.profile#geoLocation",
+5 -5
docs/COMMUNITY_FEEDS.md
··· 182 182 183 183 ```go 184 184 type PostView struct { 185 - URI string // at://did:plc:abc/social.coves.post.record/123 185 + URI string // at://did:plc:abc/social.coves.community.post.record/123 186 186 CID string // Content ID 187 187 RKey string // Record key (TID) 188 188 Author *AuthorView // Author with handle, avatar, reputation ··· 247 247 "feed": [ 248 248 { 249 249 "post": { 250 - "uri": "at://did:plc:gaming123/social.coves.post.record/abc", 250 + "uri": "at://did:plc:gaming123/social.coves.community.post.record/abc", 251 251 "cid": "bafyrei...", 252 252 "author": { 253 253 "did": "did:plc:alice", ··· 473 473 GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes 474 474 → Returns: [uri1, uri2, uri3, ...] 475 475 476 - GET /xrpc/social.coves.post.get?uris=[...] 476 + GET /xrpc/social.coves.community.post.get?uris=[...] 477 477 → Returns: [full posts] 478 478 ``` 479 479 ··· 556 556 557 557 ## Lexicon Updates 558 558 559 - ### Updated: `social.coves.post.get` 559 + ### Updated: `social.coves.community.post.get` 560 560 561 561 **Changes:** 562 562 1. ✅ Batch URIs: `uri` → `uris[]` (max 25) ··· 638 638 // Custom feed (power users) 639 639 GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes 640 640 → Returns URIs 641 - GET /xrpc/social.coves.post.get?uris=[...] 641 + GET /xrpc/social.coves.community.post.get?uris=[...] 642 642 → Hydrates posts 643 643 ``` 644 644
+1 -1
docs/FEED_SYSTEM_IMPLEMENTATION.md
··· 222 222 "feed": [ 223 223 { 224 224 "post": { 225 - "uri": "at://did:plc:community-gaming/social.coves.post.record/3k...", 225 + "uri": "at://did:plc:community-gaming/social.coves.community.post.record/3k...", 226 226 "cid": "bafyrei...", 227 227 "author": { 228 228 "did": "did:plc:alice",
+1 -1
docs/PRD_GOVERNANCE.md
··· 291 291 - [ ] Go structs: `ContentRules` type in community models 292 292 - [ ] Repository: Parse and store `contentRules` from community profiles 293 293 - [ ] Service: `ValidatePostAgainstRules(post, community)` function 294 - - [ ] Handler: Integrate validation into `social.coves.post.create` 294 + - [ ] Handler: Integrate validation into `social.coves.community.post.create` 295 295 - [ ] AppView indexing: Index post characteristics (embed_type, text_length, etc.) 296 296 - [ ] Tests: Comprehensive rule validation tests 297 297 - [ ] Documentation: Content rules guide for community creators
+17 -17
docs/PRD_POSTS.md
··· 45 45 46 46 **Repository Structure:** 47 47 ``` 48 - Repository: at://did:plc:community789/social.coves.post.record/3k2a4b5c6d7e 48 + Repository: at://did:plc:community789/social.coves.community.post.record/3k2a4b5c6d7e 49 49 Owner: did:plc:community789 (community owns the post) 50 50 Author: did:plc:user123 (tracked in record metadata) 51 51 Hosted By: did:web:coves.social (instance manages community credentials) ··· 77 77 78 78 **Implementation checklist:** 79 79 - [x] Lexicon: `contentRules` in `social.coves.community.profile` ✅ 80 - - [x] Lexicon: `postType` removed from `social.coves.post.create` ✅ 80 + - [x] Lexicon: `postType` removed from `social.coves.community.post.create` ✅ 81 81 - [ ] Validation: `ValidatePostAgainstRules()` service function 82 82 - [ ] Handler: Integrate validation in post creation endpoint 83 83 - [ ] AppView: Index derived characteristics (embed_type, text_length, etc.) ··· 90 90 **Priority:** CRITICAL - Posts are the foundation of the platform 91 91 92 92 #### Create Post 93 - - [x] Lexicon: `social.coves.post.record` ✅ 94 - - [x] Lexicon: `social.coves.post.create` ✅ 93 + - [x] Lexicon: `social.coves.community.post.record` ✅ 94 + - [x] Lexicon: `social.coves.community.post.create` ✅ 95 95 - [x] Removed `postType` enum in favor of content rules ✅ (2025-10-18) 96 96 - [x] Removed `postType` from record and get lexicons ✅ (2025-10-18) 97 - - [x] **Handler:** `POST /xrpc/social.coves.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md) 97 + - [x] **Handler:** `POST /xrpc/social.coves.community.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md) 98 98 - ✅ Accept: community (DID/handle), title (optional), content, facets, embed, contentLabels 99 99 - ✅ Validate: User is authenticated, community exists, content within limits 100 100 - ✅ Write: Create record in **community's PDS repository** ··· 124 124 - [x] **E2E Test:** Create text post → Write to **community's PDS** → Index via Jetstream → Verify in AppView ✅ 125 125 126 126 #### Get Post 127 - - [x] Lexicon: `social.coves.post.get` ✅ 128 - - [ ] **Handler:** `GET /xrpc/social.coves.post.get?uri=at://...` 127 + - [x] Lexicon: `social.coves.community.post.get` ✅ 128 + - [ ] **Handler:** `GET /xrpc/social.coves.community.post.get?uri=at://...` 129 129 - Accept: AT-URI of post 130 130 - Return: Full post view with author, community, stats, viewer state 131 131 - [ ] **Service Layer:** `PostService.Get(uri, viewerDID)` ··· 139 139 - [ ] **E2E Test:** Get post by URI → Verify all fields populated 140 140 141 141 #### Update Post 142 - - [x] Lexicon: `social.coves.post.update` ✅ 143 - - [ ] **Handler:** `POST /xrpc/social.coves.post.update` 142 + - [x] Lexicon: `social.coves.community.post.update` ✅ 143 + - [ ] **Handler:** `POST /xrpc/social.coves.community.post.update` 144 144 - Accept: uri, title, content, facets, embed, contentLabels, editNote 145 145 - Validate: User is post author, within 24-hour edit window 146 146 - Write: Update record in **community's PDS** ··· 157 157 - [ ] **E2E Test:** Update post → Verify edit reflected in AppView 158 158 159 159 #### Delete Post 160 - - [x] Lexicon: `social.coves.post.delete` ✅ 161 - - [ ] **Handler:** `POST /xrpc/social.coves.post.delete` 160 + - [x] Lexicon: `social.coves.community.post.delete` ✅ 161 + - [ ] **Handler:** `POST /xrpc/social.coves.community.post.delete` 162 162 - Accept: uri 163 163 - Validate: User is post author OR community moderator 164 164 - Write: Delete record from **community's PDS** ··· 251 251 252 252 #### Post Event Handling 253 253 - [x] **Consumer:** `PostConsumer.HandlePostEvent()` ✅ (2025-10-19) 254 - - ✅ Listen for `social.coves.post.record` CREATE from **community repositories** 254 + - ✅ Listen for `social.coves.community.post.record` CREATE from **community repositories** 255 255 - ✅ Parse post record, extract author DID and community DID (from AT-URI owner) 256 256 - ⚠️ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering) 257 257 - ✅ Insert in AppView PostgreSQL (CREATE only - UPDATE/DELETE deferred) ··· 447 447 - [ ] **Tag Storage:** Tags live in **user's repository** (users own their tags) 448 448 449 449 #### Crossposting 450 - - [x] Lexicon: `social.coves.post.crosspost` ✅ 450 + - [x] Lexicon: `social.coves.community.post.crosspost` ✅ 451 451 - [ ] **Crosspost Tracking:** Share post to multiple communities 452 452 - [ ] **Implementation:** Create new post record in each community's repository 453 453 - [ ] **Crosspost Chain:** Track all crosspost relationships ··· 461 461 - [ ] **AppView Query:** Endpoint to fetch user's saved posts 462 462 463 463 ### Post Search 464 - - [x] Lexicon: `social.coves.post.search` ✅ 464 + - [x] Lexicon: `social.coves.community.post.search` ✅ 465 465 - [ ] **Search Parameters:** 466 466 - Query string (q) 467 467 - Filter by community ··· 583 583 - **Reuses Token Refresh:** Can leverage existing community credential management 584 584 585 585 **Implementation Details:** 586 - - Post AT-URI: `at://community_did/social.coves.post.record/tid` 586 + - Post AT-URI: `at://community_did/social.coves.community.post.record/tid` 587 587 - Write operations use community's PDS credentials (encrypted, stored in AppView) 588 588 - Author tracked in post record's `author` field (DID) 589 589 - Moderators can delete any post in their community ··· 756 756 757 757 ## Lexicon Summary 758 758 759 - ### `social.coves.post.record` 759 + ### `social.coves.community.post.record` 760 760 **Status:** ✅ Defined, implementation TODO 761 761 **Last Updated:** 2025-10-18 (removed `postType` enum) 762 762 ··· 781 781 - Post "type" is derived from structure (has embed? what embed type? has title? text length?) 782 782 - Community's `contentRules` validate post structure at creation time 783 783 784 - ### `social.coves.post.create` (Procedure) 784 + ### `social.coves.community.post.create` (Procedure) 785 785 **Status:** ✅ Defined, implementation TODO 786 786 **Last Updated:** 2025-10-18 (removed `postType` parameter) 787 787
+4 -4
docs/aggregators/PRD_AGGREGATORS.md
··· 23 23 1. **Aggregators are Actors, Not a Separate System** 24 24 - Each aggregator has its own DID 25 25 - Authenticate as themselves via JWT 26 - - Use existing `social.coves.post.create` endpoint 26 + - Use existing `social.coves.community.post.create` endpoint 27 27 - Post record's `author` field = aggregator DID (server-populated) 28 28 - No separate posting API needed 29 29 ··· 89 89 Aggregator Service (External) 90 90 91 91 │ 1. Authenticates as aggregator DID (JWT) 92 - │ 2. Calls social.coves.post.create 92 + │ 2. Calls social.coves.community.post.create 93 93 94 94 Coves AppView Handler 95 95 ··· 120 120 121 121 ### For Aggregators 122 122 123 - - **`social.coves.post.create`** - Modified to handle aggregator auth 123 + - **`social.coves.community.post.create`** - Modified to handle aggregator auth 124 124 - **`social.coves.aggregator.getAuthorizations`** - Query authorized communities 125 125 126 126 ### For Discovery ··· 312 312 313 313 --- 314 314 315 - ### 2025-10-19: Reuse `social.coves.post.create` Endpoint 315 + ### 2025-10-19: Reuse `social.coves.community.post.create` Endpoint 316 316 **Decision:** Aggregators use existing post creation endpoint. 317 317 318 318 **Rationale:**
+3 -3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
··· 172 172 │ 3. Deduplication: Tracks posted items via JSON state file │ 173 173 │ 4. Feed Mapper: Maps feed URLs to community handles │ 174 174 │ 5. Post Formatter: Converts to Coves post format │ 175 - │ 6. Post Publisher: Calls social.coves.post.create via XRPC │ 175 + │ 6. Post Publisher: Calls social.coves.community.post.create via XRPC │ 176 176 │ 7. Blob Uploader: Handles image upload to ATProto │ 177 177 └─────────────────────────────────────────────────────────────┘ 178 178 179 179 │ Authenticated XRPC calls 180 180 181 181 ┌─────────────────────────────────────────────────────────────┐ 182 - │ Coves AppView (social.coves.post.create) │ 182 + │ Coves AppView (social.coves.community.post.create) │ 183 183 │ - Validates aggregator authorization │ 184 184 │ - Creates post with author = did:plc:[aggregator-did] │ 185 185 │ - Indexes to community feeds │ ··· 271 271 272 272 ```json 273 273 { 274 - "$type": "social.coves.post.record", 274 + "$type": "social.coves.community.post.record", 275 275 "author": "did:plc:[aggregator-did]", 276 276 "community": "world-news.coves.social", 277 277 "title": "{Kagi story title}",
+1 -1
internal/api/handlers/post/create.go
··· 21 21 } 22 22 } 23 23 24 - // HandleCreate handles POST /xrpc/social.coves.post.create 24 + // HandleCreate handles POST /xrpc/social.coves.community.post.create 25 25 // Creates a new post in a community's repository 26 26 func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { 27 27 // 1. Check HTTP method
+7 -7
internal/api/routes/post.go
··· 9 9 ) 10 10 11 11 // RegisterPostRoutes registers post-related XRPC endpoints on the router 12 - // Implements social.coves.post.* lexicon endpoints 12 + // Implements social.coves.community.post.* lexicon endpoints 13 13 func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) { 14 14 // Initialize handlers 15 15 createHandler := post.NewCreateHandler(service) 16 16 17 17 // Procedure endpoints (POST) - require authentication 18 - // social.coves.post.create - create a new post in a community 19 - r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.create", createHandler.HandleCreate) 18 + // social.coves.community.post.create - create a new post in a community 19 + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate) 20 20 21 21 // Future endpoints (Beta): 22 - // r.Get("/xrpc/social.coves.post.get", getHandler.HandleGet) 23 - // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.update", updateHandler.HandleUpdate) 24 - // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.delete", deleteHandler.HandleDelete) 25 - // r.Get("/xrpc/social.coves.post.list", listHandler.HandleList) 22 + // r.Get("/xrpc/social.coves.community.post.get", getHandler.HandleGet) 23 + // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.update", updateHandler.HandleUpdate) 24 + // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.delete", deleteHandler.HandleDelete) 25 + // r.Get("/xrpc/social.coves.community.post.list", listHandler.HandleList) 26 26 }
+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
+100
internal/atproto/lexicon/social/coves/community/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.community.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A post in a Coves community. Posts live in community repositories and persist independently of the author.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["community", "author", "createdAt"], 12 + "properties": { 13 + "community": { 14 + "type": "string", 15 + "format": "at-identifier", 16 + "description": "DID or handle of the community this was posted to" 17 + }, 18 + "author": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the user who created this post" 22 + }, 23 + "title": { 24 + "type": "string", 25 + "maxGraphemes": 300, 26 + "maxLength": 3000, 27 + "description": "Post title (optional for media-only posts)" 28 + }, 29 + "content": { 30 + "type": "string", 31 + "maxGraphemes": 10000, 32 + "maxLength": 100000, 33 + "description": "Post content - supports rich text via facets" 34 + }, 35 + "facets": { 36 + "type": "array", 37 + "description": "Annotations for rich text (mentions, links, tags)", 38 + "items": { 39 + "type": "ref", 40 + "ref": "social.coves.richtext.facet" 41 + } 42 + }, 43 + "embed": { 44 + "type": "union", 45 + "description": "Embedded media, external links, or quoted posts", 46 + "refs": [ 47 + "social.coves.embed.images", 48 + "social.coves.embed.video", 49 + "social.coves.embed.external", 50 + "social.coves.embed.post" 51 + ] 52 + }, 53 + "langs": { 54 + "type": "array", 55 + "description": "Languages used in the post content (ISO 639-1)", 56 + "maxLength": 3, 57 + "items": { 58 + "type": "string", 59 + "format": "language" 60 + } 61 + }, 62 + "labels": { 63 + "type": "ref", 64 + "ref": "com.atproto.label.defs#selfLabels", 65 + "description": "Self-applied content labels (NSFW, spoilers, etc.)" 66 + }, 67 + "tags": { 68 + "type": "array", 69 + "description": "User-applied topic tags", 70 + "maxLength": 8, 71 + "items": { 72 + "type": "string", 73 + "maxLength": 64, 74 + "maxGraphemes": 64 75 + } 76 + }, 77 + "crosspostOf": { 78 + "type": "ref", 79 + "ref": "com.atproto.repo.strongRef", 80 + "description": "If this is a crosspost, strong reference to the immediate parent post" 81 + }, 82 + "crosspostChain": { 83 + "type": "array", 84 + "description": "Full chain of crossposts with version pinning. First element is original, last is immediate parent.", 85 + "maxLength": 25, 86 + "items": { 87 + "type": "ref", 88 + "ref": "com.atproto.repo.strongRef" 89 + } 90 + }, 91 + "createdAt": { 92 + "type": "string", 93 + "format": "datetime", 94 + "description": "Timestamp of post creation" 95 + } 96 + } 97 + } 98 + } 99 + } 100 + }
+6 -6
internal/atproto/lexicon/social/coves/embed/post.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "object", 7 - "description": "Embedded reference to another post", 8 - "required": ["uri"], 7 + "description": "Embedded reference to another post (quoted post)", 8 + "required": ["post"], 9 9 "properties": { 10 - "uri": { 11 - "type": "string", 12 - "format": "at-uri", 13 - "description": "AT-URI of the post being embedded" 10 + "post": { 11 + "type": "ref", 12 + "ref": "com.atproto.repo.strongRef", 13 + "description": "Strong reference to the embedded post (includes URI and CID)" 14 14 } 15 15 } 16 16 }
+80
internal/atproto/lexicon/social/coves/feed/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.feed.comment", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A comment on a post or another comment. Comments live in user repositories and support nested threading.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["reply", "content", "createdAt"], 12 + "properties": { 13 + "reply": { 14 + "type": "ref", 15 + "ref": "#replyRef", 16 + "description": "Reference to the post and parent being replied to" 17 + }, 18 + "content": { 19 + "type": "string", 20 + "maxGraphemes": 3000, 21 + "maxLength": 30000, 22 + "description": "Comment text content" 23 + }, 24 + "facets": { 25 + "type": "array", 26 + "description": "Annotations for rich text (mentions, links, etc.)", 27 + "items": { 28 + "type": "ref", 29 + "ref": "social.coves.richtext.facet" 30 + } 31 + }, 32 + "embed": { 33 + "type": "union", 34 + "description": "Embedded media or quoted posts", 35 + "refs": [ 36 + "social.coves.embed.images", 37 + "social.coves.embed.post" 38 + ] 39 + }, 40 + "langs": { 41 + "type": "array", 42 + "description": "Languages used in the comment content (ISO 639-1)", 43 + "maxLength": 3, 44 + "items": { 45 + "type": "string", 46 + "format": "language" 47 + } 48 + }, 49 + "labels": { 50 + "type": "ref", 51 + "ref": "com.atproto.label.defs#selfLabels", 52 + "description": "Self-applied content labels" 53 + }, 54 + "createdAt": { 55 + "type": "string", 56 + "format": "datetime", 57 + "description": "Timestamp of comment creation" 58 + } 59 + } 60 + } 61 + }, 62 + "replyRef": { 63 + "type": "object", 64 + "description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).", 65 + "required": ["root", "parent"], 66 + "properties": { 67 + "root": { 68 + "type": "ref", 69 + "ref": "com.atproto.repo.strongRef", 70 + "description": "Strong reference to the original post that started the thread" 71 + }, 72 + "parent": { 73 + "type": "ref", 74 + "ref": "com.atproto.repo.strongRef", 75 + "description": "Strong reference to the immediate parent (post or comment) being replied to" 76 + } 77 + } 78 + } 79 + } 80 + }
+3 -3
internal/atproto/lexicon/social/coves/feed/defs.json
··· 9 9 "properties": { 10 10 "post": { 11 11 "type": "ref", 12 - "ref": "social.coves.post.get#postView" 12 + "ref": "social.coves.community.post.get#postView" 13 13 }, 14 14 "reason": { 15 15 "type": "union", ··· 29 29 "properties": { 30 30 "by": { 31 31 "type": "ref", 32 - "ref": "social.coves.post.get#authorView" 32 + "ref": "social.coves.community.post.get#authorView" 33 33 }, 34 34 "indexedAt": { 35 35 "type": "string", ··· 44 44 "properties": { 45 45 "community": { 46 46 "type": "ref", 47 - "ref": "social.coves.post.get#communityRef" 47 + "ref": "social.coves.community.post.get#communityRef" 48 48 } 49 49 } 50 50 },
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.interaction.comment", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A comment on a post or another comment", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["subject", "content", "createdAt"], 12 - "properties": { 13 - "subject": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT-URI of post or comment being replied to" 17 - }, 18 - "content": { 19 - "type": "union", 20 - "refs": ["#textContent", "#imageContent", "#stickerContent"] 21 - }, 22 - "location": { 23 - "type": "ref", 24 - "ref": "social.coves.actor.profile#geoLocation" 25 - }, 26 - "translatedFrom": { 27 - "type": "string", 28 - "maxLength": 10, 29 - "description": "Language code if auto-translated (ISO 639-1)" 30 - }, 31 - "createdAt": { 32 - "type": "string", 33 - "format": "datetime" 34 - } 35 - } 36 - } 37 - }, 38 - "textContent": { 39 - "type": "object", 40 - "required": ["text"], 41 - "properties": { 42 - "text": { 43 - "type": "string", 44 - "maxLength": 10000, 45 - "description": "Comment text" 46 - }, 47 - "facets": { 48 - "type": "array", 49 - "description": "Rich text annotations", 50 - "items": { 51 - "type": "ref", 52 - "ref": "social.coves.richtext.facet" 53 - } 54 - } 55 - } 56 - }, 57 - "imageContent": { 58 - "type": "object", 59 - "required": ["image"], 60 - "properties": { 61 - "image": { 62 - "type": "ref", 63 - "ref": "social.coves.embed.images#image" 64 - }, 65 - "caption": { 66 - "type": "string", 67 - "maxLength": 1000 68 - } 69 - } 70 - }, 71 - "stickerContent": { 72 - "type": "object", 73 - "required": ["stickerId"], 74 - "properties": { 75 - "stickerId": { 76 - "type": "string", 77 - "description": "Reference to a sticker in a sticker pack" 78 - }, 79 - "stickerPackId": { 80 - "type": "string", 81 - "description": "Reference to the sticker pack" 82 - } 83 - } 84 - } 85 - } 86 - }
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.interaction.createComment", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Create a comment on a post or another comment", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["parent", "text"], 13 - "properties": { 14 - "parent": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "AT-URI of the post or comment being replied to" 18 - }, 19 - "text": { 20 - "type": "string", 21 - "maxGraphemes": 3000, 22 - "maxLength": 30000, 23 - "description": "Comment text" 24 - }, 25 - "textFacets": { 26 - "type": "array", 27 - "description": "Rich text annotations", 28 - "items": { 29 - "type": "ref", 30 - "ref": "social.coves.richtext.facet" 31 - } 32 - } 33 - } 34 - } 35 - }, 36 - "output": { 37 - "encoding": "application/json", 38 - "schema": { 39 - "type": "object", 40 - "required": ["uri", "cid"], 41 - "properties": { 42 - "uri": { 43 - "type": "string", 44 - "format": "at-uri", 45 - "description": "AT-URI of the created comment" 46 - }, 47 - "cid": { 48 - "type": "string", 49 - "format": "cid", 50 - "description": "CID of the created comment" 51 - } 52 - } 53 - } 54 - }, 55 - "errors": [ 56 - { 57 - "name": "ParentNotFound", 58 - "description": "Parent post or comment not found" 59 - }, 60 - { 61 - "name": "NotAuthorized", 62 - "description": "User is not authorized to comment" 63 - }, 64 - { 65 - "name": "ThreadLocked", 66 - "description": "Comment thread is locked" 67 - }, 68 - { 69 - "name": "Banned", 70 - "description": "User is banned from this community" 71 - } 72 - ] 73 - } 74 - } 75 - }
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.interaction.deleteComment", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Delete a comment", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["uri"], 13 - "properties": { 14 - "uri": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "AT-URI of the comment to delete" 18 - } 19 - } 20 - } 21 - }, 22 - "output": { 23 - "encoding": "application/json", 24 - "schema": { 25 - "type": "object", 26 - "properties": {} 27 - } 28 - }, 29 - "errors": [ 30 - { 31 - "name": "CommentNotFound", 32 - "description": "Comment not found" 33 - }, 34 - { 35 - "name": "NotAuthorized", 36 - "description": "User is not authorized to delete this comment" 37 - } 38 - ] 39 - } 40 - } 41 - }
+23 -22
internal/atproto/lexicon/social/coves/post/create.json internal/atproto/lexicon/social/coves/community/post/create.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.coves.post.create", 3 + "id": "social.coves.community.post.create", 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", ··· 20 20 "type": "string", 21 21 "maxGraphemes": 300, 22 22 "maxLength": 3000, 23 - "description": "Post title (optional for microblog, image, and video posts)" 23 + "description": "Post title (optional for media-only posts)" 24 24 }, 25 25 "content": { 26 26 "type": "string", 27 - "maxLength": 50000, 28 - "description": "Post content - main text for text posts, description for media, etc." 27 + "maxGraphemes": 10000, 28 + "maxLength": 100000, 29 + "description": "Post content - supports rich text via facets" 29 30 }, 30 31 "facets": { 31 32 "type": "array", 32 - "description": "Rich text annotations for content", 33 + "description": "Annotations for rich text (mentions, links, tags)", 33 34 "items": { 34 35 "type": "ref", 35 36 "ref": "social.coves.richtext.facet" ··· 37 38 }, 38 39 "embed": { 39 40 "type": "union", 40 - "description": "Embedded content - images, videos, external links, or quoted posts", 41 + "description": "Embedded media, external links, or quoted posts", 41 42 "refs": [ 42 43 "social.coves.embed.images", 43 44 "social.coves.embed.video", ··· 45 46 "social.coves.embed.post" 46 47 ] 47 48 }, 48 - "originalAuthor": { 49 - "type": "ref", 50 - "ref": "social.coves.post.record#originalAuthor", 51 - "description": "For microblog posts - information about the original author" 49 + "langs": { 50 + "type": "array", 51 + "description": "Languages used in the post content (ISO 639-1)", 52 + "maxLength": 3, 53 + "items": { 54 + "type": "string", 55 + "format": "language" 56 + } 52 57 }, 53 - "federatedFrom": { 58 + "labels": { 54 59 "type": "ref", 55 - "ref": "social.coves.federation.post", 56 - "description": "Reference to original federated post (for microblog posts)" 60 + "ref": "com.atproto.label.defs#selfLabels", 61 + "description": "Self-applied content labels (NSFW, spoilers, etc.)" 57 62 }, 58 - "contentLabels": { 63 + "tags": { 59 64 "type": "array", 60 - "description": "Self-applied content labels", 65 + "description": "User-applied topic tags", 66 + "maxLength": 8, 61 67 "items": { 62 68 "type": "string", 63 - "knownValues": ["nsfw", "spoiler", "violence"], 64 - "maxLength": 32 69 + "maxLength": 64, 70 + "maxGraphemes": 64 65 71 } 66 - }, 67 - "location": { 68 - "type": "ref", 69 - "ref": "social.coves.actor.profile#geoLocation", 70 - "description": "Geographic location where post was created" 71 72 } 72 73 } 73 74 }
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.post.crosspost", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A record tracking crosspost relationships between posts", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["originalPost", "crosspostOf", "createdAt"], 12 - "properties": { 13 - "originalPost": { 14 - "type": "string", 15 - "format": "at-uri", 16 - "description": "AT-URI of the original post in the crosspost chain" 17 - }, 18 - "crosspostOf": { 19 - "type": "string", 20 - "format": "at-uri", 21 - "description": "AT-URI of the immediate parent this is a crosspost of" 22 - }, 23 - "allCrossposts": { 24 - "type": "array", 25 - "description": "Array of AT-URIs of all posts in the crosspost chain", 26 - "items": { 27 - "type": "string", 28 - "format": "at-uri" 29 - } 30 - }, 31 - "createdAt": { 32 - "type": "string", 33 - "format": "datetime" 34 - } 35 - } 36 - } 37 - } 38 - } 39 - }
+1 -1
internal/atproto/lexicon/social/coves/post/delete.json internal/atproto/lexicon/social/coves/community/post/delete.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.coves.post.delete", 3 + "id": "social.coves.community.post.delete", 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure",
+1 -1
internal/atproto/lexicon/social/coves/post/get.json internal/atproto/lexicon/social/coves/community/post/get.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.coves.post.get", 3 + "id": "social.coves.community.post.get", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query",
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.post.getCrosspostChain", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Get all crossposts in a crosspost chain for a given post", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["uri"], 13 - "properties": { 14 - "uri": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "AT-URI of any post in the crosspost chain" 18 - } 19 - } 20 - } 21 - }, 22 - "output": { 23 - "encoding": "application/json", 24 - "schema": { 25 - "type": "object", 26 - "required": ["crossposts"], 27 - "properties": { 28 - "crossposts": { 29 - "type": "array", 30 - "description": "All posts in the crosspost chain", 31 - "items": { 32 - "type": "ref", 33 - "ref": "#crosspostView" 34 - } 35 - } 36 - } 37 - } 38 - } 39 - }, 40 - "crosspostView": { 41 - "type": "object", 42 - "required": ["uri", "community", "author", "createdAt"], 43 - "properties": { 44 - "uri": { 45 - "type": "string", 46 - "format": "at-uri", 47 - "description": "AT-URI of the post" 48 - }, 49 - "community": { 50 - "type": "object", 51 - "required": ["uri", "name"], 52 - "properties": { 53 - "uri": { 54 - "type": "string", 55 - "format": "at-uri", 56 - "description": "AT-URI of the community" 57 - }, 58 - "name": { 59 - "type": "string", 60 - "description": "Display name of the community" 61 - }, 62 - "handle": { 63 - "type": "string", 64 - "description": "Handle of the community" 65 - } 66 - } 67 - }, 68 - "author": { 69 - "type": "object", 70 - "required": ["did", "handle"], 71 - "properties": { 72 - "did": { 73 - "type": "string", 74 - "format": "did" 75 - }, 76 - "handle": { 77 - "type": "string" 78 - }, 79 - "displayName": { 80 - "type": "string" 81 - }, 82 - "avatar": { 83 - "type": "string", 84 - "format": "uri" 85 - } 86 - } 87 - }, 88 - "isOriginal": { 89 - "type": "boolean", 90 - "description": "Whether this is the original post in the chain" 91 - }, 92 - "createdAt": { 93 - "type": "string", 94 - "format": "datetime" 95 - } 96 - } 97 - } 98 - } 99 - }
-129
internal/atproto/lexicon/social/coves/post/record.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.post.record", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A unified post record supporting multiple content types", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["$type", "community", "author", "createdAt"], 12 - "properties": { 13 - "$type": { 14 - "type": "string", 15 - "const": "social.coves.post.record", 16 - "description": "The record type identifier" 17 - }, 18 - "community": { 19 - "type": "string", 20 - "format": "at-identifier", 21 - "description": "DID or handle of the community this was posted to" 22 - }, 23 - "author": { 24 - "type": "string", 25 - "format": "did", 26 - "description": "DID of the user who created this post. Server-populated from authenticated session; clients MUST NOT provide this field. Required for attribution, moderation, and accountability." 27 - }, 28 - "title": { 29 - "type": "string", 30 - "maxGraphemes": 300, 31 - "maxLength": 3000, 32 - "description": "Post title (optional for microblog, image, and video posts)" 33 - }, 34 - "content": { 35 - "type": "string", 36 - "maxLength": 50000, 37 - "description": "Post content - main text for text posts, description for media, etc." 38 - }, 39 - "facets": { 40 - "type": "array", 41 - "description": "Rich text annotations for content", 42 - "items": { 43 - "type": "ref", 44 - "ref": "social.coves.richtext.facet" 45 - } 46 - }, 47 - "embed": { 48 - "type": "union", 49 - "description": "Embedded content - images, videos, external links, or quoted posts", 50 - "refs": [ 51 - "social.coves.embed.images", 52 - "social.coves.embed.video", 53 - "social.coves.embed.external", 54 - "social.coves.embed.post" 55 - ] 56 - }, 57 - "originalAuthor": { 58 - "type": "ref", 59 - "ref": "#originalAuthor", 60 - "description": "For microblog posts - information about the original author from federated platform" 61 - }, 62 - "contentLabels": { 63 - "type": "array", 64 - "description": "Self-applied content labels", 65 - "items": { 66 - "type": "string", 67 - "knownValues": ["nsfw", "spoiler", "violence"], 68 - "maxLength": 32 69 - } 70 - }, 71 - "federatedFrom": { 72 - "type": "ref", 73 - "ref": "social.coves.federation.post", 74 - "description": "Reference to original federated post (if applicable)" 75 - }, 76 - "location": { 77 - "type": "ref", 78 - "ref": "social.coves.actor.profile#geoLocation", 79 - "description": "Geographic location where post was created" 80 - }, 81 - "crosspostOf": { 82 - "type": "string", 83 - "format": "at-uri", 84 - "description": "If this is a crosspost, AT-URI of the post this is a crosspost of" 85 - }, 86 - "crosspostChain": { 87 - "type": "array", 88 - "description": "Array of AT-URIs of all posts in the crosspost chain (including this one)", 89 - "items": { 90 - "type": "string", 91 - "format": "at-uri" 92 - } 93 - }, 94 - "createdAt": { 95 - "type": "string", 96 - "format": "datetime" 97 - } 98 - } 99 - } 100 - }, 101 - "originalAuthor": { 102 - "type": "object", 103 - "description": "Information about the original author from a federated platform", 104 - "required": ["handle"], 105 - "properties": { 106 - "did": { 107 - "type": "string", 108 - "format": "did", 109 - "description": "Original author's DID (if available)" 110 - }, 111 - "handle": { 112 - "type": "string", 113 - "maxLength": 253, 114 - "description": "Original author's handle" 115 - }, 116 - "displayName": { 117 - "type": "string", 118 - "maxLength": 640, 119 - "description": "Original author's display name" 120 - }, 121 - "avatar": { 122 - "type": "string", 123 - "format": "uri", 124 - "description": "URL to original author's avatar" 125 - } 126 - } 127 - } 128 - } 129 - }
+2 -2
internal/atproto/lexicon/social/coves/post/search.json internal/atproto/lexicon/social/coves/community/post/search.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.coves.post.search", 3 + "id": "social.coves.community.post.search", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", ··· 66 66 "type": "array", 67 67 "items": { 68 68 "type": "ref", 69 - "ref": "social.coves.post.getFeed#feedPost" 69 + "ref": "social.coves.feed.defs#feedViewPost" 70 70 } 71 71 }, 72 72 "cursor": {
+20 -5
internal/atproto/lexicon/social/coves/post/update.json internal/atproto/lexicon/social/coves/community/post/update.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.coves.post.update", 3 + "id": "social.coves.community.post.update", 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", ··· 45 45 "social.coves.embed.post" 46 46 ] 47 47 }, 48 - "contentLabels": { 48 + "labels": { 49 + "type": "ref", 50 + "ref": "com.atproto.label.defs#selfLabels", 51 + "description": "Updated self-applied content labels" 52 + }, 53 + "langs": { 54 + "type": "array", 55 + "description": "Updated languages (ISO 639-1)", 56 + "maxLength": 3, 57 + "items": { 58 + "type": "string", 59 + "format": "language" 60 + } 61 + }, 62 + "tags": { 49 63 "type": "array", 50 - "description": "Updated content labels", 64 + "description": "Updated topic tags", 65 + "maxLength": 8, 51 66 "items": { 52 67 "type": "string", 53 - "knownValues": ["nsfw", "spoiler", "violence"], 54 - "maxLength": 32 68 + "maxLength": 64, 69 + "maxGraphemes": 64 55 70 } 56 71 }, 57 72 "editNote": {
+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
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
··· 1 + -- +goose Up 2 + -- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure 3 + -- This allows storing the optional 'neg' field and future extensions 4 + 5 + -- Create temporary function to convert TEXT[] to selfLabels JSONB 6 + -- +goose StatementBegin 7 + CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[]) 8 + RETURNS JSONB AS $$ 9 + BEGIN 10 + IF labels IS NULL OR array_length(labels, 1) = 0 THEN 11 + RETURN NULL; 12 + END IF; 13 + 14 + RETURN jsonb_build_object( 15 + 'values', 16 + (SELECT jsonb_agg(jsonb_build_object('val', label)) 17 + FROM unnest(labels) AS label) 18 + ); 19 + END; 20 + $$ LANGUAGE plpgsql IMMUTABLE; 21 + -- +goose StatementEnd 22 + 23 + -- Convert column type using the function 24 + ALTER TABLE posts 25 + ALTER COLUMN content_labels TYPE JSONB 26 + USING convert_labels_to_jsonb(content_labels); 27 + 28 + -- Drop the temporary function 29 + DROP FUNCTION convert_labels_to_jsonb(TEXT[]); 30 + 31 + -- Update column comment 32 + COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})'; 33 + 34 + -- +goose Down 35 + -- Revert JSONB back to TEXT[] (lossy - drops 'neg' field) 36 + ALTER TABLE posts 37 + ALTER COLUMN content_labels TYPE TEXT[] 38 + USING CASE 39 + WHEN content_labels IS NULL THEN NULL 40 + ELSE ARRAY( 41 + SELECT value->>'val' 42 + FROM jsonb_array_elements(content_labels->'values') AS value 43 + ) 44 + END; 45 + 46 + -- Restore original comment 47 + COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)';
+11 -8
internal/db/postgres/feed_repo.go
··· 11 11 "strconv" 12 12 "strings" 13 13 "time" 14 - 15 - "github.com/lib/pq" 16 14 ) 17 15 18 16 type postgresFeedRepo struct { ··· 329 327 communityRef posts.CommunityRef 330 328 title, content sql.NullString 331 329 facets, embed sql.NullString 332 - labels pq.StringArray 330 + labelsJSON sql.NullString 333 331 editedAt sql.NullTime 334 332 communityAvatar sql.NullString 335 333 hotRank sql.NullFloat64 ··· 339 337 &postView.URI, &postView.CID, &postView.RKey, 340 338 &authorView.DID, &authorView.Handle, 341 339 &communityRef.DID, &communityRef.Name, &communityAvatar, 342 - &title, &content, &facets, &embed, &labels, 340 + &title, &content, &facets, &embed, &labelsJSON, 343 341 &postView.CreatedAt, &editedAt, &postView.IndexedAt, 344 342 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 345 343 &hotRank, ··· 386 384 // Alpha: No viewer state for basic feed 387 385 // TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton 388 386 389 - // Build the record (required by lexicon - social.coves.post.record structure) 387 + // Build the record (required by lexicon - social.coves.community.post structure) 390 388 record := map[string]interface{}{ 391 - "$type": "social.coves.post.record", 389 + "$type": "social.coves.community.post", 392 390 "community": communityRef.DID, 393 391 "author": authorView.DID, 394 392 "createdAt": postView.CreatedAt.Format(time.RFC3339), ··· 413 411 record["embed"] = embedData 414 412 } 415 413 } 416 - if len(labels) > 0 { 417 - record["contentLabels"] = labels 414 + if labelsJSON.Valid { 415 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 416 + // Deserialize and include in record 417 + var selfLabels posts.SelfLabels 418 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { 419 + record["labels"] = selfLabels 420 + } 418 421 } 419 422 420 423 postView.Record = record
+10 -7
internal/db/postgres/feed_repo_base.go
··· 12 12 "strconv" 13 13 "strings" 14 14 "time" 15 - 16 - "github.com/lib/pq" 17 15 ) 18 16 19 17 // feedRepoBase contains shared logic for timeline and discover feed repositories ··· 283 281 communityRef posts.CommunityRef 284 282 title, content sql.NullString 285 283 facets, embed sql.NullString 286 - labels pq.StringArray 284 + labelsJSON sql.NullString 287 285 editedAt sql.NullTime 288 286 communityAvatar sql.NullString 289 287 hotRank sql.NullFloat64 ··· 293 291 &postView.URI, &postView.CID, &postView.RKey, 294 292 &authorView.DID, &authorView.Handle, 295 293 &communityRef.DID, &communityRef.Name, &communityAvatar, 296 - &title, &content, &facets, &embed, &labels, 294 + &title, &content, &facets, &embed, &labelsJSON, 297 295 &postView.CreatedAt, &editedAt, &postView.IndexedAt, 298 296 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 299 297 &hotRank, ··· 339 337 340 338 // Build the record (required by lexicon) 341 339 record := map[string]interface{}{ 342 - "$type": "social.coves.post.record", 340 + "$type": "social.coves.community.post", 343 341 "community": communityRef.DID, 344 342 "author": authorView.DID, 345 343 "createdAt": postView.CreatedAt.Format(time.RFC3339), ··· 364 362 record["embed"] = embedData 365 363 } 366 364 } 367 - if len(labels) > 0 { 368 - record["contentLabels"] = labels 365 + if labelsJSON.Valid { 366 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 367 + // Deserialize and include in record 368 + var selfLabels posts.SelfLabels 369 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { 370 + record["labels"] = selfLabels 371 + } 369 372 } 370 373 371 374 postView.Record = record
+12 -20
internal/db/postgres/post_repo.go
··· 4 4 "Coves/internal/core/posts" 5 5 "context" 6 6 "database/sql" 7 - "encoding/json" 8 7 "fmt" 9 8 "strings" 10 - 11 - "github.com/lib/pq" 12 9 ) 13 10 14 11 type postgresPostRepo struct { ··· 36 33 embedJSON.Valid = true 37 34 } 38 35 39 - // Convert content labels to PostgreSQL array 40 - var labelsArray pq.StringArray 36 + // Store content labels as JSONB 37 + // post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]} 38 + // Store the full JSON blob to preserve the 'neg' field and future extensions 39 + var labelsJSON sql.NullString 41 40 if post.ContentLabels != nil { 42 - // Parse JSON array string to []string 43 - var labels []string 44 - if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil { 45 - labelsArray = labels 46 - } 41 + labelsJSON.String = *post.ContentLabels 42 + labelsJSON.Valid = true 47 43 } 48 44 49 45 query := ` ··· 62 58 err := r.db.QueryRowContext( 63 59 ctx, query, 64 60 post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID, 65 - post.Title, post.Content, facetsJSON, embedJSON, labelsArray, 61 + post.Title, post.Content, facetsJSON, embedJSON, labelsJSON, 66 62 post.CreatedAt, 67 63 ).Scan(&post.ID, &post.IndexedAt) 68 64 if err != nil { ··· 101 97 ` 102 98 103 99 var post posts.Post 104 - var facetsJSON, embedJSON sql.NullString 105 - var contentLabels pq.StringArray 100 + var facetsJSON, embedJSON, labelsJSON sql.NullString 106 101 107 102 err := r.db.QueryRowContext(ctx, query, uri).Scan( 108 103 &post.ID, &post.URI, &post.CID, &post.RKey, 109 104 &post.AuthorDID, &post.CommunityDID, 110 - &post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels, 105 + &post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON, 111 106 &post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt, 112 107 &post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount, 113 108 ) ··· 126 121 if embedJSON.Valid { 127 122 post.Embed = &embedJSON.String 128 123 } 129 - if len(contentLabels) > 0 { 130 - labelsJSON, marshalErr := json.Marshal(contentLabels) 131 - if marshalErr == nil { 132 - labelsStr := string(labelsJSON) 133 - post.ContentLabels = &labelsStr 134 - } 124 + if labelsJSON.Valid { 125 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 126 + post.ContentLabels = &labelsJSON.String 135 127 } 136 128 137 129 return &post, nil
+11 -11
internal/db/postgres/vote_repo_test.go
··· 67 67 CID: "bafyreigtest123", 68 68 RKey: "3k1234567890", 69 69 VoterDID: voterDID, 70 - SubjectURI: "at://did:plc:community/social.coves.post.record/abc123", 70 + SubjectURI: "at://did:plc:community/social.coves.community.post/abc123", 71 71 SubjectCID: "bafyreigpost123", 72 72 Direction: "up", 73 73 CreatedAt: time.Now(), ··· 95 95 CID: "bafyreigtest456", 96 96 RKey: "3k9876543210", 97 97 VoterDID: voterDID, 98 - SubjectURI: "at://did:plc:community/social.coves.post.record/xyz789", 98 + SubjectURI: "at://did:plc:community/social.coves.community.post/xyz789", 99 99 SubjectCID: "bafyreigpost456", 100 100 Direction: "down", 101 101 CreatedAt: time.Now(), ··· 136 136 CID: "bafyreignovoter", 137 137 RKey: "3k1111111111", 138 138 VoterDID: "did:plc:nonexistentvoter", 139 - SubjectURI: "at://did:plc:community/social.coves.post.record/test123", 139 + SubjectURI: "at://did:plc:community/social.coves.community.post/test123", 140 140 SubjectCID: "bafyreigpost789", 141 141 Direction: "up", 142 142 CreatedAt: time.Now(), ··· 168 168 CID: "bafyreigtest789", 169 169 RKey: "3k5555555555", 170 170 VoterDID: voterDID, 171 - SubjectURI: "at://did:plc:community/social.coves.post.record/post123", 171 + SubjectURI: "at://did:plc:community/social.coves.community.post/post123", 172 172 SubjectCID: "bafyreigpost999", 173 173 Direction: "up", 174 174 CreatedAt: time.Now(), ··· 207 207 voterDID := "did:plc:testvoter999" 208 208 createTestUser(t, db, "testvoter999.test", voterDID) 209 209 210 - subjectURI := "at://did:plc:community/social.coves.post.record/subject123" 210 + subjectURI := "at://did:plc:community/social.coves.community.post/subject123" 211 211 212 212 // Create vote 213 213 vote := &votes.Vote{ ··· 238 238 repo := NewVoteRepository(db) 239 239 ctx := context.Background() 240 240 241 - _, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.post.record/nopost") 241 + _, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.community.post/nopost") 242 242 assert.ErrorIs(t, err, votes.ErrVoteNotFound) 243 243 } 244 244 ··· 259 259 CID: "bafyreigdelete", 260 260 RKey: "3k7777777777", 261 261 VoterDID: voterDID, 262 - SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest", 262 + SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest", 263 263 SubjectCID: "bafyreigdeletepost", 264 264 Direction: "up", 265 265 CreatedAt: time.Now(), ··· 297 297 CID: "bafyreigdelete2", 298 298 RKey: "3k8888888888", 299 299 VoterDID: voterDID, 300 - SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest2", 300 + SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest2", 301 301 SubjectCID: "bafyreigdeletepost2", 302 302 Direction: "down", 303 303 CreatedAt: time.Now(), ··· 327 327 createTestUser(t, db, "testvoterlist1.test", voterDID1) 328 328 createTestUser(t, db, "testvoterlist2.test", voterDID2) 329 329 330 - subjectURI := "at://did:plc:community/social.coves.post.record/listtest" 330 + subjectURI := "at://did:plc:community/social.coves.community.post/listtest" 331 331 332 332 // Create multiple votes on same subject 333 333 vote1 := &votes.Vote{ ··· 377 377 CID: "bafyreigvoter1", 378 378 RKey: "3k0000000001", 379 379 VoterDID: voterDID, 380 - SubjectURI: "at://did:plc:community/social.coves.post.record/post1", 380 + SubjectURI: "at://did:plc:community/social.coves.community.post/post1", 381 381 SubjectCID: "bafyreigp1", 382 382 Direction: "up", 383 383 CreatedAt: time.Now(), ··· 387 387 CID: "bafyreigvoter2", 388 388 RKey: "3k0000000002", 389 389 VoterDID: voterDID, 390 - SubjectURI: "at://did:plc:community/social.coves.post.record/post2", 390 + SubjectURI: "at://did:plc:community/social.coves.community.post/post2", 391 391 SubjectCID: "bafyreigp2", 392 392 Direction: "down", 393 393 CreatedAt: time.Now(),
+3 -3
internal/validation/lexicon.go
··· 81 81 82 82 // ValidatePost validates a post record 83 83 func (v *LexiconValidator) ValidatePost(post map[string]interface{}) error { 84 - return v.ValidateRecord(post, "social.coves.post.record") 84 + return v.ValidateRecord(post, "social.coves.community.post") 85 85 } 86 86 87 87 // ValidateComment validates a comment record 88 88 func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error { 89 - return v.ValidateRecord(comment, "social.coves.interaction.comment") 89 + return v.ValidateRecord(comment, "social.coves.feed.comment") 90 90 } 91 91 92 92 // ValidateVote validates a vote record ··· 99 99 return v.ValidateRecord(action, fmt.Sprintf("social.coves.moderation.%s", actionType)) 100 100 } 101 101 102 - // ResolveReference resolves a schema reference (e.g., "social.coves.post.get#postView") 102 + // ResolveReference resolves a schema reference (e.g., "social.coves.community.post.get#postView") 103 103 func (v *LexiconValidator) ResolveReference(ref string) (interface{}, error) { 104 104 return v.catalog.Resolve(ref) 105 105 }
+7 -3
internal/validation/lexicon_test.go
··· 58 58 59 59 // Valid post 60 60 validPost := map[string]interface{}{ 61 - "$type": "social.coves.post.record", 61 + "$type": "social.coves.community.post", 62 62 "community": "did:plc:test123", 63 63 "author": "did:plc:author123", 64 64 "title": "Test Post", ··· 72 72 73 73 // Invalid post - missing required field (author) 74 74 invalidPost := map[string]interface{}{ 75 - "$type": "social.coves.post.record", 75 + "$type": "social.coves.community.post", 76 76 "community": "did:plc:test123", 77 77 // Missing required "author" field 78 78 "title": "Test Post", ··· 94 94 // Test with JSON string 95 95 jsonString := `{ 96 96 "$type": "social.coves.interaction.vote", 97 - "subject": "at://did:plc:test/social.coves.post.text/abc123", 97 + "subject": { 98 + "uri": "at://did:plc:test/social.coves.community.post/abc123", 99 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 100 + }, 101 + "direction": "up", 98 102 "createdAt": "2024-01-01T00:00:00Z" 99 103 }` 100 104
+8 -8
tests/integration/aggregator_e2e_test.go
··· 332 332 reqJSON, err := json.Marshal(reqBody) 333 333 require.NoError(t, err) 334 334 335 - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 335 + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 336 336 req.Header.Set("Content-Type", "application/json") 337 337 338 338 // Create JWT for aggregator (not a user) ··· 360 360 Kind: "commit", 361 361 Commit: &jetstream.CommitEvent{ 362 362 Operation: "create", 363 - Collection: "social.coves.post.record", 363 + Collection: "social.coves.community.post", 364 364 RKey: rkey, 365 365 CID: response.CID, 366 366 Record: map[string]interface{}{ 367 - "$type": "social.coves.post.record", 367 + "$type": "social.coves.community.post", 368 368 "community": communityDID, 369 369 "author": aggregatorDID, // Aggregator is the author 370 370 "title": title, ··· 422 422 reqJSON, err := json.Marshal(reqBody) 423 423 require.NoError(t, err) 424 424 425 - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 425 + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 426 426 req.Header.Set("Content-Type", "application/json") 427 427 req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 428 428 ··· 444 444 reqJSON, err := json.Marshal(reqBody) 445 445 require.NoError(t, err) 446 446 447 - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 447 + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 448 448 req.Header.Set("Content-Type", "application/json") 449 449 req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 450 450 ··· 465 465 reqJSON, err = json.Marshal(reqBody) 466 466 require.NoError(t, err) 467 467 468 - req = httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 468 + req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 469 469 req.Header.Set("Content-Type", "application/json") 470 470 req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 471 471 ··· 657 657 reqJSON, err := json.Marshal(reqBody) 658 658 require.NoError(t, err) 659 659 660 - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 660 + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 661 661 req.Header.Set("Content-Type", "application/json") 662 662 req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(unauthorizedAggDID)) 663 663 ··· 781 781 reqJSON, err := json.Marshal(reqBody) 782 782 require.NoError(t, err) 783 783 784 - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 784 + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 785 785 req.Header.Set("Content-Type", "application/json") 786 786 req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) 787 787
+4 -4
tests/integration/aggregator_test.go
··· 544 544 }) 545 545 546 546 t.Run("records aggregator post for rate limiting", func(t *testing.T) { 547 - postURI := fmt.Sprintf("at://%s/social.coves.post.record/post1", communityDID) 547 + postURI := fmt.Sprintf("at://%s/social.coves.community.post/post1", communityDID) 548 548 549 549 err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123") 550 550 if err != nil { ··· 627 627 t.Run("allows posts within rate limit", func(t *testing.T) { 628 628 // Create 9 posts (under the 10/hour limit) 629 629 for i := 0; i < 9; i++ { 630 - postURI := fmt.Sprintf("at://%s/social.coves.post.record/post%d", communityDID, i) 630 + postURI := fmt.Sprintf("at://%s/social.coves.community.post/post%d", communityDID, i) 631 631 if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil { 632 632 t.Fatalf("Failed to record post %d: %v", i, err) 633 633 } ··· 642 642 643 643 t.Run("enforces rate limit at 10 posts/hour", func(t *testing.T) { 644 644 // Add one more post to hit the limit (total = 10) 645 - postURI := fmt.Sprintf("at://%s/social.coves.post.record/post10", communityDID) 645 + postURI := fmt.Sprintf("at://%s/social.coves.community.post/post10", communityDID) 646 646 if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil { 647 647 t.Fatalf("Failed to record 10th post: %v", err) 648 648 } ··· 801 801 802 802 // Record 5 posts 803 803 for i := 0; i < 5; i++ { 804 - postURI := fmt.Sprintf("at://%s/social.coves.post.record/triggerpost%d", communityDID, i) 804 + postURI := fmt.Sprintf("at://%s/social.coves.community.post/triggerpost%d", communityDID, i) 805 805 if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil { 806 806 t.Fatalf("Failed to record post %d: %v", i, err) 807 807 }
+1 -1
tests/integration/feed_test.go
··· 81 81 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i) 82 82 record, ok := feedPost.Post.Record.(map[string]interface{}) 83 83 require.True(t, ok, "Record should be a map") 84 - assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type") 84 + assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type") 85 85 assert.NotEmpty(t, record["community"], "Record should have community") 86 86 assert.NotEmpty(t, record["author"], "Record should have author") 87 87 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
+1 -1
tests/integration/helpers.go
··· 274 274 275 275 // Generate URI 276 276 rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) 277 - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", communityDID, rkey) 277 + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey) 278 278 279 279 // Insert post 280 280 _, err := db.ExecContext(ctx, `
+41 -21
tests/integration/post_creation_test.go
··· 1 1 package integration 2 2 3 3 import ( 4 + "Coves/internal/api/middleware" 4 5 "Coves/internal/atproto/identity" 5 6 "Coves/internal/core/communities" 6 7 "Coves/internal/core/posts" ··· 97 98 98 99 // This will fail at token refresh step (expected for unit test) 99 100 // We're using a fake token that can't be parsed 100 - _, err := postService.CreatePost(ctx, req) 101 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 102 + _, err := postService.CreatePost(authCtx, req) 101 103 102 104 // For now, we expect an error because token is fake 103 105 // In a full E2E test with real PDS, this would succeed ··· 123 125 124 126 // Should resolve handle to DID and proceed 125 127 // Will still fail at token refresh (expected with fake token) 126 - _, err := postService.CreatePost(ctx, req) 128 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 129 + _, err := postService.CreatePost(authCtx, req) 127 130 require.Error(t, err) 128 131 // Should fail at token refresh, not community resolution 129 132 assert.Contains(t, err.Error(), "failed to refresh community credentials") ··· 152 155 153 156 // Should resolve handle to DID and proceed 154 157 // Will still fail at token refresh (expected with fake token) 155 - _, err := postService.CreatePost(ctx, req) 158 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 159 + _, err := postService.CreatePost(authCtx, req) 156 160 require.Error(t, err) 157 161 // Should fail at token refresh, not community resolution 158 162 assert.Contains(t, err.Error(), "failed to refresh community credentials") ··· 167 171 AuthorDID: testUserDID, 168 172 } 169 173 170 - _, err := postService.CreatePost(ctx, req) 174 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 175 + _, err := postService.CreatePost(authCtx, req) 171 176 require.Error(t, err) 172 177 assert.True(t, posts.IsValidationError(err)) 173 178 }) ··· 181 186 AuthorDID: testUserDID, 182 187 } 183 188 184 - _, err := postService.CreatePost(ctx, req) 189 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 190 + _, err := postService.CreatePost(authCtx, req) 185 191 require.Error(t, err) 186 192 // Should fail with community not found (wrapped in error) 187 193 assert.Contains(t, err.Error(), "community not found") ··· 196 202 AuthorDID: "", // Missing! 197 203 } 198 204 199 - _, err := postService.CreatePost(ctx, req) 205 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 206 + _, err := postService.CreatePost(authCtx, req) 200 207 require.Error(t, err) 201 208 assert.True(t, posts.IsValidationError(err)) 202 209 assert.Contains(t, err.Error(), "authorDid") ··· 211 218 AuthorDID: testUserDID, 212 219 } 213 220 214 - _, err := postService.CreatePost(ctx, req) 221 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 222 + _, err := postService.CreatePost(authCtx, req) 215 223 require.Error(t, err) 216 224 assert.Equal(t, posts.ErrCommunityNotFound, err) 217 225 }) ··· 226 234 AuthorDID: testUserDID, 227 235 } 228 236 229 - _, err := postService.CreatePost(ctx, req) 237 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 238 + _, err := postService.CreatePost(authCtx, req) 230 239 require.Error(t, err) 231 240 assert.True(t, posts.IsValidationError(err)) 232 241 assert.Contains(t, err.Error(), "too long") ··· 236 245 content := "Post with invalid label" 237 246 238 247 req := posts.CreatePostRequest{ 239 - Community: testCommunity.DID, 240 - Content: &content, 241 - ContentLabels: []string{"invalid_label"}, // Not in known values! 242 - AuthorDID: testUserDID, 248 + Community: testCommunity.DID, 249 + Content: &content, 250 + Labels: &posts.SelfLabels{ 251 + Values: []posts.SelfLabel{ 252 + {Val: "invalid_label"}, // Not in known values! 253 + }, 254 + }, 255 + AuthorDID: testUserDID, 243 256 } 244 257 245 - _, err := postService.CreatePost(ctx, req) 258 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 259 + _, err := postService.CreatePost(authCtx, req) 246 260 require.Error(t, err) 247 261 assert.True(t, posts.IsValidationError(err)) 248 262 assert.Contains(t, err.Error(), "unknown content label") ··· 252 266 content := "Post with valid labels" 253 267 254 268 req := posts.CreatePostRequest{ 255 - Community: testCommunity.DID, 256 - Content: &content, 257 - ContentLabels: []string{"nsfw", "spoiler"}, 258 - AuthorDID: testUserDID, 269 + Community: testCommunity.DID, 270 + Content: &content, 271 + Labels: &posts.SelfLabels{ 272 + Values: []posts.SelfLabel{ 273 + {Val: "nsfw"}, 274 + {Val: "spoiler"}, 275 + }, 276 + }, 277 + AuthorDID: testUserDID, 259 278 } 260 279 261 280 // Will fail at token refresh (expected with fake token) 262 - _, err := postService.CreatePost(ctx, req) 281 + authCtx := middleware.SetTestUserDID(ctx, testUserDID) 282 + _, err := postService.CreatePost(authCtx, req) 263 283 require.Error(t, err) 264 284 // Should fail at token refresh, not validation 265 285 assert.Contains(t, err.Error(), "failed to refresh community credentials") ··· 316 336 title := "Test Title" 317 337 318 338 post := &posts.Post{ 319 - URI: "at://" + testCommunityDID + "/social.coves.post.record/test123", 339 + URI: "at://" + testCommunityDID + "/social.coves.community.post/test123", 320 340 CID: "bafy2test123", 321 341 RKey: "test123", 322 342 AuthorDID: testUserDID, ··· 335 355 content := "Duplicate post" 336 356 337 357 post1 := &posts.Post{ 338 - URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate", 358 + URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate", 339 359 CID: "bafy2duplicate1", 340 360 RKey: "duplicate", 341 361 AuthorDID: testUserDID, ··· 348 368 349 369 // Try to insert again with same URI 350 370 post2 := &posts.Post{ 351 - URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate", 371 + URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate", 352 372 CID: "bafy2duplicate2", 353 373 RKey: "duplicate", 354 374 AuthorDID: testUserDID,
+15 -15
tests/integration/post_e2e_test.go
··· 33 33 // XRPC endpoint → AppView Service → PDS write → Jetstream consumer → DB indexing 34 34 // 35 35 // This is a TRUE E2E test that simulates what happens in production: 36 - // 1. Client calls POST /xrpc/social.coves.post.create with auth token 36 + // 1. Client calls POST /xrpc/social.coves.community.post.create with auth token 37 37 // 2. Handler validates and calls PostService.CreatePost() 38 38 // 3. Service writes post to community's PDS repository 39 39 // 4. PDS broadcasts event to firehose/Jetstream ··· 116 116 Kind: "commit", 117 117 Commit: &jetstream.CommitEvent{ 118 118 Operation: "create", 119 - Collection: "social.coves.post.record", 119 + Collection: "social.coves.community.post", 120 120 RKey: rkey, 121 121 CID: "bafy2bzaceabc123def456", // Fake CID 122 122 Record: map[string]interface{}{ 123 - "$type": "social.coves.post.record", 123 + "$type": "social.coves.community.post", 124 124 "community": community.DID, 125 125 "author": author.DID, 126 126 "title": *postReq.Title, ··· 138 138 } 139 139 140 140 // STEP 4: Verify post was indexed in AppView database 141 - expectedURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey) 141 + expectedURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey) 142 142 indexedPost, err := postRepo.GetByURI(ctx, expectedURI) 143 143 if err != nil { 144 144 t.Fatalf("Post not indexed in AppView: %v", err) ··· 187 187 Kind: "commit", 188 188 Commit: &jetstream.CommitEvent{ 189 189 Operation: "create", 190 - Collection: "social.coves.post.record", 190 + Collection: "social.coves.community.post", 191 191 RKey: generateTID(), 192 192 CID: "bafy2bzacefake", 193 193 Record: map[string]interface{}{ 194 - "$type": "social.coves.post.record", 194 + "$type": "social.coves.community.post", 195 195 "community": community.DID, // Claims to be for this community 196 196 "author": author.DID, 197 197 "title": "Fake Post", ··· 227 227 Kind: "commit", 228 228 Commit: &jetstream.CommitEvent{ 229 229 Operation: "create", 230 - Collection: "social.coves.post.record", 230 + Collection: "social.coves.community.post", 231 231 RKey: rkey, 232 232 CID: "bafy2bzaceidempotent", 233 233 Record: map[string]interface{}{ 234 - "$type": "social.coves.post.record", 234 + "$type": "social.coves.community.post", 235 235 "community": community.DID, 236 236 "author": author.DID, 237 237 "title": "Duplicate Test", ··· 256 256 } 257 257 258 258 // Verify only one post in database 259 - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey) 259 + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey) 260 260 post, err := postRepo.GetByURI(ctx, uri) 261 261 if err != nil { 262 262 t.Fatalf("Post not found: %v", err) ··· 281 281 Kind: "commit", 282 282 Commit: &jetstream.CommitEvent{ 283 283 Operation: "create", 284 - Collection: "social.coves.post.record", 284 + Collection: "social.coves.community.post", 285 285 RKey: generateTID(), 286 286 CID: "bafy2bzaceorphaned", 287 287 Record: map[string]interface{}{ 288 - "$type": "social.coves.post.record", 288 + "$type": "social.coves.community.post", 289 289 "community": unknownCommunityDID, 290 290 "author": author.DID, 291 291 "title": "Orphaned Post", ··· 313 313 } 314 314 315 315 // TestPostCreation_E2E_LivePDS tests the COMPLETE end-to-end flow with a live PDS: 316 - // 1. HTTP POST to /xrpc/social.coves.post.create (with auth) 316 + // 1. HTTP POST to /xrpc/social.coves.community.post.create (with auth) 317 317 // 2. Handler → Service → Write to community's PDS repository 318 318 // 3. PDS → Jetstream firehose event 319 319 // 4. Jetstream consumer → Index in AppView database ··· 472 472 require.NoError(t, err) 473 473 474 474 // Create HTTP request 475 - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) 475 + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) 476 476 req.Header.Set("Content-Type", "application/json") 477 477 478 478 // Create a simple JWT for testing (Phase 1: no signature verification) ··· 511 511 pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port 512 512 513 513 // Build Jetstream URL with filters for post records 514 - jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.post.record", 514 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.post", 515 515 pdsHostname) 516 516 517 517 t.Logf(" Jetstream URL: %s", jetstreamURL) ··· 653 653 654 654 // Check if this is a post event for the target DID 655 655 if event.Did == targetDID && event.Kind == "commit" && 656 - event.Commit != nil && event.Commit.Collection == "social.coves.post.record" { 656 + event.Commit != nil && event.Commit.Collection == "social.coves.community.post" { 657 657 // Process the event through the consumer 658 658 if err := consumer.HandleEvent(ctx, &event); err != nil { 659 659 return fmt.Errorf("failed to process event: %w", err)
+13 -13
tests/integration/post_handler_test.go
··· 55 55 } 56 56 57 57 body, _ := json.Marshal(payload) 58 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 58 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 59 59 60 60 // Mock authenticated user context 61 61 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 82 82 } 83 83 84 84 body, _ := json.Marshal(payload) 85 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 85 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 86 86 87 87 // No auth context set 88 88 rec := httptest.NewRecorder() ··· 108 108 } 109 109 110 110 body, _ := json.Marshal(payload) 111 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 111 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 112 112 113 113 // Mock authenticated user context 114 114 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 131 131 // Invalid JSON 132 132 invalidJSON := []byte(`{"community": "did:plc:test123", "content": `) 133 133 134 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(invalidJSON)) 134 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(invalidJSON)) 135 135 136 136 // Mock authenticated user context 137 137 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 157 157 } 158 158 159 159 body, _ := json.Marshal(payload) 160 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 160 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 161 161 162 162 // Mock authenticated user context 163 163 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 192 192 } 193 193 194 194 body, _ := json.Marshal(payload) 195 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 195 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 196 196 197 197 // Mock authenticated user context 198 198 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 231 231 } 232 232 233 233 body, _ := json.Marshal(payload) 234 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 234 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 235 235 236 236 // Mock authenticated user context 237 237 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 269 269 } 270 270 271 271 body, _ := json.Marshal(payload) 272 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 272 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 273 273 274 274 // Mock authenticated user context 275 275 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 307 307 } 308 308 309 309 body, _ := json.Marshal(payload) 310 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 310 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 311 311 312 312 // Mock authenticated user context 313 313 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 345 345 } 346 346 347 347 body, _ := json.Marshal(payload) 348 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 348 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 349 349 350 350 // Mock authenticated user context 351 351 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") ··· 373 373 374 374 for _, method := range methods { 375 375 t.Run(method, func(t *testing.T) { 376 - req := httptest.NewRequest(method, "/xrpc/social.coves.post.create", nil) 376 + req := httptest.NewRequest(method, "/xrpc/social.coves.community.post.create", nil) 377 377 rec := httptest.NewRecorder() 378 378 379 379 handler.HandleCreate(rec, req) ··· 422 422 } 423 423 424 424 body, _ := json.Marshal(payload) 425 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 425 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 426 426 427 427 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") 428 428 req = req.WithContext(ctx) ··· 452 452 } 453 453 454 454 body, _ := json.Marshal(payload) 455 - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) 455 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) 456 456 457 457 ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") 458 458 req = req.WithContext(ctx)
+1 -1
tests/integration/timeline_test.go
··· 100 100 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i) 101 101 record, ok := feedPost.Post.Record.(map[string]interface{}) 102 102 require.True(t, ok, "Record should be a map") 103 - assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type") 103 + assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type") 104 104 assert.NotEmpty(t, record["community"], "Record should have community") 105 105 assert.NotEmpty(t, record["author"], "Record should have author") 106 106 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
+1 -1
tests/lexicon-test-data/actor/saved-invalid-type.json
··· 1 1 { 2 2 "$type": "social.coves.actor.saved", 3 - "subject": "at://did:plc:exampleuser/social.coves.post.record/3k7a3dmb5bk2c", 3 + "subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c", 4 4 "type": "article", 5 5 "createdAt": "2025-01-09T14:30:00Z" 6 6 }
+1 -1
tests/lexicon-test-data/actor/saved-valid.json
··· 1 1 { 2 2 "$type": "social.coves.actor.saved", 3 - "subject": "at://did:plc:exampleuser/social.coves.post.record/3k7a3dmb5bk2c", 3 + "subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c", 4 4 "type": "post", 5 5 "createdAt": "2025-01-09T14:30:00Z", 6 6 "note": "Great tutorial on Go concurrency patterns"
+12 -3
tests/lexicon-test-data/interaction/comment-invalid-content.json
··· 1 1 { 2 - "$type": "social.coves.interaction.comment", 3 - "post": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c", 2 + "$type": "social.coves.feed.comment", 3 + "reply": { 4 + "root": { 5 + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", 6 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 7 + }, 8 + "parent": { 9 + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", 10 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 11 + } 12 + }, 4 13 "createdAt": "2025-01-09T16:45:00Z" 5 - } 14 + }
+12 -7
tests/lexicon-test-data/interaction/comment-valid-sticker.json
··· 1 1 { 2 - "$type": "social.coves.interaction.comment", 3 - "subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c", 4 - "content": { 5 - "$type": "social.coves.interaction.comment#stickerContent", 6 - "stickerId": "thumbs-up", 7 - "stickerPackId": "default-pack" 2 + "$type": "social.coves.feed.comment", 3 + "reply": { 4 + "root": { 5 + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", 6 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 7 + }, 8 + "parent": { 9 + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", 10 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 11 + } 8 12 }, 13 + "content": "👍", 9 14 "createdAt": "2025-01-09T16:50:00Z" 10 - } 15 + }
+26 -20
tests/lexicon-test-data/interaction/comment-valid-text.json
··· 1 1 { 2 - "$type": "social.coves.interaction.comment", 3 - "subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c", 4 - "content": { 5 - "$type": "social.coves.interaction.comment#textContent", 6 - "text": "Great post! I especially liked the part about @alice.example.com's contribution to the project.", 7 - "facets": [ 8 - { 9 - "index": { 10 - "byteStart": 46, 11 - "byteEnd": 64 12 - }, 13 - "features": [ 14 - { 15 - "$type": "social.coves.richtext.facet#mention", 16 - "did": "did:plc:aliceuser123" 17 - } 18 - ] 19 - } 20 - ] 2 + "$type": "social.coves.feed.comment", 3 + "reply": { 4 + "root": { 5 + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", 6 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 7 + }, 8 + "parent": { 9 + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", 10 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 11 + } 21 12 }, 13 + "content": "Great post! I especially liked the part about @alice.example.com's contribution to the project.", 14 + "facets": [ 15 + { 16 + "index": { 17 + "byteStart": 46, 18 + "byteEnd": 64 19 + }, 20 + "features": [ 21 + { 22 + "$type": "social.coves.richtext.facet#mention", 23 + "did": "did:plc:aliceuser123" 24 + } 25 + ] 26 + } 27 + ], 22 28 "createdAt": "2025-01-09T16:30:00Z" 23 - } 29 + }
+1 -1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
··· 1 1 { 2 2 "$type": "social.coves.moderation.tribunalVote", 3 3 "tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c", 4 - "subject": "at://did:plc:user123/social.coves.post.record/3k7a2clb4bj2b", 4 + "subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b", 5 5 "decision": "maybe", 6 6 "createdAt": "2025-01-09T18:00:00Z" 7 7 }
+1 -1
tests/lexicon-test-data/moderation/tribunal-vote-valid.json
··· 1 1 { 2 2 "$type": "social.coves.moderation.tribunalVote", 3 3 "tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c", 4 - "subject": "at://did:plc:spammer123/social.coves.post.record/3k7a2clb4bj2b", 4 + "subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b", 5 5 "decision": "remove", 6 6 "reasoning": "The moderator's action was justified based on clear violation of Rule 2 (No Spam). The user posted the same promotional content across multiple communities within a short timeframe.", 7 7 "precedents": [
+5 -5
tests/lexicon-test-data/post/post-invalid-enum-type.json
··· 1 1 { 2 - "$type": "social.coves.post.record", 2 + "$type": "social.coves.community.post", 3 3 "community": "did:plc:programming123", 4 + "author": "did:plc:testauthor123", 4 5 "postType": "invalid-type", 5 6 "title": "This has an invalid post type", 6 - "text": "The postType field has an invalid value", 7 + "content": "The postType field is not defined in the schema and should be rejected", 7 8 "tags": [], 8 - "language": "en", 9 - "contentWarnings": [], 9 + "langs": ["en"], 10 10 "createdAt": "2025-01-09T14:30:00Z" 11 - } 11 + }
+5 -6
tests/lexicon-test-data/post/post-invalid-missing-community.json
··· 1 1 { 2 - "$type": "social.coves.post.record", 3 - "postType": "text", 2 + "$type": "social.coves.community.post", 3 + "author": "did:plc:testauthor123", 4 4 "title": "Test Post", 5 - "text": "This post is missing the required community field", 5 + "content": "This post is missing the required community field", 6 6 "tags": ["test"], 7 - "language": "en", 8 - "contentWarnings": [], 7 + "langs": ["en"], 9 8 "createdAt": "2025-01-09T14:30:00Z" 10 - } 9 + }
+6 -7
tests/lexicon-test-data/post/post-valid-text.json
··· 1 1 { 2 - "$type": "social.coves.post.record", 2 + "$type": "social.coves.community.post", 3 3 "community": "did:plc:programming123", 4 - "postType": "text", 4 + "author": "did:plc:testauthor123", 5 5 "title": "Best practices for error handling in Go", 6 - "text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...", 7 - "textFacets": [ 6 + "content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...", 7 + "facets": [ 8 8 { 9 9 "index": { 10 10 "byteStart": 20, ··· 18 18 } 19 19 ], 20 20 "tags": ["golang", "error-handling", "best-practices"], 21 - "language": "en", 22 - "contentWarnings": [], 21 + "langs": ["en"], 23 22 "createdAt": "2025-01-09T14:30:00Z" 24 - } 23 + }
+10 -4
tests/lexicon_validation_test.go
··· 48 48 schemaID := strings.ReplaceAll(relPath, string(filepath.Separator), ".") 49 49 50 50 t.Run(schemaID, func(t *testing.T) { 51 + // Skip validation for definition-only files (*.defs) - they don't need a "main" section 52 + // These files only contain shared type definitions referenced by other schemas 53 + if strings.HasSuffix(schemaID, ".defs") { 54 + t.Skip("Skipping defs-only file (no main section required)") 55 + } 56 + 51 57 if _, resolveErr := catalog.Resolve(schemaID); resolveErr != nil { 52 58 t.Errorf("Failed to resolve schema %s: %v", schemaID, resolveErr) 53 59 } ··· 137 143 }, 138 144 { 139 145 name: "Valid post record", 140 - recordType: "social.coves.post.record", 146 + recordType: "social.coves.community.post", 141 147 recordData: map[string]interface{}{ 142 - "$type": "social.coves.post.record", 148 + "$type": "social.coves.community.post", 143 149 "community": "did:plc:programming123", 144 150 "author": "did:plc:testauthor123", 145 151 "title": "Test Post", ··· 150 156 }, 151 157 { 152 158 name: "Invalid post record - missing required field", 153 - recordType: "social.coves.post.record", 159 + recordType: "social.coves.community.post", 154 160 recordData: map[string]interface{}{ 155 - "$type": "social.coves.post.record", 161 + "$type": "social.coves.community.post", 156 162 "community": "did:plc:programming123", 157 163 // Missing required "author" field 158 164 "title": "Test Post",