diff --git a/cmd/validate-lexicon/main.go b/cmd/validate-lexicon/main.go index 929900f..2fcb924 100644 --- a/cmd/validate-lexicon/main.go +++ b/cmd/validate-lexicon/main.go @@ -119,6 +119,15 @@ func validateSchemaStructure(catalog *lexicon.BaseCatalog, schemaPath string, ve } for i, schemaID := range schemaIDs { + // Skip validation for definition-only files (*.defs) - they don't need a "main" section + // These files only contain shared type definitions referenced by other schemas + if strings.HasSuffix(schemaID, ".defs") { + if verbose { + fmt.Printf(" ⏭️ %s (defs-only file, skipping main validation)\n", schemaID) + } + continue + } + if _, err := catalog.Resolve(schemaID); err != nil { validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err)) } else if verbose { @@ -415,17 +424,15 @@ func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error { "social.coves.richtext.facet#spoiler", // Post types and views - "social.coves.post.get#postView", - "social.coves.post.get#authorView", - "social.coves.post.get#communityRef", - "social.coves.post.get#imageView", - "social.coves.post.get#videoView", - "social.coves.post.get#externalView", - "social.coves.post.get#postStats", - "social.coves.post.get#viewerState", - - // Post record types - "social.coves.post.record#originalAuthor", + "social.coves.community.post.get#postView", + "social.coves.community.post.get#authorView", + "social.coves.community.post.get#communityRef", + "social.coves.community.post.get#postStats", + "social.coves.community.post.get#viewerState", + "social.coves.community.post.get#notFoundPost", + "social.coves.community.post.get#blockedPost", + + // Post record types (removed - no longer exists in new structure) // Actor definitions "social.coves.actor.profile#geoLocation", diff --git a/internal/atproto/lexicon/social/coves/community/post.json b/internal/atproto/lexicon/social/coves/community/post.json new file mode 100644 index 0000000..78b4947 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post.json @@ -0,0 +1,100 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post", + "defs": { + "main": { + "type": "record", + "description": "A post in a Coves community. Posts live in community repositories and persist independently of the author.", + "key": "tid", + "record": { + "type": "object", + "required": ["community", "author", "createdAt"], + "properties": { + "community": { + "type": "string", + "format": "at-identifier", + "description": "DID or handle of the community this was posted to" + }, + "author": { + "type": "string", + "format": "did", + "description": "DID of the user who created this post" + }, + "title": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "Post title (optional for media-only posts)" + }, + "content": { + "type": "string", + "maxGraphemes": 10000, + "maxLength": 100000, + "description": "Post content - supports rich text via facets" + }, + "facets": { + "type": "array", + "description": "Annotations for rich text (mentions, links, tags)", + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + } + }, + "embed": { + "type": "union", + "description": "Embedded media, external links, or quoted posts", + "refs": [ + "social.coves.embed.images", + "social.coves.embed.video", + "social.coves.embed.external", + "social.coves.embed.post" + ] + }, + "langs": { + "type": "array", + "description": "Languages used in the post content (ISO 639-1)", + "maxLength": 3, + "items": { + "type": "string", + "format": "language" + } + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels (NSFW, spoilers, etc.)" + }, + "tags": { + "type": "array", + "description": "User-applied topic tags", + "maxLength": 8, + "items": { + "type": "string", + "maxLength": 64, + "maxGraphemes": 64 + } + }, + "crosspostOf": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "If this is a crosspost, strong reference to the immediate parent post" + }, + "crosspostChain": { + "type": "array", + "description": "Full chain of crossposts with version pinning. First element is original, last is immediate parent.", + "maxLength": 25, + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of post creation" + } + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/community/post/create.json b/internal/atproto/lexicon/social/coves/community/post/create.json new file mode 100644 index 0000000..7e615a0 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post/create.json @@ -0,0 +1,119 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post.create", + "defs": { + "main": { + "type": "procedure", + "description": "Create a new post in a community", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["community"], + "properties": { + "community": { + "type": "string", + "format": "at-identifier", + "description": "DID or handle of the community to post in" + }, + "title": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "Post title (optional for media-only posts)" + }, + "content": { + "type": "string", + "maxGraphemes": 10000, + "maxLength": 100000, + "description": "Post content - supports rich text via facets" + }, + "facets": { + "type": "array", + "description": "Annotations for rich text (mentions, links, tags)", + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + } + }, + "embed": { + "type": "union", + "description": "Embedded media, external links, or quoted posts", + "refs": [ + "social.coves.embed.images", + "social.coves.embed.video", + "social.coves.embed.external", + "social.coves.embed.post" + ] + }, + "langs": { + "type": "array", + "description": "Languages used in the post content (ISO 639-1)", + "maxLength": 3, + "items": { + "type": "string", + "format": "language" + } + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels (NSFW, spoilers, etc.)" + }, + "tags": { + "type": "array", + "description": "User-applied topic tags", + "maxLength": 8, + "items": { + "type": "string", + "maxLength": 64, + "maxGraphemes": 64 + } + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the created post" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID of the created post" + } + } + } + }, + "errors": [ + { + "name": "CommunityNotFound", + "description": "Community not found" + }, + { + "name": "NotAuthorized", + "description": "User is not authorized to post in this community" + }, + { + "name": "Banned", + "description": "User is banned from this community" + }, + { + "name": "InvalidContent", + "description": "Post content violates community rules" + }, + { + "name": "ContentRuleViolation", + "description": "Post violates community content rules (e.g., embeds not allowed, text too short)" + } + ] + } + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/community/post/delete.json b/internal/atproto/lexicon/social/coves/community/post/delete.json new file mode 100644 index 0000000..5bd5f3e --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post/delete.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post.delete", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a post", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the post to delete" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": {} + } + }, + "errors": [ + { + "name": "PostNotFound", + "description": "Post not found" + }, + { + "name": "NotAuthorized", + "description": "User is not authorized to delete this post" + } + ] + } + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/community/post/get.json b/internal/atproto/lexicon/social/coves/community/post/get.json new file mode 100644 index 0000000..ea07d57 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post/get.json @@ -0,0 +1,294 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post.get", + "defs": { + "main": { + "type": "query", + "description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs.", + "parameters": { + "type": "params", + "required": ["uris"], + "properties": { + "uris": { + "type": "array", + "description": "List of post AT-URIs to fetch (max 25)", + "items": { + "type": "string", + "format": "at-uri" + }, + "maxLength": 25, + "minLength": 1 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["posts"], + "properties": { + "posts": { + "type": "array", + "description": "Array of post views. May include notFound/blocked entries for missing posts.", + "items": { + "type": "union", + "refs": ["#postView", "#notFoundPost", "#blockedPost"] + } + } + } + } + }, + "errors": [ + {"name": "InvalidRequest", "description": "Invalid URI format or empty array"} + ] + }, + "postView": { + "type": "object", + "required": ["uri", "cid", "author", "record", "community", "createdAt", "indexedAt"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "author": { + "type": "ref", + "ref": "#authorView" + }, + "record": { + "type": "unknown", + "description": "The actual post record (text, image, video, etc.)" + }, + "community": { + "type": "ref", + "ref": "#communityRef" + }, + "title": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textFacets": { + "type": "array", + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + } + }, + "embed": { + "type": "union", + "description": "Embedded content (images, video, link preview, or quoted post)", + "refs": [ + "social.coves.embed.images#view", + "social.coves.embed.video#view", + "social.coves.embed.external#view", + "social.coves.embed.record#view", + "social.coves.embed.recordWithMedia#view" + ] + }, + "language": { + "type": "string", + "format": "language" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "editedAt": { + "type": "string", + "format": "datetime" + }, + "indexedAt": { + "type": "string", + "format": "datetime", + "description": "When this post was indexed by the AppView" + }, + "stats": { + "type": "ref", + "ref": "#postStats" + }, + "viewer": { + "type": "ref", + "ref": "#viewerState" + } + } + }, + "authorView": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string", + "format": "handle" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string", + "format": "uri" + }, + "reputation": { + "type": "integer", + "description": "Author's reputation in the community" + } + } + }, + "communityRef": { + "type": "object", + "required": ["did", "name"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "name": { + "type": "string" + }, + "avatar": { + "type": "string", + "format": "uri" + } + } + }, + "notFoundPost": { + "type": "object", + "description": "Post was not found (deleted, never indexed, or invalid URI)", + "required": ["uri", "notFound"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "notFound": { + "type": "boolean", + "const": true + } + } + }, + "blockedPost": { + "type": "object", + "description": "Post is blocked due to viewer blocking author/community, or community moderation", + "required": ["uri", "blocked"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "blocked": { + "type": "boolean", + "const": true + }, + "blockedBy": { + "type": "string", + "enum": ["author", "community", "moderator"], + "description": "What caused the block: viewer blocked author, viewer blocked community, or post was removed by moderators" + }, + "author": { + "type": "ref", + "ref": "#blockedAuthor" + }, + "community": { + "type": "ref", + "ref": "#blockedCommunity" + } + } + }, + "blockedAuthor": { + "type": "object", + "description": "Minimal author info for blocked posts", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + } + } + }, + "blockedCommunity": { + "type": "object", + "description": "Minimal community info for blocked posts", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "name": { + "type": "string" + } + } + }, + "postStats": { + "type": "object", + "required": ["upvotes", "downvotes", "score", "commentCount"], + "properties": { + "upvotes": { + "type": "integer", + "minimum": 0 + }, + "downvotes": { + "type": "integer", + "minimum": 0 + }, + "score": { + "type": "integer", + "description": "Calculated score (upvotes - downvotes)" + }, + "commentCount": { + "type": "integer", + "minimum": 0 + }, + "shareCount": { + "type": "integer", + "minimum": 0 + }, + "tagCounts": { + "type": "object", + "description": "Aggregate counts of tags applied by community members", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "viewerState": { + "type": "object", + "properties": { + "vote": { + "type": "string", + "enum": ["up", "down"], + "description": "Viewer's vote on this post" + }, + "voteUri": { + "type": "string", + "format": "at-uri" + }, + "saved": { + "type": "boolean" + }, + "savedUri": { + "type": "string", + "format": "at-uri" + }, + "tags": { + "type": "array", + "description": "Tags applied by the viewer to this post", + "items": { + "type": "string", + "maxLength": 32 + } + } + } + } + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/community/post/search.json b/internal/atproto/lexicon/social/coves/community/post/search.json new file mode 100644 index 0000000..987f2a6 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post/search.json @@ -0,0 +1,80 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post.search", + "defs": { + "main": { + "type": "query", + "description": "Search for posts", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "description": "Search query" + }, + "community": { + "type": "string", + "format": "at-identifier", + "description": "Filter by specific community" + }, + "author": { + "type": "string", + "format": "at-identifier", + "description": "Filter by author" + }, + "type": { + "type": "string", + "enum": ["text", "image", "video", "article", "microblog"], + "description": "Filter by post type" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter by tags" + }, + "sort": { + "type": "string", + "enum": ["relevance", "new", "top"], + "default": "relevance" + }, + "timeframe": { + "type": "string", + "enum": ["hour", "day", "week", "month", "year", "all"], + "default": "all" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["posts"], + "properties": { + "posts": { + "type": "array", + "items": { + "type": "ref", + "ref": "social.coves.feed.defs#feedViewPost" + } + }, + "cursor": { + "type": "string" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/community/post/update.json b/internal/atproto/lexicon/social/coves/community/post/update.json new file mode 100644 index 0000000..101eee3 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post/update.json @@ -0,0 +1,119 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post.update", + "defs": { + "main": { + "type": "procedure", + "description": "Update an existing post", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the post to update" + }, + "title": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "Updated title" + }, + "content": { + "type": "string", + "maxLength": 50000, + "description": "Updated content - main text for text posts, description for media, etc." + }, + "facets": { + "type": "array", + "description": "Updated rich text annotations for content", + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + } + }, + "embed": { + "type": "union", + "description": "Updated embedded content (note: changing embed type may be restricted)", + "refs": [ + "social.coves.embed.images", + "social.coves.embed.video", + "social.coves.embed.external", + "social.coves.embed.post" + ] + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Updated self-applied content labels" + }, + "langs": { + "type": "array", + "description": "Updated languages (ISO 639-1)", + "maxLength": 3, + "items": { + "type": "string", + "format": "language" + } + }, + "tags": { + "type": "array", + "description": "Updated topic tags", + "maxLength": 8, + "items": { + "type": "string", + "maxLength": 64, + "maxGraphemes": 64 + } + }, + "editNote": { + "type": "string", + "maxLength": 300, + "description": "Optional note explaining the edit" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the updated post" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "New CID of the updated post" + } + } + } + }, + "errors": [ + { + "name": "PostNotFound", + "description": "Post not found" + }, + { + "name": "NotAuthorized", + "description": "User is not authorized to edit this post" + }, + { + "name": "EditWindowExpired", + "description": "Edit window has expired (posts can only be edited within 24 hours)" + }, + { + "name": "InvalidUpdate", + "description": "Invalid update operation (e.g., changing post type)" + } + ] + } + } +} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/embed/post.json b/internal/atproto/lexicon/social/coves/embed/post.json index 1b8b7d6..46362be 100644 --- a/internal/atproto/lexicon/social/coves/embed/post.json +++ b/internal/atproto/lexicon/social/coves/embed/post.json @@ -4,13 +4,13 @@ "defs": { "main": { "type": "object", - "description": "Embedded reference to another post", - "required": ["uri"], + "description": "Embedded reference to another post (quoted post)", + "required": ["post"], "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the post being embedded" + "post": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the embedded post (includes URI and CID)" } } } diff --git a/internal/atproto/lexicon/social/coves/feed/defs.json b/internal/atproto/lexicon/social/coves/feed/defs.json index af91f77..16c11f9 100644 --- a/internal/atproto/lexicon/social/coves/feed/defs.json +++ b/internal/atproto/lexicon/social/coves/feed/defs.json @@ -9,7 +9,7 @@ "properties": { "post": { "type": "ref", - "ref": "social.coves.post.get#postView" + "ref": "social.coves.community.post.get#postView" }, "reason": { "type": "union", @@ -29,7 +29,7 @@ "properties": { "by": { "type": "ref", - "ref": "social.coves.post.get#authorView" + "ref": "social.coves.community.post.get#authorView" }, "indexedAt": { "type": "string", @@ -44,7 +44,7 @@ "properties": { "community": { "type": "ref", - "ref": "social.coves.post.get#communityRef" + "ref": "social.coves.community.post.get#communityRef" } } }, diff --git a/internal/validation/lexicon.go b/internal/validation/lexicon.go index 10db80d..c5d035f 100644 --- a/internal/validation/lexicon.go +++ b/internal/validation/lexicon.go @@ -81,12 +81,12 @@ func (v *LexiconValidator) ValidateCommunityProfile(profile map[string]interface // ValidatePost validates a post record func (v *LexiconValidator) ValidatePost(post map[string]interface{}) error { - return v.ValidateRecord(post, "social.coves.post.record") + return v.ValidateRecord(post, "social.coves.community.post") } // ValidateComment validates a comment record func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error { - return v.ValidateRecord(comment, "social.coves.interaction.comment") + return v.ValidateRecord(comment, "social.coves.feed.comment") } // ValidateVote validates a vote record @@ -99,7 +99,7 @@ func (v *LexiconValidator) ValidateModerationAction(action map[string]interface{ return v.ValidateRecord(action, fmt.Sprintf("social.coves.moderation.%s", actionType)) } -// ResolveReference resolves a schema reference (e.g., "social.coves.post.get#postView") +// ResolveReference resolves a schema reference (e.g., "social.coves.community.post.get#postView") func (v *LexiconValidator) ResolveReference(ref string) (interface{}, error) { return v.catalog.Resolve(ref) } diff --git a/internal/validation/lexicon_test.go b/internal/validation/lexicon_test.go index 62f1583..54c5f78 100644 --- a/internal/validation/lexicon_test.go +++ b/internal/validation/lexicon_test.go @@ -58,7 +58,7 @@ func TestValidatePost(t *testing.T) { // Valid post validPost := map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": "did:plc:test123", "author": "did:plc:author123", "title": "Test Post", @@ -72,7 +72,7 @@ func TestValidatePost(t *testing.T) { // Invalid post - missing required field (author) invalidPost := map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": "did:plc:test123", // Missing required "author" field "title": "Test Post", @@ -94,7 +94,11 @@ func TestValidateRecordWithDifferentInputTypes(t *testing.T) { // Test with JSON string jsonString := `{ "$type": "social.coves.interaction.vote", - "subject": "at://did:plc:test/social.coves.post.text/abc123", + "subject": { + "uri": "at://did:plc:test/social.coves.community.post/abc123", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + }, + "direction": "up", "createdAt": "2024-01-01T00:00:00Z" }` diff --git a/tests/lexicon_validation_test.go b/tests/lexicon_validation_test.go index 8e2d2e0..885c455 100644 --- a/tests/lexicon_validation_test.go +++ b/tests/lexicon_validation_test.go @@ -48,6 +48,12 @@ func TestLexiconSchemaValidation(t *testing.T) { schemaID := strings.ReplaceAll(relPath, string(filepath.Separator), ".") t.Run(schemaID, func(t *testing.T) { + // Skip validation for definition-only files (*.defs) - they don't need a "main" section + // These files only contain shared type definitions referenced by other schemas + if strings.HasSuffix(schemaID, ".defs") { + t.Skip("Skipping defs-only file (no main section required)") + } + if _, resolveErr := catalog.Resolve(schemaID); resolveErr != nil { t.Errorf("Failed to resolve schema %s: %v", schemaID, resolveErr) } @@ -137,9 +143,9 @@ func TestValidateRecord(t *testing.T) { }, { name: "Valid post record", - recordType: "social.coves.post.record", + recordType: "social.coves.community.post", recordData: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": "did:plc:programming123", "author": "did:plc:testauthor123", "title": "Test Post", @@ -150,9 +156,9 @@ func TestValidateRecord(t *testing.T) { }, { name: "Invalid post record - missing required field", - recordType: "social.coves.post.record", + recordType: "social.coves.community.post", recordData: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": "did:plc:programming123", // Missing required "author" field "title": "Test Post", -- 2.51.2 From 307c4407d0d6e244d505daf80ab307636b63ad81 Mon Sep 17 00:00:00 2001 From: Bretton May Date: Mon, 3 Nov 2025 19:28:44 -0800 Subject: [PATCH 2/5] feat(labels): implement com.atproto.label.defs#selfLabels structure Implements proper atproto label structure with optional 'neg' field for negating labels. This fixes client-supplied labels being dropped and ensures full round-trip compatibility. Changes: - Add SelfLabels and SelfLabel structs per com.atproto.label.defs spec - SelfLabel includes Val (required) and Neg (optional bool pointer) fields - Update CreatePostRequest.Labels from []string to *SelfLabels - Update PostRecord.Labels to structured format - Update validation logic to iterate over Labels.Values - Update jetstream consumer to use structured labels Before: Labels were []string, breaking in 3 ways: 1. Client-supplied structured labels ignored (JSON decoder drops object) 2. PDS rejects unknown contentLabels array field 3. Jetstream consumer marshals incorrectly After: Full com.atproto.label.defs#selfLabels support with neg field preservation. --- internal/atproto/jetstream/post_consumer.go | 22 ++++++------- internal/core/posts/post.go | 27 ++++++++++++---- internal/core/posts/service.go | 36 +++++++++++---------- 3 files changed, 50 insertions(+), 35 deletions(-) diff --git a/internal/atproto/jetstream/post_consumer.go b/internal/atproto/jetstream/post_consumer.go index ea94c33..899fa38 100644 --- a/internal/atproto/jetstream/post_consumer.go +++ b/internal/atproto/jetstream/post_consumer.go @@ -13,9 +13,9 @@ import ( ) // PostEventConsumer consumes post-related events from Jetstream -// Currently handles only CREATE operations for social.coves.post.record +// Currently handles only CREATE operations for social.coves.community.post // UPDATE and DELETE handlers will be added when those features are implemented -type PostEventConsumer struct { +type PostEventConsumer struct{ postRepo posts.Repository communityRepo communities.Repository userService users.UserService @@ -46,7 +46,7 @@ func (c *PostEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEve // Only handle post record creation for now // UPDATE and DELETE will be added when we implement those features - if commit.Collection == "social.coves.post.record" && commit.Operation == "create" { + if commit.Collection == "social.coves.community.post" && commit.Operation == "create" { return c.createPost(ctx, event.Did, commit) } @@ -73,8 +73,8 @@ func (c *PostEventConsumer) createPost(ctx context.Context, repoDID string, comm } // Build AT-URI for this post - // Format: at://community_did/social.coves.post.record/rkey - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey) + // Format: at://community_did/social.coves.community.post/rkey + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey) // Parse timestamp from record createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt) @@ -119,8 +119,8 @@ func (c *PostEventConsumer) createPost(ctx context.Context, repoDID string, comm } } - if len(postRecord.ContentLabels) > 0 { - labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels) + if postRecord.Labels != nil { + labelsJSON, marshalErr := json.Marshal(postRecord.Labels) if marshalErr == nil { labelsStr := string(labelsJSON) post.ContentLabels = &labelsStr @@ -151,7 +151,7 @@ func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID strin // This prevents users from creating posts that appear to be from communities they don't control // // Example attack prevented: - // - User creates post in their own repo (at://user_did/social.coves.post.record/xyz) + // - User creates post in their own repo (at://user_did/social.coves.community.post/xyz) // - Claims it's for community X (community field = community_did) // - Without this check, fake post would be indexed // @@ -199,8 +199,8 @@ func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID strin } // PostRecordFromJetstream represents a post record as received from Jetstream -// Matches the structure written to PDS via social.coves.post.record -type PostRecordFromJetstream struct { +// Matches the structure written to PDS via social.coves.community.post +type PostRecordFromJetstream struct{ OriginalAuthor interface{} `json:"originalAuthor,omitempty"` FederatedFrom interface{} `json:"federatedFrom,omitempty"` Location interface{} `json:"location,omitempty"` @@ -212,7 +212,7 @@ type PostRecordFromJetstream struct { Author string `json:"author"` CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - ContentLabels []string `json:"contentLabels,omitempty"` + Labels *posts.SelfLabels `json:"labels,omitempty"` } // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream diff --git a/internal/core/posts/post.go b/internal/core/posts/post.go index 860fbbb..e0f74d2 100644 --- a/internal/core/posts/post.go +++ b/internal/core/posts/post.go @@ -4,6 +4,19 @@ import ( "time" ) +// SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels +// This is the structured format used in atProto for content warnings +type SelfLabels struct { + Values []SelfLabel `json:"values"` +} + +// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel +// Neg is optional and negates the label when true +type SelfLabel struct { + Val string `json:"val"` // Required: label value (max 128 chars) + Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true +} + // Post represents a post in the AppView database // Posts are indexed from the firehose after being written to community repositories type Post struct { @@ -12,7 +25,7 @@ type Post struct { EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"` Embed *string `json:"embed,omitempty" db:"embed"` DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` - ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"` + ContentLabels *string `json:"labels,omitempty" db:"content_labels"` Title *string `json:"title,omitempty" db:"title"` Content *string `json:"content,omitempty" db:"content"` ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` @@ -29,7 +42,7 @@ type Post struct { } // CreatePostRequest represents input for creating a new post -// Matches social.coves.post.create lexicon input schema +// Matches social.coves.community.post.create lexicon input schema type CreatePostRequest struct { OriginalAuthor interface{} `json:"originalAuthor,omitempty"` FederatedFrom interface{} `json:"federatedFrom,omitempty"` @@ -40,12 +53,12 @@ type CreatePostRequest struct { Community string `json:"community"` AuthorDID string `json:"authorDid"` Facets []interface{} `json:"facets,omitempty"` - ContentLabels []string `json:"contentLabels,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` } // CreatePostResponse represents the response from creating a post -// Matches social.coves.post.create lexicon output schema -type CreatePostResponse struct { +// Matches social.coves.community.post.create lexicon output schema +type CreatePostResponse struct{ URI string `json:"uri"` // AT-URI of created post CID string `json:"cid"` // CID of created post } @@ -64,11 +77,11 @@ type PostRecord struct { Author string `json:"author"` CreatedAt string `json:"createdAt"` Facets []interface{} `json:"facets,omitempty"` - ContentLabels []string `json:"contentLabels,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` } // PostView represents the full view of a post with all metadata -// Matches social.coves.post.get#postView lexicon +// Matches social.coves.community.post.get#postView lexicon // Used in feeds and get endpoints type PostView struct { IndexedAt time.Time `json:"indexedAt"` diff --git a/internal/core/posts/service.go b/internal/core/posts/service.go index 7dfed6d..8e8b719 100644 --- a/internal/core/posts/service.go +++ b/internal/core/posts/service.go @@ -145,14 +145,14 @@ func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*C // 8. Build post record for PDS postRecord := PostRecord{ - Type: "social.coves.post.record", + Type: "social.coves.community.post", Community: communityDID, Author: req.AuthorDID, Title: req.Title, Content: req.Content, Facets: req.Facets, Embed: req.Embed, - ContentLabels: req.ContentLabels, + Labels: req.Labels, OriginalAuthor: req.OriginalAuthor, FederatedFrom: req.FederatedFrom, Location: req.Location, @@ -187,9 +187,9 @@ func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*C func (s *postService) validateCreateRequest(req CreatePostRequest) error { // Global content limits (from lexicon) const ( - maxContentLength = 50000 // 50k characters - maxTitleLength = 3000 // 3k bytes - maxTitleGraphemes = 300 // 300 graphemes (simplified check) + maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon + maxTitleLength = 3000 // 3k bytes + maxTitleGraphemes = 300 // 300 graphemes (simplified check) ) // Validate community required @@ -219,15 +219,17 @@ func (s *postService) validateCreateRequest(req CreatePostRequest) error { } // Validate content labels are from known values - validLabels := map[string]bool{ - "nsfw": true, - "spoiler": true, - "violence": true, - } - for _, label := range req.ContentLabels { - if !validLabels[label] { - return NewValidationError("contentLabels", - fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label)) + if req.Labels != nil { + validLabels := map[string]bool{ + "nsfw": true, + "spoiler": true, + "violence": true, + } + for _, label := range req.Labels.Values { + if !validLabels[label.Val] { + return NewValidationError("labels", + fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val)) + } } } @@ -257,9 +259,9 @@ func (s *postService) createPostOnPDS( // IMPORTANT: repo is set to community DID, not author DID // This writes the post to the community's repository payload := map[string]interface{}{ - "repo": community.DID, // Community's repository - "collection": "social.coves.post.record", // Collection type - "record": record, // The post record + "repo": community.DID, // Community's repository + "collection": "social.coves.community.post", // Collection type + "record": record, // The post record // "rkey" omitted - PDS will auto-generate TID } -- 2.51.2 From 7c350f54cb4d5a3d06d3050fb7aa6cb29d0b151d Mon Sep 17 00:00:00 2001 From: Bretton May Date: Mon, 3 Nov 2025 19:28:47 -0800 Subject: [PATCH 3/5] feat(db): migrate content_labels from TEXT[] to JSONB for selfLabels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates content_labels column from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure including the optional 'neg' field. Changes: - Migration 015: TEXT[] → JSONB with data conversion function - Convert existing {nsfw,spoiler} to {"values":[{"val":"nsfw"},{"val":"spoiler"}]} - Update post_repo to store/retrieve full JSON blob (no flattening) - Update feed repos to deserialize JSONB directly - Remove pq.StringArray usage from all repositories Before: TEXT[] storage lost 'neg' field and future extensions After: JSONB preserves complete selfLabels structure with no data loss Migration uses temporary PL/pgSQL function to handle conversion safely. Rollback migration converts back to TEXT[] (lossy - drops 'neg' field). --- .../015_alter_content_labels_to_jsonb.sql | 47 +++++++++++++++++++ internal/db/postgres/feed_repo.go | 19 ++++---- internal/db/postgres/feed_repo_base.go | 17 ++++--- internal/db/postgres/post_repo.go | 32 +++++-------- 4 files changed, 80 insertions(+), 35 deletions(-) create mode 100644 internal/db/migrations/015_alter_content_labels_to_jsonb.sql diff --git a/internal/db/migrations/015_alter_content_labels_to_jsonb.sql b/internal/db/migrations/015_alter_content_labels_to_jsonb.sql new file mode 100644 index 0000000..5f4f977 --- /dev/null +++ b/internal/db/migrations/015_alter_content_labels_to_jsonb.sql @@ -0,0 +1,47 @@ +-- +goose Up +-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure +-- This allows storing the optional 'neg' field and future extensions + +-- Create temporary function to convert TEXT[] to selfLabels JSONB +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[]) +RETURNS JSONB AS $$ +BEGIN + IF labels IS NULL OR array_length(labels, 1) = 0 THEN + RETURN NULL; + END IF; + + RETURN jsonb_build_object( + 'values', + (SELECT jsonb_agg(jsonb_build_object('val', label)) + FROM unnest(labels) AS label) + ); +END; +$$ LANGUAGE plpgsql IMMUTABLE; +-- +goose StatementEnd + +-- Convert column type using the function +ALTER TABLE posts + ALTER COLUMN content_labels TYPE JSONB + USING convert_labels_to_jsonb(content_labels); + +-- Drop the temporary function +DROP FUNCTION convert_labels_to_jsonb(TEXT[]); + +-- Update column comment +COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})'; + +-- +goose Down +-- Revert JSONB back to TEXT[] (lossy - drops 'neg' field) +ALTER TABLE posts + ALTER COLUMN content_labels TYPE TEXT[] + USING CASE + WHEN content_labels IS NULL THEN NULL + ELSE ARRAY( + SELECT value->>'val' + FROM jsonb_array_elements(content_labels->'values') AS value + ) + END; + +-- Restore original comment +COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)'; diff --git a/internal/db/postgres/feed_repo.go b/internal/db/postgres/feed_repo.go index e5914bd..7587f9d 100644 --- a/internal/db/postgres/feed_repo.go +++ b/internal/db/postgres/feed_repo.go @@ -11,8 +11,6 @@ import ( "strconv" "strings" "time" - - "github.com/lib/pq" ) type postgresFeedRepo struct { @@ -329,7 +327,7 @@ func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.Fee communityRef posts.CommunityRef title, content sql.NullString facets, embed sql.NullString - labels pq.StringArray + labelsJSON sql.NullString editedAt sql.NullTime communityAvatar sql.NullString hotRank sql.NullFloat64 @@ -339,7 +337,7 @@ func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.Fee &postView.URI, &postView.CID, &postView.RKey, &authorView.DID, &authorView.Handle, &communityRef.DID, &communityRef.Name, &communityAvatar, - &title, &content, &facets, &embed, &labels, + &title, &content, &facets, &embed, &labelsJSON, &postView.CreatedAt, &editedAt, &postView.IndexedAt, &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, &hotRank, @@ -386,9 +384,9 @@ func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.Fee // Alpha: No viewer state for basic feed // TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton - // Build the record (required by lexicon - social.coves.post.record structure) + // Build the record (required by lexicon - social.coves.community.post structure) record := map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": communityRef.DID, "author": authorView.DID, "createdAt": postView.CreatedAt.Format(time.RFC3339), @@ -413,8 +411,13 @@ func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.Fee record["embed"] = embedData } } - if len(labels) > 0 { - record["contentLabels"] = labels + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + // Deserialize and include in record + var selfLabels posts.SelfLabels + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { + record["labels"] = selfLabels + } } postView.Record = record diff --git a/internal/db/postgres/feed_repo_base.go b/internal/db/postgres/feed_repo_base.go index cce8d69..874b5a3 100644 --- a/internal/db/postgres/feed_repo_base.go +++ b/internal/db/postgres/feed_repo_base.go @@ -12,8 +12,6 @@ import ( "strconv" "strings" "time" - - "github.com/lib/pq" ) // feedRepoBase contains shared logic for timeline and discover feed repositories @@ -283,7 +281,7 @@ func (r *feedRepoBase) scanFeedPost(rows *sql.Rows) (*posts.PostView, float64, e communityRef posts.CommunityRef title, content sql.NullString facets, embed sql.NullString - labels pq.StringArray + labelsJSON sql.NullString editedAt sql.NullTime communityAvatar sql.NullString hotRank sql.NullFloat64 @@ -293,7 +291,7 @@ func (r *feedRepoBase) scanFeedPost(rows *sql.Rows) (*posts.PostView, float64, e &postView.URI, &postView.CID, &postView.RKey, &authorView.DID, &authorView.Handle, &communityRef.DID, &communityRef.Name, &communityAvatar, - &title, &content, &facets, &embed, &labels, + &title, &content, &facets, &embed, &labelsJSON, &postView.CreatedAt, &editedAt, &postView.IndexedAt, &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, &hotRank, @@ -339,7 +337,7 @@ func (r *feedRepoBase) scanFeedPost(rows *sql.Rows) (*posts.PostView, float64, e // Build the record (required by lexicon) record := map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": communityRef.DID, "author": authorView.DID, "createdAt": postView.CreatedAt.Format(time.RFC3339), @@ -364,8 +362,13 @@ func (r *feedRepoBase) scanFeedPost(rows *sql.Rows) (*posts.PostView, float64, e record["embed"] = embedData } } - if len(labels) > 0 { - record["contentLabels"] = labels + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + // Deserialize and include in record + var selfLabels posts.SelfLabels + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { + record["labels"] = selfLabels + } } postView.Record = record diff --git a/internal/db/postgres/post_repo.go b/internal/db/postgres/post_repo.go index 2b84efe..6363ea9 100644 --- a/internal/db/postgres/post_repo.go +++ b/internal/db/postgres/post_repo.go @@ -4,11 +4,8 @@ import ( "Coves/internal/core/posts" "context" "database/sql" - "encoding/json" "fmt" "strings" - - "github.com/lib/pq" ) type postgresPostRepo struct { @@ -36,14 +33,13 @@ func (r *postgresPostRepo) Create(ctx context.Context, post *posts.Post) error { embedJSON.Valid = true } - // Convert content labels to PostgreSQL array - var labelsArray pq.StringArray + // Store content labels as JSONB + // post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]} + // Store the full JSON blob to preserve the 'neg' field and future extensions + var labelsJSON sql.NullString if post.ContentLabels != nil { - // Parse JSON array string to []string - var labels []string - if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil { - labelsArray = labels - } + labelsJSON.String = *post.ContentLabels + labelsJSON.Valid = true } query := ` @@ -62,7 +58,7 @@ func (r *postgresPostRepo) Create(ctx context.Context, post *posts.Post) error { err := r.db.QueryRowContext( ctx, query, post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID, - post.Title, post.Content, facetsJSON, embedJSON, labelsArray, + post.Title, post.Content, facetsJSON, embedJSON, labelsJSON, post.CreatedAt, ).Scan(&post.ID, &post.IndexedAt) if err != nil { @@ -101,13 +97,12 @@ func (r *postgresPostRepo) GetByURI(ctx context.Context, uri string) (*posts.Pos ` var post posts.Post - var facetsJSON, embedJSON sql.NullString - var contentLabels pq.StringArray + var facetsJSON, embedJSON, labelsJSON sql.NullString err := r.db.QueryRowContext(ctx, query, uri).Scan( &post.ID, &post.URI, &post.CID, &post.RKey, &post.AuthorDID, &post.CommunityDID, - &post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels, + &post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON, &post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt, &post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount, ) @@ -126,12 +121,9 @@ func (r *postgresPostRepo) GetByURI(ctx context.Context, uri string) (*posts.Pos if embedJSON.Valid { post.Embed = &embedJSON.String } - if len(contentLabels) > 0 { - labelsJSON, marshalErr := json.Marshal(contentLabels) - if marshalErr == nil { - labelsStr := string(labelsJSON) - post.ContentLabels = &labelsStr - } + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + post.ContentLabels = &labelsJSON.String } return &post, nil -- 2.51.2 From 170508af09bdd8e314695f987078f6e2469482c7 Mon Sep 17 00:00:00 2001 From: Bretton May Date: Mon, 3 Nov 2025 19:28:50 -0800 Subject: [PATCH 4/5] test: update tests for lexicon migration and selfLabels structure Updates all tests to use new social.coves.community.post namespace and structured com.atproto.label.defs#selfLabels format. Changes: - Update test data to match new lexicon schema (author field, facets, etc) - Update integration tests to use SelfLabels{Values: []SelfLabel{...}} - Update vote_repo_test to use new namespace - Update post creation tests for label validation - Update E2E tests for community post namespace All lexicon validation tests passing (64 pass, 2 skip for defs files). All integration tests passing with JSONB label storage. --- internal/db/postgres/vote_repo_test.go | 22 +++---- tests/integration/aggregator_e2e_test.go | 16 ++--- tests/integration/aggregator_test.go | 8 +-- tests/integration/feed_test.go | 2 +- tests/integration/helpers.go | 2 +- tests/integration/post_creation_test.go | 62 ++++++++++++------- tests/integration/post_e2e_test.go | 30 ++++----- tests/integration/post_handler_test.go | 26 ++++---- tests/integration/timeline_test.go | 2 +- .../actor/saved-invalid-type.json | 2 +- .../lexicon-test-data/actor/saved-valid.json | 2 +- .../interaction/comment-invalid-content.json | 15 ++++- .../interaction/comment-valid-sticker.json | 19 +++--- .../interaction/comment-valid-text.json | 46 ++++++++------ .../tribunal-vote-invalid-decision.json | 2 +- .../moderation/tribunal-vote-valid.json | 2 +- .../post/post-invalid-enum-type.json | 10 +-- .../post/post-invalid-missing-community.json | 11 ++-- .../post/post-valid-text.json | 13 ++-- 19 files changed, 165 insertions(+), 127 deletions(-) diff --git a/internal/db/postgres/vote_repo_test.go b/internal/db/postgres/vote_repo_test.go index 67dd2f3..5367219 100644 --- a/internal/db/postgres/vote_repo_test.go +++ b/internal/db/postgres/vote_repo_test.go @@ -67,7 +67,7 @@ func TestVoteRepo_Create(t *testing.T) { CID: "bafyreigtest123", RKey: "3k1234567890", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/abc123", + SubjectURI: "at://did:plc:community/social.coves.community.post/abc123", SubjectCID: "bafyreigpost123", Direction: "up", CreatedAt: time.Now(), @@ -95,7 +95,7 @@ func TestVoteRepo_Create_Idempotent(t *testing.T) { CID: "bafyreigtest456", RKey: "3k9876543210", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/xyz789", + SubjectURI: "at://did:plc:community/social.coves.community.post/xyz789", SubjectCID: "bafyreigpost456", Direction: "down", CreatedAt: time.Now(), @@ -136,7 +136,7 @@ func TestVoteRepo_Create_VoterNotFound(t *testing.T) { CID: "bafyreignovoter", RKey: "3k1111111111", VoterDID: "did:plc:nonexistentvoter", - SubjectURI: "at://did:plc:community/social.coves.post.record/test123", + SubjectURI: "at://did:plc:community/social.coves.community.post/test123", SubjectCID: "bafyreigpost789", Direction: "up", CreatedAt: time.Now(), @@ -168,7 +168,7 @@ func TestVoteRepo_GetByURI(t *testing.T) { CID: "bafyreigtest789", RKey: "3k5555555555", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/post123", + SubjectURI: "at://did:plc:community/social.coves.community.post/post123", SubjectCID: "bafyreigpost999", Direction: "up", CreatedAt: time.Now(), @@ -207,7 +207,7 @@ func TestVoteRepo_GetByVoterAndSubject(t *testing.T) { voterDID := "did:plc:testvoter999" createTestUser(t, db, "testvoter999.test", voterDID) - subjectURI := "at://did:plc:community/social.coves.post.record/subject123" + subjectURI := "at://did:plc:community/social.coves.community.post/subject123" // Create vote vote := &votes.Vote{ @@ -238,7 +238,7 @@ func TestVoteRepo_GetByVoterAndSubject_NotFound(t *testing.T) { repo := NewVoteRepository(db) ctx := context.Background() - _, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.post.record/nopost") + _, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.community.post/nopost") assert.ErrorIs(t, err, votes.ErrVoteNotFound) } @@ -259,7 +259,7 @@ func TestVoteRepo_Delete(t *testing.T) { CID: "bafyreigdelete", RKey: "3k7777777777", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest", + SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest", SubjectCID: "bafyreigdeletepost", Direction: "up", CreatedAt: time.Now(), @@ -297,7 +297,7 @@ func TestVoteRepo_Delete_Idempotent(t *testing.T) { CID: "bafyreigdelete2", RKey: "3k8888888888", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest2", + SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest2", SubjectCID: "bafyreigdeletepost2", Direction: "down", CreatedAt: time.Now(), @@ -327,7 +327,7 @@ func TestVoteRepo_ListBySubject(t *testing.T) { createTestUser(t, db, "testvoterlist1.test", voterDID1) createTestUser(t, db, "testvoterlist2.test", voterDID2) - subjectURI := "at://did:plc:community/social.coves.post.record/listtest" + subjectURI := "at://did:plc:community/social.coves.community.post/listtest" // Create multiple votes on same subject vote1 := &votes.Vote{ @@ -377,7 +377,7 @@ func TestVoteRepo_ListByVoter(t *testing.T) { CID: "bafyreigvoter1", RKey: "3k0000000001", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/post1", + SubjectURI: "at://did:plc:community/social.coves.community.post/post1", SubjectCID: "bafyreigp1", Direction: "up", CreatedAt: time.Now(), @@ -387,7 +387,7 @@ func TestVoteRepo_ListByVoter(t *testing.T) { CID: "bafyreigvoter2", RKey: "3k0000000002", VoterDID: voterDID, - SubjectURI: "at://did:plc:community/social.coves.post.record/post2", + SubjectURI: "at://did:plc:community/social.coves.community.post/post2", SubjectCID: "bafyreigp2", Direction: "down", CreatedAt: time.Now(), diff --git a/tests/integration/aggregator_e2e_test.go b/tests/integration/aggregator_e2e_test.go index 6a750e6..4387917 100644 --- a/tests/integration/aggregator_e2e_test.go +++ b/tests/integration/aggregator_e2e_test.go @@ -332,7 +332,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { reqJSON, err := json.Marshal(reqBody) require.NoError(t, err) - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") // Create JWT for aggregator (not a user) @@ -360,11 +360,11 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { Kind: "commit", Commit: &jetstream.CommitEvent{ Operation: "create", - Collection: "social.coves.post.record", + Collection: "social.coves.community.post", RKey: rkey, CID: response.CID, Record: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": communityDID, "author": aggregatorDID, // Aggregator is the author "title": title, @@ -422,7 +422,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { reqJSON, err := json.Marshal(reqBody) require.NoError(t, err) - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) @@ -444,7 +444,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { reqJSON, err := json.Marshal(reqBody) require.NoError(t, err) - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) @@ -465,7 +465,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { reqJSON, err = json.Marshal(reqBody) require.NoError(t, err) - req = httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) @@ -657,7 +657,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { reqJSON, err := json.Marshal(reqBody) require.NoError(t, err) - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(unauthorizedAggDID)) @@ -781,7 +781,7 @@ func TestAggregator_E2E_WithJetstream(t *testing.T) { reqJSON, err := json.Marshal(reqBody) require.NoError(t, err) - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID)) diff --git a/tests/integration/aggregator_test.go b/tests/integration/aggregator_test.go index 8523f7d..ea0af30 100644 --- a/tests/integration/aggregator_test.go +++ b/tests/integration/aggregator_test.go @@ -544,7 +544,7 @@ func TestAggregatorService_PostCreationIntegration(t *testing.T) { }) t.Run("records aggregator post for rate limiting", func(t *testing.T) { - postURI := fmt.Sprintf("at://%s/social.coves.post.record/post1", communityDID) + postURI := fmt.Sprintf("at://%s/social.coves.community.post/post1", communityDID) err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123") if err != nil { @@ -627,7 +627,7 @@ func TestAggregatorService_RateLimiting(t *testing.T) { t.Run("allows posts within rate limit", func(t *testing.T) { // Create 9 posts (under the 10/hour limit) for i := 0; i < 9; i++ { - postURI := fmt.Sprintf("at://%s/social.coves.post.record/post%d", communityDID, i) + postURI := fmt.Sprintf("at://%s/social.coves.community.post/post%d", communityDID, i) if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil { t.Fatalf("Failed to record post %d: %v", i, err) } @@ -642,7 +642,7 @@ func TestAggregatorService_RateLimiting(t *testing.T) { t.Run("enforces rate limit at 10 posts/hour", func(t *testing.T) { // Add one more post to hit the limit (total = 10) - postURI := fmt.Sprintf("at://%s/social.coves.post.record/post10", communityDID) + postURI := fmt.Sprintf("at://%s/social.coves.community.post/post10", communityDID) if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil { t.Fatalf("Failed to record 10th post: %v", err) } @@ -801,7 +801,7 @@ func TestAggregatorTriggers(t *testing.T) { // Record 5 posts for i := 0; i < 5; i++ { - postURI := fmt.Sprintf("at://%s/social.coves.post.record/triggerpost%d", communityDID, i) + postURI := fmt.Sprintf("at://%s/social.coves.community.post/triggerpost%d", communityDID, i) if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil { t.Fatalf("Failed to record post %d: %v", i, err) } diff --git a/tests/integration/feed_test.go b/tests/integration/feed_test.go index f15fc6b..a4051de 100644 --- a/tests/integration/feed_test.go +++ b/tests/integration/feed_test.go @@ -81,7 +81,7 @@ func TestGetCommunityFeed_Hot(t *testing.T) { assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i) record, ok := feedPost.Post.Record.(map[string]interface{}) require.True(t, ok, "Record should be a map") - assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type") + assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type") assert.NotEmpty(t, record["community"], "Record should have community") assert.NotEmpty(t, record["author"], "Record should have author") assert.NotEmpty(t, record["createdAt"], "Record should have createdAt") diff --git a/tests/integration/helpers.go b/tests/integration/helpers.go index f37923c..717b41c 100644 --- a/tests/integration/helpers.go +++ b/tests/integration/helpers.go @@ -274,7 +274,7 @@ func createTestPost(t *testing.T, db *sql.DB, communityDID, authorDID, title str // Generate URI rkey := fmt.Sprintf("post-%d", time.Now().UnixNano()) - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", communityDID, rkey) + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey) // Insert post _, err := db.ExecContext(ctx, ` diff --git a/tests/integration/post_creation_test.go b/tests/integration/post_creation_test.go index 1688d9b..c8db667 100644 --- a/tests/integration/post_creation_test.go +++ b/tests/integration/post_creation_test.go @@ -1,6 +1,7 @@ package integration import ( + "Coves/internal/api/middleware" "Coves/internal/atproto/identity" "Coves/internal/core/communities" "Coves/internal/core/posts" @@ -97,7 +98,8 @@ func TestPostCreation_Basic(t *testing.T) { // This will fail at token refresh step (expected for unit test) // We're using a fake token that can't be parsed - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) // For now, we expect an error because token is fake // In a full E2E test with real PDS, this would succeed @@ -123,7 +125,8 @@ func TestPostCreation_Basic(t *testing.T) { // Should resolve handle to DID and proceed // Will still fail at token refresh (expected with fake token) - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) // Should fail at token refresh, not community resolution assert.Contains(t, err.Error(), "failed to refresh community credentials") @@ -152,7 +155,8 @@ func TestPostCreation_Basic(t *testing.T) { // Should resolve handle to DID and proceed // Will still fail at token refresh (expected with fake token) - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) // Should fail at token refresh, not community resolution assert.Contains(t, err.Error(), "failed to refresh community credentials") @@ -167,7 +171,8 @@ func TestPostCreation_Basic(t *testing.T) { AuthorDID: testUserDID, } - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) assert.True(t, posts.IsValidationError(err)) }) @@ -181,7 +186,8 @@ func TestPostCreation_Basic(t *testing.T) { AuthorDID: testUserDID, } - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) // Should fail with community not found (wrapped in error) assert.Contains(t, err.Error(), "community not found") @@ -196,7 +202,8 @@ func TestPostCreation_Basic(t *testing.T) { AuthorDID: "", // Missing! } - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) assert.True(t, posts.IsValidationError(err)) assert.Contains(t, err.Error(), "authorDid") @@ -211,7 +218,8 @@ func TestPostCreation_Basic(t *testing.T) { AuthorDID: testUserDID, } - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) assert.Equal(t, posts.ErrCommunityNotFound, err) }) @@ -226,7 +234,8 @@ func TestPostCreation_Basic(t *testing.T) { AuthorDID: testUserDID, } - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) assert.True(t, posts.IsValidationError(err)) assert.Contains(t, err.Error(), "too long") @@ -236,13 +245,18 @@ func TestPostCreation_Basic(t *testing.T) { content := "Post with invalid label" req := posts.CreatePostRequest{ - Community: testCommunity.DID, - Content: &content, - ContentLabels: []string{"invalid_label"}, // Not in known values! - AuthorDID: testUserDID, + Community: testCommunity.DID, + Content: &content, + Labels: &posts.SelfLabels{ + Values: []posts.SelfLabel{ + {Val: "invalid_label"}, // Not in known values! + }, + }, + AuthorDID: testUserDID, } - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) assert.True(t, posts.IsValidationError(err)) assert.Contains(t, err.Error(), "unknown content label") @@ -252,14 +266,20 @@ func TestPostCreation_Basic(t *testing.T) { content := "Post with valid labels" req := posts.CreatePostRequest{ - Community: testCommunity.DID, - Content: &content, - ContentLabels: []string{"nsfw", "spoiler"}, - AuthorDID: testUserDID, + Community: testCommunity.DID, + Content: &content, + Labels: &posts.SelfLabels{ + Values: []posts.SelfLabel{ + {Val: "nsfw"}, + {Val: "spoiler"}, + }, + }, + AuthorDID: testUserDID, } // Will fail at token refresh (expected with fake token) - _, err := postService.CreatePost(ctx, req) + authCtx := middleware.SetTestUserDID(ctx, testUserDID) + _, err := postService.CreatePost(authCtx, req) require.Error(t, err) // Should fail at token refresh, not validation assert.Contains(t, err.Error(), "failed to refresh community credentials") @@ -316,7 +336,7 @@ func TestPostRepository_Create(t *testing.T) { title := "Test Title" post := &posts.Post{ - URI: "at://" + testCommunityDID + "/social.coves.post.record/test123", + URI: "at://" + testCommunityDID + "/social.coves.community.post/test123", CID: "bafy2test123", RKey: "test123", AuthorDID: testUserDID, @@ -335,7 +355,7 @@ func TestPostRepository_Create(t *testing.T) { content := "Duplicate post" post1 := &posts.Post{ - URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate", + URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate", CID: "bafy2duplicate1", RKey: "duplicate", AuthorDID: testUserDID, @@ -348,7 +368,7 @@ func TestPostRepository_Create(t *testing.T) { // Try to insert again with same URI post2 := &posts.Post{ - URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate", + URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate", CID: "bafy2duplicate2", RKey: "duplicate", AuthorDID: testUserDID, diff --git a/tests/integration/post_e2e_test.go b/tests/integration/post_e2e_test.go index 463c6d9..f308120 100644 --- a/tests/integration/post_e2e_test.go +++ b/tests/integration/post_e2e_test.go @@ -33,7 +33,7 @@ import ( // XRPC endpoint → AppView Service → PDS write → Jetstream consumer → DB indexing // // This is a TRUE E2E test that simulates what happens in production: -// 1. Client calls POST /xrpc/social.coves.post.create with auth token +// 1. Client calls POST /xrpc/social.coves.community.post.create with auth token // 2. Handler validates and calls PostService.CreatePost() // 3. Service writes post to community's PDS repository // 4. PDS broadcasts event to firehose/Jetstream @@ -116,11 +116,11 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { Kind: "commit", Commit: &jetstream.CommitEvent{ Operation: "create", - Collection: "social.coves.post.record", + Collection: "social.coves.community.post", RKey: rkey, CID: "bafy2bzaceabc123def456", // Fake CID Record: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": community.DID, "author": author.DID, "title": *postReq.Title, @@ -138,7 +138,7 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { } // STEP 4: Verify post was indexed in AppView database - expectedURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey) + expectedURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey) indexedPost, err := postRepo.GetByURI(ctx, expectedURI) if err != nil { t.Fatalf("Post not indexed in AppView: %v", err) @@ -187,11 +187,11 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { Kind: "commit", Commit: &jetstream.CommitEvent{ Operation: "create", - Collection: "social.coves.post.record", + Collection: "social.coves.community.post", RKey: generateTID(), CID: "bafy2bzacefake", Record: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": community.DID, // Claims to be for this community "author": author.DID, "title": "Fake Post", @@ -227,11 +227,11 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { Kind: "commit", Commit: &jetstream.CommitEvent{ Operation: "create", - Collection: "social.coves.post.record", + Collection: "social.coves.community.post", RKey: rkey, CID: "bafy2bzaceidempotent", Record: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": community.DID, "author": author.DID, "title": "Duplicate Test", @@ -256,7 +256,7 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { } // Verify only one post in database - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey) + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey) post, err := postRepo.GetByURI(ctx, uri) if err != nil { t.Fatalf("Post not found: %v", err) @@ -281,11 +281,11 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { Kind: "commit", Commit: &jetstream.CommitEvent{ Operation: "create", - Collection: "social.coves.post.record", + Collection: "social.coves.community.post", RKey: generateTID(), CID: "bafy2bzaceorphaned", Record: map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": unknownCommunityDID, "author": author.DID, "title": "Orphaned Post", @@ -313,7 +313,7 @@ func TestPostCreation_E2E_WithJetstream(t *testing.T) { } // TestPostCreation_E2E_LivePDS tests the COMPLETE end-to-end flow with a live PDS: -// 1. HTTP POST to /xrpc/social.coves.post.create (with auth) +// 1. HTTP POST to /xrpc/social.coves.community.post.create (with auth) // 2. Handler → Service → Write to community's PDS repository // 3. PDS → Jetstream firehose event // 4. Jetstream consumer → Index in AppView database @@ -472,7 +472,7 @@ func TestPostCreation_E2E_LivePDS(t *testing.T) { require.NoError(t, err) // Create HTTP request - req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON)) + req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON)) req.Header.Set("Content-Type", "application/json") // Create a simple JWT for testing (Phase 1: no signature verification) @@ -511,7 +511,7 @@ func TestPostCreation_E2E_LivePDS(t *testing.T) { pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port // Build Jetstream URL with filters for post records - jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.post.record", + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.post", pdsHostname) t.Logf(" Jetstream URL: %s", jetstreamURL) @@ -653,7 +653,7 @@ func subscribeToJetstreamForPost( // Check if this is a post event for the target DID if event.Did == targetDID && event.Kind == "commit" && - event.Commit != nil && event.Commit.Collection == "social.coves.post.record" { + event.Commit != nil && event.Commit.Collection == "social.coves.community.post" { // Process the event through the consumer if err := consumer.HandleEvent(ctx, &event); err != nil { return fmt.Errorf("failed to process event: %w", err) diff --git a/tests/integration/post_handler_test.go b/tests/integration/post_handler_test.go index 4a80568..71819f6 100644 --- a/tests/integration/post_handler_test.go +++ b/tests/integration/post_handler_test.go @@ -55,7 +55,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -82,7 +82,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // No auth context set rec := httptest.NewRecorder() @@ -108,7 +108,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -131,7 +131,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { // Invalid JSON invalidJSON := []byte(`{"community": "did:plc:test123", "content": `) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(invalidJSON)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(invalidJSON)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -157,7 +157,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -192,7 +192,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -231,7 +231,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -269,7 +269,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -307,7 +307,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -345,7 +345,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) // Mock authenticated user context ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") @@ -373,7 +373,7 @@ func TestPostHandler_SecurityValidation(t *testing.T) { for _, method := range methods { t.Run(method, func(t *testing.T) { - req := httptest.NewRequest(method, "/xrpc/social.coves.post.create", nil) + req := httptest.NewRequest(method, "/xrpc/social.coves.community.post.create", nil) rec := httptest.NewRecorder() handler.HandleCreate(rec, req) @@ -422,7 +422,7 @@ func TestPostHandler_SpecialCharacters(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") req = req.WithContext(ctx) @@ -452,7 +452,7 @@ func TestPostHandler_SpecialCharacters(t *testing.T) { } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body)) ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice") req = req.WithContext(ctx) diff --git a/tests/integration/timeline_test.go b/tests/integration/timeline_test.go index ad3eaf0..a24982c 100644 --- a/tests/integration/timeline_test.go +++ b/tests/integration/timeline_test.go @@ -100,7 +100,7 @@ func TestGetTimeline_Basic(t *testing.T) { assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i) record, ok := feedPost.Post.Record.(map[string]interface{}) require.True(t, ok, "Record should be a map") - assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type") + assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type") assert.NotEmpty(t, record["community"], "Record should have community") assert.NotEmpty(t, record["author"], "Record should have author") assert.NotEmpty(t, record["createdAt"], "Record should have createdAt") diff --git a/tests/lexicon-test-data/actor/saved-invalid-type.json b/tests/lexicon-test-data/actor/saved-invalid-type.json index fc0cca3..ec5b436 100644 --- a/tests/lexicon-test-data/actor/saved-invalid-type.json +++ b/tests/lexicon-test-data/actor/saved-invalid-type.json @@ -1,6 +1,6 @@ { "$type": "social.coves.actor.saved", - "subject": "at://did:plc:exampleuser/social.coves.post.record/3k7a3dmb5bk2c", + "subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c", "type": "article", "createdAt": "2025-01-09T14:30:00Z" } \ No newline at end of file diff --git a/tests/lexicon-test-data/actor/saved-valid.json b/tests/lexicon-test-data/actor/saved-valid.json index f41fabe..92ea22a 100644 --- a/tests/lexicon-test-data/actor/saved-valid.json +++ b/tests/lexicon-test-data/actor/saved-valid.json @@ -1,6 +1,6 @@ { "$type": "social.coves.actor.saved", - "subject": "at://did:plc:exampleuser/social.coves.post.record/3k7a3dmb5bk2c", + "subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c", "type": "post", "createdAt": "2025-01-09T14:30:00Z", "note": "Great tutorial on Go concurrency patterns" diff --git a/tests/lexicon-test-data/interaction/comment-invalid-content.json b/tests/lexicon-test-data/interaction/comment-invalid-content.json index 8619cbe..6a3f152 100644 --- a/tests/lexicon-test-data/interaction/comment-invalid-content.json +++ b/tests/lexicon-test-data/interaction/comment-invalid-content.json @@ -1,5 +1,14 @@ { - "$type": "social.coves.interaction.comment", - "post": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c", + "$type": "social.coves.feed.comment", + "reply": { + "root": { + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + }, + "parent": { + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + } + }, "createdAt": "2025-01-09T16:45:00Z" -} \ No newline at end of file +} diff --git a/tests/lexicon-test-data/interaction/comment-valid-sticker.json b/tests/lexicon-test-data/interaction/comment-valid-sticker.json index 1e1e24c..3108bf1 100644 --- a/tests/lexicon-test-data/interaction/comment-valid-sticker.json +++ b/tests/lexicon-test-data/interaction/comment-valid-sticker.json @@ -1,10 +1,15 @@ { - "$type": "social.coves.interaction.comment", - "subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c", - "content": { - "$type": "social.coves.interaction.comment#stickerContent", - "stickerId": "thumbs-up", - "stickerPackId": "default-pack" + "$type": "social.coves.feed.comment", + "reply": { + "root": { + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + }, + "parent": { + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + } }, + "content": "👍", "createdAt": "2025-01-09T16:50:00Z" -} \ No newline at end of file +} diff --git a/tests/lexicon-test-data/interaction/comment-valid-text.json b/tests/lexicon-test-data/interaction/comment-valid-text.json index a71c434..1b695ac 100644 --- a/tests/lexicon-test-data/interaction/comment-valid-text.json +++ b/tests/lexicon-test-data/interaction/comment-valid-text.json @@ -1,23 +1,29 @@ { - "$type": "social.coves.interaction.comment", - "subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c", - "content": { - "$type": "social.coves.interaction.comment#textContent", - "text": "Great post! I especially liked the part about @alice.example.com's contribution to the project.", - "facets": [ - { - "index": { - "byteStart": 46, - "byteEnd": 64 - }, - "features": [ - { - "$type": "social.coves.richtext.facet#mention", - "did": "did:plc:aliceuser123" - } - ] - } - ] + "$type": "social.coves.feed.comment", + "reply": { + "root": { + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + }, + "parent": { + "uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + } }, + "content": "Great post! I especially liked the part about @alice.example.com's contribution to the project.", + "facets": [ + { + "index": { + "byteStart": 46, + "byteEnd": 64 + }, + "features": [ + { + "$type": "social.coves.richtext.facet#mention", + "did": "did:plc:aliceuser123" + } + ] + } + ], "createdAt": "2025-01-09T16:30:00Z" -} \ No newline at end of file +} diff --git a/tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json b/tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json index bb78a31..01d9d3c 100644 --- a/tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json +++ b/tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json @@ -1,7 +1,7 @@ { "$type": "social.coves.moderation.tribunalVote", "tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c", - "subject": "at://did:plc:user123/social.coves.post.record/3k7a2clb4bj2b", + "subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b", "decision": "maybe", "createdAt": "2025-01-09T18:00:00Z" } \ No newline at end of file diff --git a/tests/lexicon-test-data/moderation/tribunal-vote-valid.json b/tests/lexicon-test-data/moderation/tribunal-vote-valid.json index b6087a4..6a42232 100644 --- a/tests/lexicon-test-data/moderation/tribunal-vote-valid.json +++ b/tests/lexicon-test-data/moderation/tribunal-vote-valid.json @@ -1,7 +1,7 @@ { "$type": "social.coves.moderation.tribunalVote", "tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c", - "subject": "at://did:plc:spammer123/social.coves.post.record/3k7a2clb4bj2b", + "subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b", "decision": "remove", "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.", "precedents": [ diff --git a/tests/lexicon-test-data/post/post-invalid-enum-type.json b/tests/lexicon-test-data/post/post-invalid-enum-type.json index bdaccd2..19af8cc 100644 --- a/tests/lexicon-test-data/post/post-invalid-enum-type.json +++ b/tests/lexicon-test-data/post/post-invalid-enum-type.json @@ -1,11 +1,11 @@ { - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": "did:plc:programming123", + "author": "did:plc:testauthor123", "postType": "invalid-type", "title": "This has an invalid post type", - "text": "The postType field has an invalid value", + "content": "The postType field is not defined in the schema and should be rejected", "tags": [], - "language": "en", - "contentWarnings": [], + "langs": ["en"], "createdAt": "2025-01-09T14:30:00Z" -} \ No newline at end of file +} diff --git a/tests/lexicon-test-data/post/post-invalid-missing-community.json b/tests/lexicon-test-data/post/post-invalid-missing-community.json index 34ba2b5..135dbb2 100644 --- a/tests/lexicon-test-data/post/post-invalid-missing-community.json +++ b/tests/lexicon-test-data/post/post-invalid-missing-community.json @@ -1,10 +1,9 @@ { - "$type": "social.coves.post.record", - "postType": "text", + "$type": "social.coves.community.post", + "author": "did:plc:testauthor123", "title": "Test Post", - "text": "This post is missing the required community field", + "content": "This post is missing the required community field", "tags": ["test"], - "language": "en", - "contentWarnings": [], + "langs": ["en"], "createdAt": "2025-01-09T14:30:00Z" -} \ No newline at end of file +} diff --git a/tests/lexicon-test-data/post/post-valid-text.json b/tests/lexicon-test-data/post/post-valid-text.json index 4f19de7..8d6380c 100644 --- a/tests/lexicon-test-data/post/post-valid-text.json +++ b/tests/lexicon-test-data/post/post-valid-text.json @@ -1,10 +1,10 @@ { - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": "did:plc:programming123", - "postType": "text", + "author": "did:plc:testauthor123", "title": "Best practices for error handling in Go", - "text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...", - "textFacets": [ + "content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...", + "facets": [ { "index": { "byteStart": 20, @@ -18,7 +18,6 @@ } ], "tags": ["golang", "error-handling", "best-practices"], - "language": "en", - "contentWarnings": [], + "langs": ["en"], "createdAt": "2025-01-09T14:30:00Z" -} \ No newline at end of file +} -- 2.51.2 From 68ba19401c54e12b74863951b7a252fda6f779fb Mon Sep 17 00:00:00 2001 From: Bretton May Date: Mon, 3 Nov 2025 19:28:54 -0800 Subject: [PATCH 5/5] chore: update docs and references for lexicon migration Updates remaining documentation, code references, and configuration to reflect the lexicon namespace migration and labels changes. Changes: - Update docs (PRDs, CLAUDE.md) with new namespace references - Update API routes and handlers for community.post - Update aggregator client references - Update feed system documentation - Remove deprecated interaction/comment schemas (moved to feed/comment) No functional changes - documentation and reference updates only. --- aggregators/kagi-news/src/coves_client.py | 6 +- cmd/server/main.go | 4 +- docs/COMMUNITY_FEEDS.md | 10 +- docs/FEED_SYSTEM_IMPLEMENTATION.md | 2 +- docs/PRD_GOVERNANCE.md | 2 +- docs/PRD_POSTS.md | 34 +- docs/aggregators/PRD_AGGREGATORS.md | 8 +- docs/aggregators/PRD_KAGI_NEWS_RSS.md | 6 +- internal/api/handlers/post/create.go | 2 +- internal/api/routes/post.go | 14 +- .../lexicon/social/coves/feed/comment.json | 80 +++++ .../social/coves/interaction/comment.json | 86 ----- .../coves/interaction/createComment.json | 75 ----- .../coves/interaction/deleteComment.json | 41 --- .../lexicon/social/coves/post/create.json | 118 ------- .../lexicon/social/coves/post/crosspost.json | 39 --- .../lexicon/social/coves/post/delete.json | 41 --- .../lexicon/social/coves/post/get.json | 294 ------------------ .../social/coves/post/getCrosspostChain.json | 99 ------ .../lexicon/social/coves/post/record.json | 129 -------- .../lexicon/social/coves/post/search.json | 80 ----- .../lexicon/social/coves/post/update.json | 104 ------- 22 files changed, 124 insertions(+), 1150 deletions(-) create mode 100644 internal/atproto/lexicon/social/coves/feed/comment.json delete mode 100644 internal/atproto/lexicon/social/coves/interaction/comment.json delete mode 100644 internal/atproto/lexicon/social/coves/interaction/createComment.json delete mode 100644 internal/atproto/lexicon/social/coves/interaction/deleteComment.json delete mode 100644 internal/atproto/lexicon/social/coves/post/create.json delete mode 100644 internal/atproto/lexicon/social/coves/post/crosspost.json delete mode 100644 internal/atproto/lexicon/social/coves/post/delete.json delete mode 100644 internal/atproto/lexicon/social/coves/post/get.json delete mode 100644 internal/atproto/lexicon/social/coves/post/getCrosspostChain.json delete mode 100644 internal/atproto/lexicon/social/coves/post/record.json delete mode 100644 internal/atproto/lexicon/social/coves/post/search.json delete mode 100644 internal/atproto/lexicon/social/coves/post/update.json diff --git a/aggregators/kagi-news/src/coves_client.py b/aggregators/kagi-news/src/coves_client.py index 083818b..07b511c 100644 --- a/aggregators/kagi-news/src/coves_client.py +++ b/aggregators/kagi-news/src/coves_client.py @@ -17,7 +17,7 @@ class CovesClient: Handles: - Authentication with aggregator credentials - - Creating posts in communities (social.coves.post.create) + - Creating posts in communities (social.coves.community.post.create) - External embed formatting """ @@ -94,7 +94,7 @@ class CovesClient: self.authenticate() try: - # Prepare post data for social.coves.post.create endpoint + # Prepare post data for social.coves.community.post.create endpoint post_data = { "community": community_handle, "content": content, @@ -114,7 +114,7 @@ class CovesClient: logger.info(f"Creating post in community: {community_handle}") # Make direct HTTP request to XRPC endpoint - url = f"{self.api_url}/xrpc/social.coves.post.create" + url = f"{self.api_url}/xrpc/social.coves.community.post.create" headers = { "Authorization": f"Bearer {self.client._session.access_jwt}", "Content-Type": "application/json" diff --git a/cmd/server/main.go b/cmd/server/main.go index b4bbcea..15e3e21 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -306,7 +306,7 @@ func main() { postJetstreamURL := os.Getenv("POST_JETSTREAM_URL") if postJetstreamURL == "" { // Listen to post record creation events - postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.post.record" + postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.post" } postEventConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService) @@ -319,7 +319,7 @@ func main() { }() log.Printf("Started Jetstream post consumer: %s", postJetstreamURL) - log.Println(" - Indexing: social.coves.post.record CREATE operations") + log.Println(" - Indexing: social.coves.community.post CREATE operations") log.Println(" - UPDATE/DELETE indexing deferred until those features are implemented") // Start Jetstream consumer for aggregators diff --git a/docs/COMMUNITY_FEEDS.md b/docs/COMMUNITY_FEEDS.md index a7598d0..145de6f 100644 --- a/docs/COMMUNITY_FEEDS.md +++ b/docs/COMMUNITY_FEEDS.md @@ -182,7 +182,7 @@ type FeedViewPost struct { ```go type PostView struct { - URI string // at://did:plc:abc/social.coves.post.record/123 + URI string // at://did:plc:abc/social.coves.community.post.record/123 CID string // Content ID RKey string // Record key (TID) Author *AuthorView // Author with handle, avatar, reputation @@ -247,7 +247,7 @@ GET /xrpc/social.coves.feed.getCommunity "feed": [ { "post": { - "uri": "at://did:plc:gaming123/social.coves.post.record/abc", + "uri": "at://did:plc:gaming123/social.coves.community.post.record/abc", "cid": "bafyrei...", "author": { "did": "did:plc:alice", @@ -473,7 +473,7 @@ Allow users to create custom algorithms: GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes → Returns: [uri1, uri2, uri3, ...] -GET /xrpc/social.coves.post.get?uris=[...] +GET /xrpc/social.coves.community.post.get?uris=[...] → Returns: [full posts] ``` @@ -556,7 +556,7 @@ Show post's position in thread: ## Lexicon Updates -### Updated: `social.coves.post.get` +### Updated: `social.coves.community.post.get` **Changes:** 1. ✅ Batch URIs: `uri` → `uris[]` (max 25) @@ -638,7 +638,7 @@ GET /xrpc/social.coves.feed.getCommunity?community=gaming&sort=hot // Custom feed (power users) GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes → Returns URIs -GET /xrpc/social.coves.post.get?uris=[...] +GET /xrpc/social.coves.community.post.get?uris=[...] → Hydrates posts ``` diff --git a/docs/FEED_SYSTEM_IMPLEMENTATION.md b/docs/FEED_SYSTEM_IMPLEMENTATION.md index 83d591b..3e8fdc2 100644 --- a/docs/FEED_SYSTEM_IMPLEMENTATION.md +++ b/docs/FEED_SYSTEM_IMPLEMENTATION.md @@ -222,7 +222,7 @@ curl -X GET \ "feed": [ { "post": { - "uri": "at://did:plc:community-gaming/social.coves.post.record/3k...", + "uri": "at://did:plc:community-gaming/social.coves.community.post.record/3k...", "cid": "bafyrei...", "author": { "did": "did:plc:alice", diff --git a/docs/PRD_GOVERNANCE.md b/docs/PRD_GOVERNANCE.md index 5fc2893..83f38db 100644 --- a/docs/PRD_GOVERNANCE.md +++ b/docs/PRD_GOVERNANCE.md @@ -291,7 +291,7 @@ Content rules are stored in `social.coves.community.profile` under the `contentR - [ ] Go structs: `ContentRules` type in community models - [ ] Repository: Parse and store `contentRules` from community profiles - [ ] Service: `ValidatePostAgainstRules(post, community)` function -- [ ] Handler: Integrate validation into `social.coves.post.create` +- [ ] Handler: Integrate validation into `social.coves.community.post.create` - [ ] AppView indexing: Index post characteristics (embed_type, text_length, etc.) - [ ] Tests: Comprehensive rule validation tests - [ ] Documentation: Content rules guide for community creators diff --git a/docs/PRD_POSTS.md b/docs/PRD_POSTS.md index 4f67e4c..8f8f8b8 100644 --- a/docs/PRD_POSTS.md +++ b/docs/PRD_POSTS.md @@ -45,7 +45,7 @@ Post appears in feeds **Repository Structure:** ``` -Repository: at://did:plc:community789/social.coves.post.record/3k2a4b5c6d7e +Repository: at://did:plc:community789/social.coves.community.post.record/3k2a4b5c6d7e Owner: did:plc:community789 (community owns the post) Author: did:plc:user123 (tracked in record metadata) Hosted By: did:web:coves.social (instance manages community credentials) @@ -77,7 +77,7 @@ Posts are validated against community-specific content rules at creation time. C **Implementation checklist:** - [x] Lexicon: `contentRules` in `social.coves.community.profile` ✅ -- [x] Lexicon: `postType` removed from `social.coves.post.create` ✅ +- [x] Lexicon: `postType` removed from `social.coves.community.post.create` ✅ - [ ] Validation: `ValidatePostAgainstRules()` service function - [ ] Handler: Integrate validation in post creation endpoint - [ ] AppView: Index derived characteristics (embed_type, text_length, etc.) @@ -90,11 +90,11 @@ Posts are validated against community-specific content rules at creation time. C **Priority:** CRITICAL - Posts are the foundation of the platform #### Create Post -- [x] Lexicon: `social.coves.post.record` ✅ -- [x] Lexicon: `social.coves.post.create` ✅ +- [x] Lexicon: `social.coves.community.post.record` ✅ +- [x] Lexicon: `social.coves.community.post.create` ✅ - [x] Removed `postType` enum in favor of content rules ✅ (2025-10-18) - [x] Removed `postType` from record and get lexicons ✅ (2025-10-18) -- [x] **Handler:** `POST /xrpc/social.coves.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md) +- [x] **Handler:** `POST /xrpc/social.coves.community.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md) - ✅ Accept: community (DID/handle), title (optional), content, facets, embed, contentLabels - ✅ Validate: User is authenticated, community exists, content within limits - ✅ Write: Create record in **community's PDS repository** @@ -124,8 +124,8 @@ Posts are validated against community-specific content rules at creation time. C - [x] **E2E Test:** Create text post → Write to **community's PDS** → Index via Jetstream → Verify in AppView ✅ #### Get Post -- [x] Lexicon: `social.coves.post.get` ✅ -- [ ] **Handler:** `GET /xrpc/social.coves.post.get?uri=at://...` +- [x] Lexicon: `social.coves.community.post.get` ✅ +- [ ] **Handler:** `GET /xrpc/social.coves.community.post.get?uri=at://...` - Accept: AT-URI of post - Return: Full post view with author, community, stats, viewer state - [ ] **Service Layer:** `PostService.Get(uri, viewerDID)` @@ -139,8 +139,8 @@ Posts are validated against community-specific content rules at creation time. C - [ ] **E2E Test:** Get post by URI → Verify all fields populated #### Update Post -- [x] Lexicon: `social.coves.post.update` ✅ -- [ ] **Handler:** `POST /xrpc/social.coves.post.update` +- [x] Lexicon: `social.coves.community.post.update` ✅ +- [ ] **Handler:** `POST /xrpc/social.coves.community.post.update` - Accept: uri, title, content, facets, embed, contentLabels, editNote - Validate: User is post author, within 24-hour edit window - Write: Update record in **community's PDS** @@ -157,8 +157,8 @@ Posts are validated against community-specific content rules at creation time. C - [ ] **E2E Test:** Update post → Verify edit reflected in AppView #### Delete Post -- [x] Lexicon: `social.coves.post.delete` ✅ -- [ ] **Handler:** `POST /xrpc/social.coves.post.delete` +- [x] Lexicon: `social.coves.community.post.delete` ✅ +- [ ] **Handler:** `POST /xrpc/social.coves.community.post.delete` - Accept: uri - Validate: User is post author OR community moderator - Write: Delete record from **community's PDS** @@ -251,7 +251,7 @@ Posts are validated against community-specific content rules at creation time. C #### Post Event Handling - [x] **Consumer:** `PostConsumer.HandlePostEvent()` ✅ (2025-10-19) - - ✅ Listen for `social.coves.post.record` CREATE from **community repositories** + - ✅ Listen for `social.coves.community.post.record` CREATE from **community repositories** - ✅ Parse post record, extract author DID and community DID (from AT-URI owner) - ⚠️ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering) - ✅ Insert in AppView PostgreSQL (CREATE only - UPDATE/DELETE deferred) @@ -447,7 +447,7 @@ CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE dele - [ ] **Tag Storage:** Tags live in **user's repository** (users own their tags) #### Crossposting -- [x] Lexicon: `social.coves.post.crosspost` ✅ +- [x] Lexicon: `social.coves.community.post.crosspost` ✅ - [ ] **Crosspost Tracking:** Share post to multiple communities - [ ] **Implementation:** Create new post record in each community's repository - [ ] **Crosspost Chain:** Track all crosspost relationships @@ -461,7 +461,7 @@ CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE dele - [ ] **AppView Query:** Endpoint to fetch user's saved posts ### Post Search -- [x] Lexicon: `social.coves.post.search` ✅ +- [x] Lexicon: `social.coves.community.post.search` ✅ - [ ] **Search Parameters:** - Query string (q) - Filter by community @@ -583,7 +583,7 @@ CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE dele - **Reuses Token Refresh:** Can leverage existing community credential management **Implementation Details:** -- Post AT-URI: `at://community_did/social.coves.post.record/tid` +- Post AT-URI: `at://community_did/social.coves.community.post.record/tid` - Write operations use community's PDS credentials (encrypted, stored in AppView) - Author tracked in post record's `author` field (DID) - Moderators can delete any post in their community @@ -756,7 +756,7 @@ CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE dele ## Lexicon Summary -### `social.coves.post.record` +### `social.coves.community.post.record` **Status:** ✅ Defined, implementation TODO **Last Updated:** 2025-10-18 (removed `postType` enum) @@ -781,7 +781,7 @@ CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE dele - Post "type" is derived from structure (has embed? what embed type? has title? text length?) - Community's `contentRules` validate post structure at creation time -### `social.coves.post.create` (Procedure) +### `social.coves.community.post.create` (Procedure) **Status:** ✅ Defined, implementation TODO **Last Updated:** 2025-10-18 (removed `postType` parameter) diff --git a/docs/aggregators/PRD_AGGREGATORS.md b/docs/aggregators/PRD_AGGREGATORS.md index 5afa22d..66e80a9 100644 --- a/docs/aggregators/PRD_AGGREGATORS.md +++ b/docs/aggregators/PRD_AGGREGATORS.md @@ -23,7 +23,7 @@ Aggregators follow established atProto patterns for autonomous services (Feed Ge 1. **Aggregators are Actors, Not a Separate System** - Each aggregator has its own DID - Authenticate as themselves via JWT - - Use existing `social.coves.post.create` endpoint + - Use existing `social.coves.community.post.create` endpoint - Post record's `author` field = aggregator DID (server-populated) - No separate posting API needed @@ -89,7 +89,7 @@ Grants an aggregator permission to post with specific configuration. Aggregator Service (External) │ │ 1. Authenticates as aggregator DID (JWT) - │ 2. Calls social.coves.post.create + │ 2. Calls social.coves.community.post.create ▼ Coves AppView Handler │ @@ -120,7 +120,7 @@ Community Feed ### For Aggregators -- **`social.coves.post.create`** - Modified to handle aggregator auth +- **`social.coves.community.post.create`** - Modified to handle aggregator auth - **`social.coves.aggregator.getAuthorizations`** - Query authorized communities ### For Discovery @@ -312,7 +312,7 @@ Potential first aggregator: RSS news bot for select communities. --- -### 2025-10-19: Reuse `social.coves.post.create` Endpoint +### 2025-10-19: Reuse `social.coves.community.post.create` Endpoint **Decision:** Aggregators use existing post creation endpoint. **Rationale:** diff --git a/docs/aggregators/PRD_KAGI_NEWS_RSS.md b/docs/aggregators/PRD_KAGI_NEWS_RSS.md index 0336887..ab4d2f6 100644 --- a/docs/aggregators/PRD_KAGI_NEWS_RSS.md +++ b/docs/aggregators/PRD_KAGI_NEWS_RSS.md @@ -172,14 +172,14 @@ Analysis of live Kagi News feeds confirms the following structure: │ 3. Deduplication: Tracks posted items via JSON state file │ │ 4. Feed Mapper: Maps feed URLs to community handles │ │ 5. Post Formatter: Converts to Coves post format │ -│ 6. Post Publisher: Calls social.coves.post.create via XRPC │ +│ 6. Post Publisher: Calls social.coves.community.post.create via XRPC │ │ 7. Blob Uploader: Handles image upload to ATProto │ └─────────────────────────────────────────────────────────────┘ │ │ Authenticated XRPC calls ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Coves AppView (social.coves.post.create) │ +│ Coves AppView (social.coves.community.post.create) │ │ - Validates aggregator authorization │ │ - Creates post with author = did:plc:[aggregator-did] │ │ - Indexes to community feeds │ @@ -271,7 +271,7 @@ log_level: "info" ```json { - "$type": "social.coves.post.record", + "$type": "social.coves.community.post.record", "author": "did:plc:[aggregator-did]", "community": "world-news.coves.social", "title": "{Kagi story title}", diff --git a/internal/api/handlers/post/create.go b/internal/api/handlers/post/create.go index b93d8d0..06f77fa 100644 --- a/internal/api/handlers/post/create.go +++ b/internal/api/handlers/post/create.go @@ -21,7 +21,7 @@ func NewCreateHandler(service posts.Service) *CreateHandler { } } -// HandleCreate handles POST /xrpc/social.coves.post.create +// HandleCreate handles POST /xrpc/social.coves.community.post.create // Creates a new post in a community's repository func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { // 1. Check HTTP method diff --git a/internal/api/routes/post.go b/internal/api/routes/post.go index 6c97dac..d2be121 100644 --- a/internal/api/routes/post.go +++ b/internal/api/routes/post.go @@ -9,18 +9,18 @@ import ( ) // RegisterPostRoutes registers post-related XRPC endpoints on the router -// Implements social.coves.post.* lexicon endpoints +// Implements social.coves.community.post.* lexicon endpoints func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) { // Initialize handlers createHandler := post.NewCreateHandler(service) // Procedure endpoints (POST) - require authentication - // social.coves.post.create - create a new post in a community - r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.create", createHandler.HandleCreate) + // social.coves.community.post.create - create a new post in a community + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate) // Future endpoints (Beta): - // r.Get("/xrpc/social.coves.post.get", getHandler.HandleGet) - // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.update", updateHandler.HandleUpdate) - // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.delete", deleteHandler.HandleDelete) - // r.Get("/xrpc/social.coves.post.list", listHandler.HandleList) + // r.Get("/xrpc/social.coves.community.post.get", getHandler.HandleGet) + // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.update", updateHandler.HandleUpdate) + // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.delete", deleteHandler.HandleDelete) + // r.Get("/xrpc/social.coves.community.post.list", listHandler.HandleList) } diff --git a/internal/atproto/lexicon/social/coves/feed/comment.json b/internal/atproto/lexicon/social/coves/feed/comment.json new file mode 100644 index 0000000..a567181 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/feed/comment.json @@ -0,0 +1,80 @@ +{ + "lexicon": 1, + "id": "social.coves.feed.comment", + "defs": { + "main": { + "type": "record", + "description": "A comment on a post or another comment. Comments live in user repositories and support nested threading.", + "key": "tid", + "record": { + "type": "object", + "required": ["reply", "content", "createdAt"], + "properties": { + "reply": { + "type": "ref", + "ref": "#replyRef", + "description": "Reference to the post and parent being replied to" + }, + "content": { + "type": "string", + "maxGraphemes": 3000, + "maxLength": 30000, + "description": "Comment text content" + }, + "facets": { + "type": "array", + "description": "Annotations for rich text (mentions, links, etc.)", + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + } + }, + "embed": { + "type": "union", + "description": "Embedded media or quoted posts", + "refs": [ + "social.coves.embed.images", + "social.coves.embed.post" + ] + }, + "langs": { + "type": "array", + "description": "Languages used in the comment content (ISO 639-1)", + "maxLength": 3, + "items": { + "type": "string", + "format": "language" + } + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of comment creation" + } + } + } + }, + "replyRef": { + "type": "object", + "description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).", + "required": ["root", "parent"], + "properties": { + "root": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the original post that started the thread" + }, + "parent": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the immediate parent (post or comment) being replied to" + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/interaction/comment.json b/internal/atproto/lexicon/social/coves/interaction/comment.json deleted file mode 100644 index 12482ef..0000000 --- a/internal/atproto/lexicon/social/coves/interaction/comment.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.interaction.comment", - "defs": { - "main": { - "type": "record", - "description": "A comment on a post or another comment", - "key": "tid", - "record": { - "type": "object", - "required": ["subject", "content", "createdAt"], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of post or comment being replied to" - }, - "content": { - "type": "union", - "refs": ["#textContent", "#imageContent", "#stickerContent"] - }, - "location": { - "type": "ref", - "ref": "social.coves.actor.profile#geoLocation" - }, - "translatedFrom": { - "type": "string", - "maxLength": 10, - "description": "Language code if auto-translated (ISO 639-1)" - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - }, - "textContent": { - "type": "object", - "required": ["text"], - "properties": { - "text": { - "type": "string", - "maxLength": 10000, - "description": "Comment text" - }, - "facets": { - "type": "array", - "description": "Rich text annotations", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - } - } - }, - "imageContent": { - "type": "object", - "required": ["image"], - "properties": { - "image": { - "type": "ref", - "ref": "social.coves.embed.images#image" - }, - "caption": { - "type": "string", - "maxLength": 1000 - } - } - }, - "stickerContent": { - "type": "object", - "required": ["stickerId"], - "properties": { - "stickerId": { - "type": "string", - "description": "Reference to a sticker in a sticker pack" - }, - "stickerPackId": { - "type": "string", - "description": "Reference to the sticker pack" - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/interaction/createComment.json b/internal/atproto/lexicon/social/coves/interaction/createComment.json deleted file mode 100644 index 42d9187..0000000 --- a/internal/atproto/lexicon/social/coves/interaction/createComment.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.interaction.createComment", - "defs": { - "main": { - "type": "procedure", - "description": "Create a comment on a post or another comment", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["parent", "text"], - "properties": { - "parent": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the post or comment being replied to" - }, - "text": { - "type": "string", - "maxGraphemes": 3000, - "maxLength": 30000, - "description": "Comment text" - }, - "textFacets": { - "type": "array", - "description": "Rich text annotations", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri", "cid"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the created comment" - }, - "cid": { - "type": "string", - "format": "cid", - "description": "CID of the created comment" - } - } - } - }, - "errors": [ - { - "name": "ParentNotFound", - "description": "Parent post or comment not found" - }, - { - "name": "NotAuthorized", - "description": "User is not authorized to comment" - }, - { - "name": "ThreadLocked", - "description": "Comment thread is locked" - }, - { - "name": "Banned", - "description": "User is banned from this community" - } - ] - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/interaction/deleteComment.json b/internal/atproto/lexicon/social/coves/interaction/deleteComment.json deleted file mode 100644 index 34fefd7..0000000 --- a/internal/atproto/lexicon/social/coves/interaction/deleteComment.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.interaction.deleteComment", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a comment", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the comment to delete" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": {} - } - }, - "errors": [ - { - "name": "CommentNotFound", - "description": "Comment not found" - }, - { - "name": "NotAuthorized", - "description": "User is not authorized to delete this comment" - } - ] - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/create.json b/internal/atproto/lexicon/social/coves/post/create.json deleted file mode 100644 index cdd236b..0000000 --- a/internal/atproto/lexicon/social/coves/post/create.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create a new post in a community", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["community"], - "properties": { - "community": { - "type": "string", - "format": "at-identifier", - "description": "DID or handle of the community to post in" - }, - "title": { - "type": "string", - "maxGraphemes": 300, - "maxLength": 3000, - "description": "Post title (optional for microblog, image, and video posts)" - }, - "content": { - "type": "string", - "maxLength": 50000, - "description": "Post content - main text for text posts, description for media, etc." - }, - "facets": { - "type": "array", - "description": "Rich text annotations for content", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - }, - "embed": { - "type": "union", - "description": "Embedded content - images, videos, external links, or quoted posts", - "refs": [ - "social.coves.embed.images", - "social.coves.embed.video", - "social.coves.embed.external", - "social.coves.embed.post" - ] - }, - "originalAuthor": { - "type": "ref", - "ref": "social.coves.post.record#originalAuthor", - "description": "For microblog posts - information about the original author" - }, - "federatedFrom": { - "type": "ref", - "ref": "social.coves.federation.post", - "description": "Reference to original federated post (for microblog posts)" - }, - "contentLabels": { - "type": "array", - "description": "Self-applied content labels", - "items": { - "type": "string", - "knownValues": ["nsfw", "spoiler", "violence"], - "maxLength": 32 - } - }, - "location": { - "type": "ref", - "ref": "social.coves.actor.profile#geoLocation", - "description": "Geographic location where post was created" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri", "cid"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the created post" - }, - "cid": { - "type": "string", - "format": "cid", - "description": "CID of the created post" - } - } - } - }, - "errors": [ - { - "name": "CommunityNotFound", - "description": "Community not found" - }, - { - "name": "NotAuthorized", - "description": "User is not authorized to post in this community" - }, - { - "name": "Banned", - "description": "User is banned from this community" - }, - { - "name": "InvalidContent", - "description": "Post content violates community rules" - }, - { - "name": "ContentRuleViolation", - "description": "Post violates community content rules (e.g., embeds not allowed, text too short)" - } - ] - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/crosspost.json b/internal/atproto/lexicon/social/coves/post/crosspost.json deleted file mode 100644 index 5f338ab..0000000 --- a/internal/atproto/lexicon/social/coves/post/crosspost.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.crosspost", - "defs": { - "main": { - "type": "record", - "description": "A record tracking crosspost relationships between posts", - "key": "tid", - "record": { - "type": "object", - "required": ["originalPost", "crosspostOf", "createdAt"], - "properties": { - "originalPost": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the original post in the crosspost chain" - }, - "crosspostOf": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the immediate parent this is a crosspost of" - }, - "allCrossposts": { - "type": "array", - "description": "Array of AT-URIs of all posts in the crosspost chain", - "items": { - "type": "string", - "format": "at-uri" - } - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/delete.json b/internal/atproto/lexicon/social/coves/post/delete.json deleted file mode 100644 index 8afd6db..0000000 --- a/internal/atproto/lexicon/social/coves/post/delete.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a post", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the post to delete" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": {} - } - }, - "errors": [ - { - "name": "PostNotFound", - "description": "Post not found" - }, - { - "name": "NotAuthorized", - "description": "User is not authorized to delete this post" - } - ] - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/get.json b/internal/atproto/lexicon/social/coves/post/get.json deleted file mode 100644 index e83d724..0000000 --- a/internal/atproto/lexicon/social/coves/post/get.json +++ /dev/null @@ -1,294 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.get", - "defs": { - "main": { - "type": "query", - "description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs.", - "parameters": { - "type": "params", - "required": ["uris"], - "properties": { - "uris": { - "type": "array", - "description": "List of post AT-URIs to fetch (max 25)", - "items": { - "type": "string", - "format": "at-uri" - }, - "maxLength": 25, - "minLength": 1 - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["posts"], - "properties": { - "posts": { - "type": "array", - "description": "Array of post views. May include notFound/blocked entries for missing posts.", - "items": { - "type": "union", - "refs": ["#postView", "#notFoundPost", "#blockedPost"] - } - } - } - } - }, - "errors": [ - {"name": "InvalidRequest", "description": "Invalid URI format or empty array"} - ] - }, - "postView": { - "type": "object", - "required": ["uri", "cid", "author", "record", "community", "createdAt", "indexedAt"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "author": { - "type": "ref", - "ref": "#authorView" - }, - "record": { - "type": "unknown", - "description": "The actual post record (text, image, video, etc.)" - }, - "community": { - "type": "ref", - "ref": "#communityRef" - }, - "title": { - "type": "string" - }, - "text": { - "type": "string" - }, - "textFacets": { - "type": "array", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - }, - "embed": { - "type": "union", - "description": "Embedded content (images, video, link preview, or quoted post)", - "refs": [ - "social.coves.embed.images#view", - "social.coves.embed.video#view", - "social.coves.embed.external#view", - "social.coves.embed.record#view", - "social.coves.embed.recordWithMedia#view" - ] - }, - "language": { - "type": "string", - "format": "language" - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "editedAt": { - "type": "string", - "format": "datetime" - }, - "indexedAt": { - "type": "string", - "format": "datetime", - "description": "When this post was indexed by the AppView" - }, - "stats": { - "type": "ref", - "ref": "#postStats" - }, - "viewer": { - "type": "ref", - "ref": "#viewerState" - } - } - }, - "authorView": { - "type": "object", - "required": ["did", "handle"], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string", - "format": "handle" - }, - "displayName": { - "type": "string" - }, - "avatar": { - "type": "string", - "format": "uri" - }, - "reputation": { - "type": "integer", - "description": "Author's reputation in the community" - } - } - }, - "communityRef": { - "type": "object", - "required": ["did", "name"], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "name": { - "type": "string" - }, - "avatar": { - "type": "string", - "format": "uri" - } - } - }, - "notFoundPost": { - "type": "object", - "description": "Post was not found (deleted, never indexed, or invalid URI)", - "required": ["uri", "notFound"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "notFound": { - "type": "boolean", - "const": true - } - } - }, - "blockedPost": { - "type": "object", - "description": "Post is blocked due to viewer blocking author/community, or community moderation", - "required": ["uri", "blocked"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "blocked": { - "type": "boolean", - "const": true - }, - "blockedBy": { - "type": "string", - "enum": ["author", "community", "moderator"], - "description": "What caused the block: viewer blocked author, viewer blocked community, or post was removed by moderators" - }, - "author": { - "type": "ref", - "ref": "#blockedAuthor" - }, - "community": { - "type": "ref", - "ref": "#blockedCommunity" - } - } - }, - "blockedAuthor": { - "type": "object", - "description": "Minimal author info for blocked posts", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did" - } - } - }, - "blockedCommunity": { - "type": "object", - "description": "Minimal community info for blocked posts", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "name": { - "type": "string" - } - } - }, - "postStats": { - "type": "object", - "required": ["upvotes", "downvotes", "score", "commentCount"], - "properties": { - "upvotes": { - "type": "integer", - "minimum": 0 - }, - "downvotes": { - "type": "integer", - "minimum": 0 - }, - "score": { - "type": "integer", - "description": "Calculated score (upvotes - downvotes)" - }, - "commentCount": { - "type": "integer", - "minimum": 0 - }, - "shareCount": { - "type": "integer", - "minimum": 0 - }, - "tagCounts": { - "type": "object", - "description": "Aggregate counts of tags applied by community members", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - } - } - }, - "viewerState": { - "type": "object", - "properties": { - "vote": { - "type": "string", - "enum": ["up", "down"], - "description": "Viewer's vote on this post" - }, - "voteUri": { - "type": "string", - "format": "at-uri" - }, - "saved": { - "type": "boolean" - }, - "savedUri": { - "type": "string", - "format": "at-uri" - }, - "tags": { - "type": "array", - "description": "Tags applied by the viewer to this post", - "items": { - "type": "string", - "maxLength": 32 - } - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/getCrosspostChain.json b/internal/atproto/lexicon/social/coves/post/getCrosspostChain.json deleted file mode 100644 index 03552c9..0000000 --- a/internal/atproto/lexicon/social/coves/post/getCrosspostChain.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.getCrosspostChain", - "defs": { - "main": { - "type": "procedure", - "description": "Get all crossposts in a crosspost chain for a given post", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of any post in the crosspost chain" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["crossposts"], - "properties": { - "crossposts": { - "type": "array", - "description": "All posts in the crosspost chain", - "items": { - "type": "ref", - "ref": "#crosspostView" - } - } - } - } - } - }, - "crosspostView": { - "type": "object", - "required": ["uri", "community", "author", "createdAt"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the post" - }, - "community": { - "type": "object", - "required": ["uri", "name"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the community" - }, - "name": { - "type": "string", - "description": "Display name of the community" - }, - "handle": { - "type": "string", - "description": "Handle of the community" - } - } - }, - "author": { - "type": "object", - "required": ["did", "handle"], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "avatar": { - "type": "string", - "format": "uri" - } - } - }, - "isOriginal": { - "type": "boolean", - "description": "Whether this is the original post in the chain" - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/record.json b/internal/atproto/lexicon/social/coves/post/record.json deleted file mode 100644 index 3a09db0..0000000 --- a/internal/atproto/lexicon/social/coves/post/record.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.record", - "defs": { - "main": { - "type": "record", - "description": "A unified post record supporting multiple content types", - "key": "tid", - "record": { - "type": "object", - "required": ["$type", "community", "author", "createdAt"], - "properties": { - "$type": { - "type": "string", - "const": "social.coves.post.record", - "description": "The record type identifier" - }, - "community": { - "type": "string", - "format": "at-identifier", - "description": "DID or handle of the community this was posted to" - }, - "author": { - "type": "string", - "format": "did", - "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." - }, - "title": { - "type": "string", - "maxGraphemes": 300, - "maxLength": 3000, - "description": "Post title (optional for microblog, image, and video posts)" - }, - "content": { - "type": "string", - "maxLength": 50000, - "description": "Post content - main text for text posts, description for media, etc." - }, - "facets": { - "type": "array", - "description": "Rich text annotations for content", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - }, - "embed": { - "type": "union", - "description": "Embedded content - images, videos, external links, or quoted posts", - "refs": [ - "social.coves.embed.images", - "social.coves.embed.video", - "social.coves.embed.external", - "social.coves.embed.post" - ] - }, - "originalAuthor": { - "type": "ref", - "ref": "#originalAuthor", - "description": "For microblog posts - information about the original author from federated platform" - }, - "contentLabels": { - "type": "array", - "description": "Self-applied content labels", - "items": { - "type": "string", - "knownValues": ["nsfw", "spoiler", "violence"], - "maxLength": 32 - } - }, - "federatedFrom": { - "type": "ref", - "ref": "social.coves.federation.post", - "description": "Reference to original federated post (if applicable)" - }, - "location": { - "type": "ref", - "ref": "social.coves.actor.profile#geoLocation", - "description": "Geographic location where post was created" - }, - "crosspostOf": { - "type": "string", - "format": "at-uri", - "description": "If this is a crosspost, AT-URI of the post this is a crosspost of" - }, - "crosspostChain": { - "type": "array", - "description": "Array of AT-URIs of all posts in the crosspost chain (including this one)", - "items": { - "type": "string", - "format": "at-uri" - } - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - }, - "originalAuthor": { - "type": "object", - "description": "Information about the original author from a federated platform", - "required": ["handle"], - "properties": { - "did": { - "type": "string", - "format": "did", - "description": "Original author's DID (if available)" - }, - "handle": { - "type": "string", - "maxLength": 253, - "description": "Original author's handle" - }, - "displayName": { - "type": "string", - "maxLength": 640, - "description": "Original author's display name" - }, - "avatar": { - "type": "string", - "format": "uri", - "description": "URL to original author's avatar" - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/search.json b/internal/atproto/lexicon/social/coves/post/search.json deleted file mode 100644 index 1a83b75..0000000 --- a/internal/atproto/lexicon/social/coves/post/search.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.search", - "defs": { - "main": { - "type": "query", - "description": "Search for posts", - "parameters": { - "type": "params", - "required": ["q"], - "properties": { - "q": { - "type": "string", - "description": "Search query" - }, - "community": { - "type": "string", - "format": "at-identifier", - "description": "Filter by specific community" - }, - "author": { - "type": "string", - "format": "at-identifier", - "description": "Filter by author" - }, - "type": { - "type": "string", - "enum": ["text", "image", "video", "article", "microblog"], - "description": "Filter by post type" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Filter by tags" - }, - "sort": { - "type": "string", - "enum": ["relevance", "new", "top"], - "default": "relevance" - }, - "timeframe": { - "type": "string", - "enum": ["hour", "day", "week", "month", "year", "all"], - "default": "all" - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["posts"], - "properties": { - "posts": { - "type": "array", - "items": { - "type": "ref", - "ref": "social.coves.post.getFeed#feedPost" - } - }, - "cursor": { - "type": "string" - } - } - } - } - } - } -} \ No newline at end of file diff --git a/internal/atproto/lexicon/social/coves/post/update.json b/internal/atproto/lexicon/social/coves/post/update.json deleted file mode 100644 index 3a9e12a..0000000 --- a/internal/atproto/lexicon/social/coves/post/update.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "lexicon": 1, - "id": "social.coves.post.update", - "defs": { - "main": { - "type": "procedure", - "description": "Update an existing post", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the post to update" - }, - "title": { - "type": "string", - "maxGraphemes": 300, - "maxLength": 3000, - "description": "Updated title" - }, - "content": { - "type": "string", - "maxLength": 50000, - "description": "Updated content - main text for text posts, description for media, etc." - }, - "facets": { - "type": "array", - "description": "Updated rich text annotations for content", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - }, - "embed": { - "type": "union", - "description": "Updated embedded content (note: changing embed type may be restricted)", - "refs": [ - "social.coves.embed.images", - "social.coves.embed.video", - "social.coves.embed.external", - "social.coves.embed.post" - ] - }, - "contentLabels": { - "type": "array", - "description": "Updated content labels", - "items": { - "type": "string", - "knownValues": ["nsfw", "spoiler", "violence"], - "maxLength": 32 - } - }, - "editNote": { - "type": "string", - "maxLength": 300, - "description": "Optional note explaining the edit" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri", "cid"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the updated post" - }, - "cid": { - "type": "string", - "format": "cid", - "description": "New CID of the updated post" - } - } - } - }, - "errors": [ - { - "name": "PostNotFound", - "description": "Post not found" - }, - { - "name": "NotAuthorized", - "description": "User is not authorized to edit this post" - }, - { - "name": "EditWindowExpired", - "description": "Edit window has expired (posts can only be edited within 24 hours)" - }, - { - "name": "InvalidUpdate", - "description": "Invalid update operation (e.g., changing post type)" - } - ] - } - } -} \ No newline at end of file