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",