diff --git a/cmd/validate-lexicon/main.go b/cmd/validate-lexicon/main.go index f8767de..66b9328 100644 --- a/cmd/validate-lexicon/main.go +++ b/cmd/validate-lexicon/main.go @@ -423,14 +423,22 @@ func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error { "social.coves.richtext.facet#strikethrough", "social.coves.richtext.facet#spoiler", + // Embed views + "social.coves.embed.external#view", + "social.coves.embed.external#viewExternal", + "social.coves.embed.post#view", + + // Moderation views + "social.coves.moderation.defs#banView", + // Post types and views - "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", + "social.coves.community.post.defs#postView", + "social.coves.community.post.defs#authorView", + "social.coves.community.post.defs#communityRef", + "social.coves.community.post.defs#postStats", + "social.coves.community.post.defs#viewerState", + "social.coves.community.post.defs#notFoundPost", + "social.coves.community.post.defs#blockedPost", // Post record types (removed - no longer exists in new structure) diff --git a/cmd/validate-live/main.go b/cmd/validate-live/main.go new file mode 100644 index 0000000..dd54866 --- /dev/null +++ b/cmd/validate-live/main.go @@ -0,0 +1,193 @@ +// Command validate-live fetches all social.coves.* records from a live PDS via +// public XRPC endpoints and validates them against the local lexicon schemas. +// Use it before publishing lexicons or tightening schema constraints to confirm +// no live record would be invalidated. +// +// Usage: go run ./cmd/validate-live [-pds https://pds.example.com] [-schemas internal/atproto/lexicon] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" +) + +type repoEntry struct { + Did string `json:"did"` +} + +type listReposResponse struct { + Cursor string `json:"cursor"` + Repos []repoEntry `json:"repos"` +} + +type describeRepoResponse struct { + Collections []string `json:"collections"` +} + +type recordEntry struct { + URI string `json:"uri"` + Value json.RawMessage `json:"value"` +} + +type listRecordsResponse struct { + Cursor string `json:"cursor"` + Records []recordEntry `json:"records"` +} + +func main() { + var ( + pdsURL = flag.String("pds", "https://pds.bretton.dev", "Base URL of the PDS to scan") + schemaPath = flag.String("schemas", "internal/atproto/lexicon", "Path to lexicon schemas directory") + ) + flag.Parse() + + catalog := lexicon.NewBaseCatalog() + if err := catalog.LoadDirectory(*schemaPath); err != nil { + log.Fatalf("Failed to load lexicon schemas: %v", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + + repos, err := listAllRepos(client, *pdsURL) + if err != nil { + log.Fatalf("Failed to list repos: %v", err) + } + fmt.Printf("Found %d repos on %s\n", len(repos), *pdsURL) + + totalByCollection := map[string]int{} + failures := 0 + skippedRepos := 0 + skippedCollections := 0 + + for _, repo := range repos { + collections, err := describeRepo(client, *pdsURL, repo.Did) + if err != nil { + log.Printf("WARN: describeRepo %s: %v (repo skipped — sweep is incomplete)", repo.Did, err) + skippedRepos++ + continue + } + for _, collection := range collections { + if !strings.HasPrefix(collection, "social.coves.") { + continue + } + records, err := listAllRecords(client, *pdsURL, repo.Did, collection) + if err != nil { + log.Printf("WARN: listRecords %s %s: %v (collection skipped — sweep is incomplete)", repo.Did, collection, err) + skippedCollections++ + continue + } + for _, record := range records { + totalByCollection[collection]++ + parsed, err := atdata.UnmarshalJSON(record.Value) + if err != nil { + failures++ + fmt.Printf("UNPARSEABLE %s: %v\n", record.URI, err) + continue + } + if err := lexicon.ValidateRecord(&catalog, parsed, collection, lexicon.AllowLenientDatetime); err != nil { + failures++ + fmt.Printf("INVALID %s: %v\n", record.URI, err) + } + } + } + } + + fmt.Println("\nRecords validated per collection:") + collections := make([]string, 0, len(totalByCollection)) + for collection := range totalByCollection { + collections = append(collections, collection) + } + sort.Strings(collections) + for _, collection := range collections { + fmt.Printf(" %-50s %d\n", collection, totalByCollection[collection]) + } + if failures > 0 { + fmt.Printf("\nFAIL: %d live records fail validation against current schemas\n", failures) + os.Exit(1) + } + if skippedRepos > 0 || skippedCollections > 0 { + fmt.Printf("\nINCOMPLETE SWEEP: %d repos and %d collections could not be fetched; no verdict on unvalidated records\n", + skippedRepos, skippedCollections) + os.Exit(2) + } + fmt.Println("\nOK: all live records validate against current schemas") +} + +func getJSON(client *http.Client, rawURL string, out interface{}) error { + resp, err := client.Get(rawURL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func listAllRepos(client *http.Client, pdsURL string) ([]repoEntry, error) { + var repos []repoEntry + cursor := "" + for { + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.listRepos?limit=500", pdsURL) + if cursor != "" { + u += "&cursor=" + url.QueryEscape(cursor) + } + var page listReposResponse + if err := getJSON(client, u, &page); err != nil { + return nil, err + } + repos = append(repos, page.Repos...) + if page.Cursor == "" || len(page.Repos) == 0 { + return repos, nil + } + if page.Cursor == cursor { + return nil, fmt.Errorf("listRepos: server returned non-advancing cursor %q", cursor) + } + cursor = page.Cursor + } +} + +func describeRepo(client *http.Client, pdsURL, did string) ([]string, error) { + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.describeRepo?repo=%s", pdsURL, url.QueryEscape(did)) + var resp describeRepoResponse + if err := getJSON(client, u, &resp); err != nil { + return nil, err + } + return resp.Collections, nil +} + +func listAllRecords(client *http.Client, pdsURL, did, collection string) ([]recordEntry, error) { + var records []recordEntry + cursor := "" + for { + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100", + pdsURL, url.QueryEscape(did), url.QueryEscape(collection)) + if cursor != "" { + u += "&cursor=" + url.QueryEscape(cursor) + } + var page listRecordsResponse + if err := getJSON(client, u, &page); err != nil { + return nil, err + } + records = append(records, page.Records...) + if page.Cursor == "" || len(page.Records) == 0 { + return records, nil + } + if page.Cursor == cursor { + return nil, fmt.Errorf("listRecords %s %s: server returned non-advancing cursor %q", did, collection, cursor) + } + cursor = page.Cursor + } +} diff --git a/internal/api/handlers/user/update_profile.go b/internal/api/handlers/user/update_profile.go index 847aea3..82995e7 100644 --- a/internal/api/handlers/user/update_profile.go +++ b/internal/api/handlers/user/update_profile.go @@ -13,6 +13,7 @@ import ( "Coves/internal/core/users" "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/rivo/uniseg" ) // CovesProfileCollection is the atProto collection for Coves user profiles. @@ -25,10 +26,14 @@ const CovesProfileCollection = users.ProfileCollection type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) const ( - // MaxDisplayNameLength is the maximum allowed length for display names (per atProto lexicon) - MaxDisplayNameLength = 64 - // MaxBioLength is the maximum allowed length for bio/description (per atProto lexicon) - MaxBioLength = 256 + // MaxDisplayNameGraphemes is the maximum display name length in graphemes (per atProto lexicon) + MaxDisplayNameGraphemes = 64 + // MaxDisplayNameBytes is the maximum display name length in bytes (per atProto lexicon) + MaxDisplayNameBytes = 640 + // MaxBioGraphemes is the maximum bio/description length in graphemes (per atProto lexicon) + MaxBioGraphemes = 256 + // MaxBioBytes is the maximum bio/description length in bytes (per atProto lexicon) + MaxBioBytes = 2560 // MaxAvatarBlobSize is the maximum allowed avatar size in bytes (1MB per lexicon) MaxAvatarBlobSize = 1_000_000 // MaxBannerBlobSize is the maximum allowed banner size in bytes (2MB per lexicon) @@ -138,17 +143,19 @@ func (h *UpdateProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - // Validate displayName length - if req.DisplayName != nil && len(*req.DisplayName) > MaxDisplayNameLength { + // Validate displayName length (grapheme + byte caps per lexicon) + if req.DisplayName != nil && + (uniseg.GraphemeClusterCount(*req.DisplayName) > MaxDisplayNameGraphemes || len(*req.DisplayName) > MaxDisplayNameBytes) { writeUpdateProfileError(w, http.StatusBadRequest, "DisplayNameTooLong", - fmt.Sprintf("Display name exceeds %d character limit", MaxDisplayNameLength)) + fmt.Sprintf("Display name exceeds %d character limit", MaxDisplayNameGraphemes)) return } - // Validate bio length - if req.Bio != nil && len(*req.Bio) > MaxBioLength { + // Validate bio length (grapheme + byte caps per lexicon) + if req.Bio != nil && + (uniseg.GraphemeClusterCount(*req.Bio) > MaxBioGraphemes || len(*req.Bio) > MaxBioBytes) { writeUpdateProfileError(w, http.StatusBadRequest, "BioTooLong", - fmt.Sprintf("Bio exceeds %d character limit", MaxBioLength)) + fmt.Sprintf("Bio exceeds %d character limit", MaxBioGraphemes)) return } diff --git a/internal/api/handlers/user/update_profile_test.go b/internal/api/handlers/user/update_profile_test.go index 3ad7a5e..099429d 100644 --- a/internal/api/handlers/user/update_profile_test.go +++ b/internal/api/handlers/user/update_profile_test.go @@ -346,7 +346,7 @@ func TestUpdateProfileHandler_BannerBlobWithoutMimeType(t *testing.T) { func TestUpdateProfileHandler_DisplayNameTooLong(t *testing.T) { handler := createTestHandler() - longName := strings.Repeat("a", MaxDisplayNameLength+1) + longName := strings.Repeat("a", MaxDisplayNameGraphemes+1) reqBody := UpdateProfileRequest{ DisplayName: &longName, } @@ -370,7 +370,7 @@ func TestUpdateProfileHandler_DisplayNameTooLong(t *testing.T) { func TestUpdateProfileHandler_BioTooLong(t *testing.T) { handler := createTestHandler() - longBio := strings.Repeat("a", MaxBioLength+1) + longBio := strings.Repeat("a", MaxBioGraphemes+1) reqBody := UpdateProfileRequest{ Bio: &longBio, } diff --git a/internal/api/handlers/vote/create_vote.go b/internal/api/handlers/vote/create_vote.go index 7fa1f36..3360398 100644 --- a/internal/api/handlers/vote/create_vote.go +++ b/internal/api/handlers/vote/create_vote.go @@ -29,10 +29,11 @@ type CreateVoteInput struct { Direction string `json:"direction"` } -// CreateVoteOutput represents the response body for creating a vote +// CreateVoteOutput represents the response body for creating a vote. +// URI and CID are omitted when an existing same-direction vote was toggled off. type CreateVoteOutput struct { - URI string `json:"uri"` - CID string `json:"cid"` + URI string `json:"uri,omitempty"` + CID string `json:"cid,omitempty"` } // HandleCreateVote creates a vote on a post or comment diff --git a/internal/atproto/lexicon/social/coves/actor/block.json b/internal/atproto/lexicon/social/coves/actor/block.json index ac5070f..2071c99 100644 --- a/internal/atproto/lexicon/social/coves/actor/block.json +++ b/internal/atproto/lexicon/social/coves/actor/block.json @@ -8,7 +8,7 @@ "key": "tid", "record": { "type": "object", - "required": ["subject"], + "required": ["subject", "createdAt"], "properties": { "subject": { "type": "string", diff --git a/internal/atproto/lexicon/social/coves/actor/defs.json b/internal/atproto/lexicon/social/coves/actor/defs.json index f2997a8..29051eb 100644 --- a/internal/atproto/lexicon/social/coves/actor/defs.json +++ b/internal/atproto/lexicon/social/coves/actor/defs.json @@ -5,7 +5,9 @@ "profileView": { "type": "object", "description": "Basic profile view with essential information", - "required": ["did"], + "required": [ + "did" + ], "properties": { "did": { "type": "string", @@ -31,7 +33,9 @@ "profileViewDetailed": { "type": "object", "description": "Detailed profile view with stats and viewer state", - "required": ["did"], + "required": [ + "did" + ], "properties": { "did": { "type": "string", @@ -47,19 +51,6 @@ "maxGraphemes": 64, "maxLength": 640 }, - "bio": { - "type": "string", - "maxGraphemes": 256, - "maxLength": 2560 - }, - "bioFacets": { - "type": "array", - "description": "Rich text annotations for bio", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } - }, "avatar": { "type": "string", "format": "uri", @@ -83,6 +74,20 @@ "type": "ref", "ref": "#viewerState", "description": "Viewer's relationship to this profile" + }, + "description": { + "type": "string", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "descriptionFacets": { + "type": "array", + "description": "Rich text annotations for the description", + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + }, + "maxLength": 50 } } }, @@ -128,10 +133,10 @@ "type": "boolean", "description": "Whether the viewer is blocked by this user" }, - "blockUri": { + "blocking": { "type": "string", "format": "at-uri", - "description": "AT-URI of the block record if viewer blocked this user" + "description": "AT-URI of the block record if the viewer has blocked this user" } } } diff --git a/internal/atproto/lexicon/social/coves/actor/getPosts.json b/internal/atproto/lexicon/social/coves/actor/getPosts.json index 45e6880..c000587 100644 --- a/internal/atproto/lexicon/social/coves/actor/getPosts.json +++ b/internal/atproto/lexicon/social/coves/actor/getPosts.json @@ -16,8 +16,8 @@ }, "filter": { "type": "string", - "knownValues": ["posts_with_replies", "posts_no_replies", "posts_with_media"], - "default": "posts_with_replies", + "knownValues": ["posts-with-replies", "posts-no-replies", "posts-with-media"], + "default": "posts-with-replies", "description": "Filter for post types" }, "community": { diff --git a/internal/atproto/lexicon/social/coves/actor/profile.json b/internal/atproto/lexicon/social/coves/actor/profile.json index 4568ff0..46a3bf6 100644 --- a/internal/atproto/lexicon/social/coves/actor/profile.json +++ b/internal/atproto/lexicon/social/coves/actor/profile.json @@ -8,7 +8,7 @@ "key": "literal:self", "record": { "type": "object", - "required": ["createdAt"], + "required": [], "properties": { "displayName": { "type": "string", @@ -16,15 +16,16 @@ "maxLength": 640, "description": "Optional display name" }, - "bio": { + "description": { "type": "string", "maxGraphemes": 256, "maxLength": 2560, - "description": "User bio with rich text support" + "description": "User bio/description with rich text support" }, - "bioFacets": { + "descriptionFacets": { "type": "array", - "description": "Rich text annotations for bio", + "maxLength": 50, + "description": "Rich text annotations for the description", "items": { "type": "ref", "ref": "social.coves.richtext.facet" @@ -32,12 +33,20 @@ }, "avatar": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], "maxSize": 1000000 }, "banner": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], "maxSize": 2000000 }, "createdAt": { @@ -48,4 +57,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/actor/updateProfile.json b/internal/atproto/lexicon/social/coves/actor/updateProfile.json index a9c9863..1d037ea 100644 --- a/internal/atproto/lexicon/social/coves/actor/updateProfile.json +++ b/internal/atproto/lexicon/social/coves/actor/updateProfile.json @@ -20,25 +20,27 @@ "type": "string", "maxGraphemes": 256, "maxLength": 2560, - "description": "User bio with rich text support" + "description": "User bio. Stored as the `description` field of the social.coves.actor.profile record." }, - "bioFacets": { - "type": "array", - "description": "Rich text annotations for bio", - "items": { - "type": "ref", - "ref": "social.coves.richtext.facet" - } + "avatarBlob": { + "type": "string", + "maxLength": 1400000, + "description": "Base64-encoded avatar image data (png, jpeg, or webp; decoded size limit 1MB)" }, - "avatar": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], - "maxSize": 1000000 + "avatarMimeType": { + "type": "string", + "maxLength": 128, + "description": "MIME type of the avatar image (image/png, image/jpeg, image/webp)" }, - "banner": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], - "maxSize": 2000000 + "bannerBlob": { + "type": "string", + "maxLength": 2800000, + "description": "Base64-encoded banner image data (png, jpeg, or webp; decoded size limit 2MB)" + }, + "bannerMimeType": { + "type": "string", + "maxLength": 128, + "description": "MIME type of the banner image (image/png, image/jpeg, image/webp)" } } } @@ -47,7 +49,10 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "cid"], + "required": [ + "uri", + "cid" + ], "properties": { "uri": { "type": "string", @@ -64,4 +69,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/aggregator/authorization.json b/internal/atproto/lexicon/social/coves/aggregator/authorization.json index 96b1606..81ca2c0 100644 --- a/internal/atproto/lexicon/social/coves/aggregator/authorization.json +++ b/internal/atproto/lexicon/social/coves/aggregator/authorization.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "Authorization for an aggregator to post to a community with specific configuration. Published in the community's repository by moderators. Similar to social.coves.actor.subscription.", + "description": "Authorization for an aggregator to post to a community with specific configuration. Published in the community's repository by moderators. The record key MUST be the authorized aggregator's DID, guaranteeing at most one authorization per (community, aggregator) pair. Similar to social.coves.actor.subscription.", "key": "any", "record": { "type": "object", diff --git a/internal/atproto/lexicon/social/coves/aggregator/register.json b/internal/atproto/lexicon/social/coves/aggregator/register.json index 247e391..e40aa7e 100644 --- a/internal/atproto/lexicon/social/coves/aggregator/register.json +++ b/internal/atproto/lexicon/social/coves/aggregator/register.json @@ -18,8 +18,8 @@ }, "domain": { "type": "string", - "format": "uri", - "description": "Domain where the aggregator is hosted (e.g., 'rss-bot.example.com'). Must serve .well-known/atproto-did file containing the DID." + "maxLength": 253, + "description": "Hostname where the aggregator is hosted, without scheme (e.g., 'rss-bot.example.com'). Must serve a .well-known/atproto-did file containing the DID." } } } diff --git a/internal/atproto/lexicon/social/coves/community/comment.json b/internal/atproto/lexicon/social/coves/community/comment.json index 6af5ce4..2d3f51e 100644 --- a/internal/atproto/lexicon/social/coves/community/comment.json +++ b/internal/atproto/lexicon/social/coves/community/comment.json @@ -17,8 +17,8 @@ }, "content": { "type": "string", - "maxGraphemes": 3000, - "maxLength": 30000, + "maxGraphemes": 10000, + "maxLength": 100000, "description": "Comment text content" }, "facets": { diff --git a/internal/atproto/lexicon/social/coves/community/comment/defs.json b/internal/atproto/lexicon/social/coves/community/comment/defs.json index 2da348c..b8cccc5 100644 --- a/internal/atproto/lexicon/social/coves/community/comment/defs.json +++ b/internal/atproto/lexicon/social/coves/community/comment/defs.json @@ -19,7 +19,7 @@ }, "author": { "type": "ref", - "ref": "social.coves.community.post.get#authorView", + "ref": "social.coves.community.post.defs#authorView", "description": "Comment author information" }, "record": { @@ -38,10 +38,10 @@ }, "embed": { "type": "union", - "description": "Embedded content in the comment (images or quoted post)", + "description": "Embedded content from the comment record (images or quoted post). The AppView may transform blob references into fetchable URLs and enrich quoted posts with a resolved view.", "refs": [ - "social.coves.embed.images#view", - "social.coves.embed.post#view" + "social.coves.embed.images", + "social.coves.embed.post" ] }, "createdAt": { diff --git a/internal/atproto/lexicon/social/coves/community/comment/getComments.json b/internal/atproto/lexicon/social/coves/community/comment/getComments.json index e97edf1..bbbe499 100644 --- a/internal/atproto/lexicon/social/coves/community/comment/getComments.json +++ b/internal/atproto/lexicon/social/coves/community/comment/getComments.json @@ -66,7 +66,7 @@ }, "post": { "type": "ref", - "ref": "social.coves.community.post.get#postView", + "ref": "social.coves.community.post.defs#postView", "description": "The post these comments belong to" }, "cursor": { diff --git a/internal/atproto/lexicon/social/coves/community/create.json b/internal/atproto/lexicon/social/coves/community/create.json index 668fec2..b2f1c4c 100644 --- a/internal/atproto/lexicon/social/coves/community/create.json +++ b/internal/atproto/lexicon/social/coves/community/create.json @@ -9,39 +9,37 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["name", "description"], + "required": [ + "name", + "description" + ], "properties": { "name": { "type": "string", - "maxGraphemes": 64, - "maxLength": 640, - "description": "Community display name" + "maxLength": 63, + "description": "Short community name used as the local part of the handle (e.g., 'gaming'). Must be a valid DNS label: ASCII letters, digits, and hyphens only." + }, + "displayName": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280, + "description": "Display name for the community" }, "description": { "type": "string", - "maxGraphemes": 300, - "maxLength": 3000, + "maxGraphemes": 1000, + "maxLength": 10000, "description": "Community description" }, - "avatar": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], - "maxSize": 1000000 - }, - "banner": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], - "maxSize": 2000000 - }, "avatarMimeType": { "type": "string", "maxLength": 128, - "description": "MIME type of avatar blob (image/png, image/jpeg, image/webp)" + "description": "MIME type of the avatar image (image/png, image/jpeg, image/webp)" }, "bannerMimeType": { "type": "string", "maxLength": 128, - "description": "MIME type of banner blob (image/png, image/jpeg, image/webp)" + "description": "MIME type of the banner image (image/png, image/jpeg, image/webp)" }, "rules": { "type": "array", @@ -76,7 +74,11 @@ }, "visibility": { "type": "string", - "knownValues": ["public", "unlisted", "private"], + "knownValues": [ + "public", + "unlisted", + "private" + ], "default": "public", "maxLength": 64, "description": "Community visibility level" @@ -85,6 +87,16 @@ "type": "boolean", "default": true, "description": "Whether other Coves instances can index and discover this community" + }, + "avatarBlob": { + "type": "string", + "maxLength": 1400000, + "description": "Base64-encoded avatar image data (png, jpeg, or webp)" + }, + "bannerBlob": { + "type": "string", + "maxLength": 2800000, + "description": "Base64-encoded banner image data (png, jpeg, or webp)" } } } @@ -93,7 +105,12 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "cid", "did", "handle"], + "required": [ + "uri", + "cid", + "did", + "handle" + ], "properties": { "uri": { "type": "string", @@ -130,4 +147,4 @@ ] } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/community/defs.json b/internal/atproto/lexicon/social/coves/community/defs.json index b7b765e..92a3de7 100644 --- a/internal/atproto/lexicon/social/coves/community/defs.json +++ b/internal/atproto/lexicon/social/coves/community/defs.json @@ -5,7 +5,10 @@ "communityView": { "type": "object", "description": "Basic community view with essential information and basic stats", - "required": ["did", "name"], + "required": [ + "did", + "name" + ], "properties": { "did": { "type": "string", @@ -19,8 +22,7 @@ }, "name": { "type": "string", - "maxLength": 64, - "maxGraphemes": 64, + "maxLength": 63, "description": "Short community name" }, "displayName": { @@ -36,7 +38,11 @@ }, "visibility": { "type": "string", - "knownValues": ["public", "unlisted", "private"], + "knownValues": [ + "public", + "unlisted", + "private" + ], "description": "Community visibility level" }, "subscriberCount": { @@ -73,7 +79,10 @@ "communityViewDetailed": { "type": "object", "description": "Detailed community view with stats and viewer state", - "required": ["did", "name"], + "required": [ + "did", + "name" + ], "properties": { "did": { "type": "string", @@ -87,8 +96,7 @@ }, "name": { "type": "string", - "maxLength": 64, - "maxGraphemes": 64, + "maxLength": 63, "description": "Short community name" }, "displayName": { @@ -138,12 +146,19 @@ }, "visibility": { "type": "string", - "knownValues": ["public", "unlisted", "private"], + "knownValues": [ + "public", + "unlisted", + "private" + ], "description": "Community visibility level" }, "moderationType": { "type": "string", - "knownValues": ["moderator", "sortition"], + "knownValues": [ + "moderator", + "sortition" + ], "description": "Type of moderation system" }, "contentWarnings": { @@ -151,7 +166,11 @@ "description": "Required content warnings for this community", "items": { "type": "string", - "knownValues": ["nsfw", "violence", "spoilers"], + "knownValues": [ + "nsfw", + "violence", + "spoilers" + ], "maxLength": 32 } }, diff --git a/internal/atproto/lexicon/social/coves/community/getMembers.json b/internal/atproto/lexicon/social/coves/community/getMembers.json index b4d5969..2eed433 100644 --- a/internal/atproto/lexicon/social/coves/community/getMembers.json +++ b/internal/atproto/lexicon/social/coves/community/getMembers.json @@ -7,7 +7,9 @@ "description": "Get list of users with membership status in a community", "parameters": { "type": "params", - "required": ["community"], + "required": [ + "community" + ], "properties": { "community": { "type": "string", @@ -25,7 +27,11 @@ }, "sort": { "type": "string", - "knownValues": ["reputation", "recent", "alphabetical"], + "knownValues": [ + "reputation", + "recent", + "alphabetical" + ], "default": "reputation", "maxLength": 64 } @@ -35,7 +41,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["members"], + "required": [ + "members" + ], "properties": { "members": { "type": "array", @@ -53,7 +61,10 @@ }, "memberView": { "type": "object", - "required": ["did", "memberSince", "reputation"], + "required": [ + "did", + "memberSince" + ], "properties": { "did": { "type": "string", @@ -64,7 +75,9 @@ "format": "handle" }, "displayName": { - "type": "string" + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 }, "avatar": { "type": "string", @@ -94,4 +107,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/community/getSubscribers.json b/internal/atproto/lexicon/social/coves/community/getSubscribers.json index 62b7fd8..0123b24 100644 --- a/internal/atproto/lexicon/social/coves/community/getSubscribers.json +++ b/internal/atproto/lexicon/social/coves/community/getSubscribers.json @@ -7,7 +7,9 @@ "description": "Get list of users subscribed to a community", "parameters": { "type": "params", - "required": ["community"], + "required": [ + "community" + ], "properties": { "community": { "type": "string", @@ -29,7 +31,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["subscribers"], + "required": [ + "subscribers" + ], "properties": { "subscribers": { "type": "array", @@ -47,7 +51,10 @@ }, "subscriberView": { "type": "object", - "required": ["did", "subscribedAt"], + "required": [ + "did", + "subscribedAt" + ], "properties": { "did": { "type": "string", @@ -58,7 +65,9 @@ "format": "handle" }, "displayName": { - "type": "string" + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 }, "avatar": { "type": "string", @@ -79,4 +88,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/community/moderator.json b/internal/atproto/lexicon/social/coves/community/moderator.json index b7cbec3..7dcd4d8 100644 --- a/internal/atproto/lexicon/social/coves/community/moderator.json +++ b/internal/atproto/lexicon/social/coves/community/moderator.json @@ -32,13 +32,13 @@ "items": { "type": "string", "knownValues": [ - "remove_posts", - "remove_comments", - "ban_users", - "manage_rules", - "manage_wiki", - "manage_moderators", - "manage_settings" + "remove-posts", + "remove-comments", + "ban-users", + "manage-rules", + "manage-wiki", + "manage-moderators", + "manage-settings" ], "maxLength": 64 } diff --git a/internal/atproto/lexicon/social/coves/community/post.json b/internal/atproto/lexicon/social/coves/community/post.json index 3f0c3ad..403d15c 100644 --- a/internal/atproto/lexicon/social/coves/community/post.json +++ b/internal/atproto/lexicon/social/coves/community/post.json @@ -12,8 +12,8 @@ "properties": { "community": { "type": "string", - "format": "at-identifier", - "description": "DID or handle of the community this was posted to" + "format": "did", + "description": "DID of the community this was posted to" }, "author": { "type": "string", @@ -71,8 +71,8 @@ "maxLength": 8, "items": { "type": "string", - "maxLength": 64, - "maxGraphemes": 64 + "maxGraphemes": 64, + "maxLength": 640 } }, "crosspostOf": { diff --git a/internal/atproto/lexicon/social/coves/community/post/create.json b/internal/atproto/lexicon/social/coves/community/post/create.json index 0a23ab4..a9b9508 100644 --- a/internal/atproto/lexicon/social/coves/community/post/create.json +++ b/internal/atproto/lexicon/social/coves/community/post/create.json @@ -9,7 +9,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["community"], + "required": [ + "community" + ], "properties": { "community": { "type": "string", @@ -67,7 +69,7 @@ "maxLength": 8, "items": { "type": "string", - "maxLength": 64, + "maxLength": 640, "maxGraphemes": 64 } } @@ -78,7 +80,10 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "cid"], + "required": [ + "uri", + "cid" + ], "properties": { "uri": { "type": "string", @@ -117,4 +122,4 @@ ] } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/community/post/defs.json b/internal/atproto/lexicon/social/coves/community/post/defs.json new file mode 100644 index 0000000..2083af6 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/post/defs.json @@ -0,0 +1,284 @@ +{ + "lexicon": 1, + "id": "social.coves.community.post.defs", + "defs": { + "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" + }, + "embed": { + "type": "union", + "description": "Embedded content from the post record. Untransformed embeds keep their record types; when the AppView rewrites blob references to URLs or resolves quoted posts, it serves the corresponding #view type.", + "refs": [ + "social.coves.embed.images", + "social.coves.embed.video", + "social.coves.embed.external", + "social.coves.embed.external#view", + "social.coves.embed.post", + "social.coves.embed.post#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", + "maxGraphemes": 128, + "maxLength": 1280 + }, + "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" + }, + "handle": { + "type": "string", + "format": "handle", + "description": "Current handle resolved from DID" + }, + "name": { + "type": "string", + "maxLength": 63 + }, + "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", + "knownValues": [ + "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", + "maxLength": 63 + } + } + }, + "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": "unknown", + "description": "Map of tag name to the number of community members who applied it (e.g., {\"insightful\": 12})" + } + } + }, + "viewerState": { + "type": "object", + "properties": { + "vote": { + "type": "string", + "knownValues": [ + "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", + "maxGraphemes": 64, + "maxLength": 640 + } + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/community/post/get.json b/internal/atproto/lexicon/social/coves/community/post/get.json index a9669f6..47ed913 100644 --- a/internal/atproto/lexicon/social/coves/community/post/get.json +++ b/internal/atproto/lexicon/social/coves/community/post/get.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs.", + "description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs. Auth optional: when authenticated, viewer state is populated.", "parameters": { "type": "params", "required": ["uris"], @@ -32,7 +32,11 @@ "description": "Array of post views. May include notFound/blocked entries for missing posts.", "items": { "type": "union", - "refs": ["#postView", "#notFoundPost", "#blockedPost"] + "refs": [ + "social.coves.community.post.defs#postView", + "social.coves.community.post.defs#notFoundPost", + "social.coves.community.post.defs#blockedPost" + ] } } } @@ -41,246 +45,6 @@ "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" - }, - "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" - }, - "handle": { - "type": "string", - "format": "handle", - "description": "Current handle resolved from 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", - "knownValues": ["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", - "knownValues": ["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/update.json b/internal/atproto/lexicon/social/coves/community/post/update.json index 169971e..88fcc4d 100644 --- a/internal/atproto/lexicon/social/coves/community/post/update.json +++ b/internal/atproto/lexicon/social/coves/community/post/update.json @@ -9,7 +9,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri"], + "required": [ + "uri" + ], "properties": { "uri": { "type": "string", @@ -24,8 +26,8 @@ }, "content": { "type": "string", - "maxLength": 50000, - "maxGraphemes": 20000, + "maxLength": 100000, + "maxGraphemes": 10000, "description": "Updated content - main text for text posts, description for media, etc." }, "facets": { @@ -67,13 +69,13 @@ "maxLength": 8, "items": { "type": "string", - "maxLength": 64, + "maxLength": 640, "maxGraphemes": 64 } }, "editNote": { "type": "string", - "maxLength": 300, + "maxLength": 3000, "maxGraphemes": 300, "description": "Optional note explaining the edit" } @@ -84,7 +86,10 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "cid"], + "required": [ + "uri", + "cid" + ], "properties": { "uri": { "type": "string", @@ -119,4 +124,4 @@ ] } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/community/profile.json b/internal/atproto/lexicon/social/coves/community/profile.json index b12fa3c..8351e28 100644 --- a/internal/atproto/lexicon/social/coves/community/profile.json +++ b/internal/atproto/lexicon/social/coves/community/profile.json @@ -8,13 +8,15 @@ "key": "literal:self", "record": { "type": "object", - "required": ["name", "createdAt", "createdBy", "hostedBy"], + "required": [ + "name", + "createdAt" + ], "properties": { "name": { "type": "string", - "maxLength": 64, - "maxGraphemes": 64, - "description": "Short community name (local part of handle)" + "maxLength": 63, + "description": "Short community name (local part of handle). Must be a valid DNS label: ASCII letters, digits, and hyphens only." }, "displayName": { "type": "string", @@ -38,12 +40,20 @@ }, "avatar": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], "maxSize": 1000000 }, "banner": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], "maxSize": 2000000 }, "createdBy": { @@ -58,14 +68,21 @@ }, "visibility": { "type": "string", - "knownValues": ["public", "unlisted", "private"], + "knownValues": [ + "public", + "unlisted", + "private" + ], "default": "public", "maxLength": 64, "description": "Community visibility level" }, "moderationType": { "type": "string", - "knownValues": ["moderator", "sortition"], + "knownValues": [ + "moderator", + "sortition" + ], "default": "moderator", "maxLength": 64, "description": "Type of moderation system (moderator=traditional moderator team, sortition=community tribunal)" @@ -75,7 +92,11 @@ "description": "Required content warnings for this community", "items": { "type": "string", - "knownValues": ["nsfw", "violence", "spoilers"], + "knownValues": [ + "nsfw", + "violence", + "spoilers" + ], "maxLength": 32 } }, @@ -87,4 +108,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/community/rules.json b/internal/atproto/lexicon/social/coves/community/rules.json index 942111e..e49f0b2 100644 --- a/internal/atproto/lexicon/social/coves/community/rules.json +++ b/internal/atproto/lexicon/social/coves/community/rules.json @@ -41,6 +41,7 @@ }, "moderatorList": { "type": "array", + "maxLength": 100, "description": "DIDs of community moderators (if moderator-based)", "items": { "type": "string", @@ -92,16 +93,20 @@ "properties": { "blockedDomains": { "type": "array", + "maxLength": 200, "description": "Domains that cannot be linked", "items": { - "type": "string" + "type": "string", + "maxLength": 253 } }, "allowedDomains": { "type": "array", + "maxLength": 200, "description": "If set, only these domains can be linked", "items": { - "type": "string" + "type": "string", + "maxLength": 253 } } } @@ -124,9 +129,11 @@ }, "allowedRegions": { "type": "array", + "maxLength": 100, "description": "Specific regions/states allowed", "items": { - "type": "string" + "type": "string", + "maxLength": 64 } } } @@ -134,93 +141,86 @@ "moderatorModeration": { "type": "object", "description": "Moderation configuration for moderator-based communities", - "required": ["$type"], "properties": { - "$type": { - "type": "string", - "description": "Discriminator for moderator-based moderation" - }, "negativeTags": { "type": "array", - "description": "Default tags that count as negative", - "default": ["spam", "hostile", "offtopic", "misleading"], + "maxLength": 50, + "description": "Tags that count as negative (typical defaults: spam, hostile, offtopic, misleading)", "items": { - "type": "string" + "type": "string", + "maxLength": 32 } }, "customNegativeTags": { "type": "array", + "maxLength": 50, "description": "Community-specific tags that count as negative", "items": { - "type": "string" + "type": "string", + "maxLength": 32 } }, "hideThreshold": { "type": "integer", - "minimum": 5, - "default": 15, - "description": "Number of negative tags needed to hide content" + "minimum": 1, + "description": "Number of negative tags needed to hide content (instance policy may impose a floor; typical value: 15)" } } }, "sortitionModeration": { "type": "object", "description": "Moderation configuration for sortition-based communities", - "required": ["$type"], "properties": { - "$type": { - "type": "string", - "description": "Discriminator for sortition-based moderation" - }, "negativeTags": { "type": "array", - "description": "Default tags that count as negative", - "default": ["spam", "hostile", "offtopic", "misleading"], + "maxLength": 50, + "description": "Tags that count as negative (typical defaults: spam, hostile, offtopic, misleading)", "items": { - "type": "string" + "type": "string", + "maxLength": 32 } }, "customNegativeTags": { "type": "array", + "maxLength": 50, "description": "Community-specific tags that count as negative", "items": { - "type": "string" + "type": "string", + "maxLength": 32 } }, "hideThreshold": { "type": "integer", - "minimum": 5, - "default": 15, - "description": "Number of negative tags needed to hide content" + "minimum": 1, + "description": "Number of negative tags needed to hide content (instance policy may impose a floor; typical value: 15)" }, "tribunalThreshold": { "type": "integer", - "minimum": 10, - "default": 30, - "description": "Number of negative tags to trigger tribunal review" + "minimum": 1, + "description": "Number of negative tags to trigger tribunal review (instance policy may impose a floor; typical value: 30)" }, "jurySize": { "type": "integer", - "minimum": 5, - "maximum": 21, - "default": 9, - "description": "Number of jurors for tribunal" + "minimum": 1, + "description": "Number of jurors for tribunal (instance policy governs allowed range; typical value: 9)" } } }, "rule": { "type": "object", "description": "A text-based community rule for display purposes", - "required": ["title", "description", "createdAt", "isActive"], + "required": ["title"], "properties": { "title": { "type": "string", - "maxLength": 256, + "maxGraphemes": 256, + "maxLength": 2560, "description": "Short rule title (e.g., 'No Editorialized Titles')" }, "description": { "type": "string", - "maxLength": 2000, + "maxGraphemes": 2000, + "maxLength": 20000, "description": "Detailed explanation of the rule" }, "createdAt": { diff --git a/internal/atproto/lexicon/social/coves/community/update.json b/internal/atproto/lexicon/social/coves/community/update.json index 167853d..5231f4f 100644 --- a/internal/atproto/lexicon/social/coves/community/update.json +++ b/internal/atproto/lexicon/social/coves/community/update.json @@ -9,84 +9,83 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["community"], + "required": [ + "communityDid" + ], "properties": { - "community": { + "communityDid": { "type": "string", "format": "did", "description": "DID of the community to update" }, - "name": { + "displayName": { "type": "string", - "maxGraphemes": 64, - "maxLength": 640, - "description": "Community display name" + "maxGraphemes": 128, + "maxLength": 1280, + "description": "Display name for the community" }, "description": { "type": "string", - "maxGraphemes": 300, - "maxLength": 3000, + "maxGraphemes": 1000, + "maxLength": 10000, "description": "Community description" }, - "avatar": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], - "maxSize": 1000000 - }, - "banner": { - "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], - "maxSize": 2000000 - }, "avatarMimeType": { "type": "string", "maxLength": 128, - "description": "MIME type of avatar blob (image/png, image/jpeg, image/webp)" + "description": "MIME type of the avatar image (image/png, image/jpeg, image/webp)" }, "bannerMimeType": { "type": "string", "maxLength": 128, - "description": "MIME type of banner blob (image/png, image/jpeg, image/webp)" + "description": "MIME type of the banner image (image/png, image/jpeg, image/webp)" }, - "rules": { - "type": "array", - "maxLength": 10, - "items": { - "type": "string", - "maxGraphemes": 200, - "maxLength": 2000 - }, - "description": "Community rules" + "moderationType": { + "type": "string", + "knownValues": [ + "moderator", + "sortition" + ], + "maxLength": 64, + "description": "Type of moderation system" }, - "categories": { + "contentWarnings": { "type": "array", - "maxLength": 3, + "maxLength": 10, + "description": "Required content warnings for this community", "items": { "type": "string", - "maxLength": 50 - }, - "description": "Community categories for discovery" - }, - "language": { - "type": "string", - "format": "language", - "description": "Primary language of the community" - }, - "membershipThreshold": { - "type": "integer", - "minimum": 0, - "maximum": 10000, - "description": "Reputation threshold required for membership" + "knownValues": [ + "nsfw", + "violence", + "spoilers" + ], + "maxLength": 32 + } }, "visibility": { "type": "string", - "knownValues": ["public", "unlisted", "private"], + "knownValues": [ + "public", + "unlisted", + "private" + ], "maxLength": 64, "description": "Community visibility level" }, "allowExternalDiscovery": { "type": "boolean", "description": "Whether other Coves instances can index and discover this community" + }, + "avatarBlob": { + "type": "string", + "maxLength": 1400000, + "description": "Base64-encoded avatar image data (png, jpeg, or webp)" + }, + "bannerBlob": { + "type": "string", + "maxLength": 2800000, + "description": "Base64-encoded banner image data (png, jpeg, or webp)" } } } @@ -95,7 +94,10 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "cid"], + "required": [ + "uri", + "cid" + ], "properties": { "uri": { "type": "string", @@ -122,4 +124,4 @@ ] } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/embed/external.json b/internal/atproto/lexicon/social/coves/embed/external.json index 5410261..0eab0aa 100644 --- a/internal/atproto/lexicon/social/coves/embed/external.json +++ b/internal/atproto/lexicon/social/coves/embed/external.json @@ -5,7 +5,9 @@ "main": { "type": "object", "description": "External link embed with optional aggregated sources for megathreads", - "required": ["external"], + "required": [ + "external" + ], "properties": { "external": { "type": "ref", @@ -16,7 +18,9 @@ "external": { "type": "object", "description": "Primary external link metadata", - "required": ["uri"], + "required": [ + "uri" + ], "properties": { "uri": { "type": "string", @@ -25,19 +29,23 @@ }, "title": { "type": "string", - "maxLength": 500, + "maxLength": 5000, "maxGraphemes": 500, "description": "Title of the linked content" }, "description": { "type": "string", - "maxLength": 1000, + "maxLength": 10000, "maxGraphemes": 1000, "description": "Description or excerpt of the linked content" }, "thumb": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], "maxSize": 6000000, "description": "Thumbnail image for the post (applies to primary link)" }, @@ -48,7 +56,12 @@ }, "embedType": { "type": "string", - "knownValues": ["article", "image", "video", "website"], + "knownValues": [ + "article", + "image", + "video", + "website" + ], "description": "Type hint for content rendering and filtering" }, "provider": { @@ -84,7 +97,9 @@ "source": { "type": "object", "description": "A source link aggregated into a megathread", - "required": ["uri"], + "required": [ + "uri" + ], "properties": { "uri": { "type": "string", @@ -93,7 +108,7 @@ }, "title": { "type": "string", - "maxLength": 500, + "maxLength": 5000, "maxGraphemes": 500, "description": "Title of the source article" }, @@ -108,6 +123,93 @@ "description": "Reference to the Coves post that originally shared this link. Used for feed deprioritization of rolled-up posts" } } + }, + "view": { + "type": "object", + "description": "View of an external link embed as served by the AppView", + "required": [ + "external" + ], + "properties": { + "external": { + "type": "ref", + "ref": "#viewExternal" + } + } + }, + "viewExternal": { + "type": "object", + "description": "External link metadata as served by the AppView; thumb is a fetchable URL instead of a blob reference", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "URI of the primary external content" + }, + "title": { + "type": "string", + "maxLength": 5000, + "maxGraphemes": 500, + "description": "Title of the linked content" + }, + "description": { + "type": "string", + "maxLength": 10000, + "maxGraphemes": 1000, + "description": "Description or excerpt of the linked content" + }, + "thumb": { + "type": "string", + "format": "uri", + "description": "URL of the thumbnail image, served via the hosting PDS blob endpoint" + }, + "domain": { + "type": "string", + "maxLength": 253, + "description": "Domain of the linked content (e.g., nytimes.com)" + }, + "embedType": { + "type": "string", + "knownValues": [ + "article", + "image", + "video", + "website" + ], + "description": "Type hint for content rendering and filtering" + }, + "provider": { + "type": "string", + "maxLength": 100, + "description": "Service provider name (e.g., imgur, streamable)" + }, + "images": { + "type": "array", + "maxLength": 8, + "description": "Preview images for image gallery providers", + "items": { + "type": "ref", + "ref": "social.coves.embed.images#image" + } + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "description": "Total number of items if more than displayed (for galleries)" + }, + "sources": { + "type": "array", + "description": "Aggregated source links for megathreads. Each source references an original article and optionally the Coves post that shared it", + "maxLength": 50, + "items": { + "type": "ref", + "ref": "#source" + } + } + } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/embed/images.json b/internal/atproto/lexicon/social/coves/embed/images.json index 4d58bf9..479d96c 100644 --- a/internal/atproto/lexicon/social/coves/embed/images.json +++ b/internal/atproto/lexicon/social/coves/embed/images.json @@ -5,7 +5,9 @@ "main": { "type": "object", "description": "Image set embed supporting multiple images (max 8)", - "required": ["images"], + "required": [ + "images" + ], "properties": { "images": { "type": "array", @@ -22,17 +24,24 @@ "image": { "type": "object", "description": "Individual image with metadata", - "required": ["image"], + "required": [ + "image" + ], "properties": { "image": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp", "image/gif"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif" + ], "maxSize": 10000000, "description": "Image blob reference" }, "alt": { "type": "string", - "maxLength": 1000, + "maxLength": 10000, "maxGraphemes": 1000, "description": "Alt text for accessibility" }, @@ -46,7 +55,10 @@ "aspectRatio": { "type": "object", "description": "Image aspect ratio for client display", - "required": ["width", "height"], + "required": [ + "width", + "height" + ], "properties": { "width": { "type": "integer", @@ -61,4 +73,4 @@ } } } -} \ 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 46362be..93a60a5 100644 --- a/internal/atproto/lexicon/social/coves/embed/post.json +++ b/internal/atproto/lexicon/social/coves/embed/post.json @@ -5,7 +5,9 @@ "main": { "type": "object", "description": "Embedded reference to another post (quoted post)", - "required": ["post"], + "required": [ + "post" + ], "properties": { "post": { "type": "ref", @@ -13,6 +15,24 @@ "description": "Strong reference to the embedded post (includes URI and CID)" } } + }, + "view": { + "type": "object", + "description": "View of a quoted-post embed as served by the AppView, optionally enriched with resolved post data", + "required": [ + "post" + ], + "properties": { + "post": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the embedded post (includes URI and CID)" + }, + "resolved": { + "type": "unknown", + "description": "Resolved data for the referenced post (e.g., a hydrated Bluesky post), or an unavailable marker with a user-facing message" + } + } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/embed/video.json b/internal/atproto/lexicon/social/coves/embed/video.json index d41d24a..f6ab08c 100644 --- a/internal/atproto/lexicon/social/coves/embed/video.json +++ b/internal/atproto/lexicon/social/coves/embed/video.json @@ -5,23 +5,32 @@ "main": { "type": "object", "description": "Video embed with metadata", - "required": ["video"], + "required": [ + "video" + ], "properties": { "video": { "type": "blob", - "accept": ["video/mp4", "video/webm"], + "accept": [ + "video/mp4", + "video/webm" + ], "maxSize": 100000000, "description": "Video blob reference" }, "thumbnail": { "type": "blob", - "accept": ["image/png", "image/jpeg", "image/webp"], + "accept": [ + "image/png", + "image/jpeg", + "image/webp" + ], "maxSize": 1000000, "description": "Video thumbnail image" }, "alt": { "type": "string", - "maxLength": 1000, + "maxLength": 10000, "maxGraphemes": 1000, "description": "Alt text describing video content" }, @@ -33,4 +42,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/atproto/lexicon/social/coves/feed/defs.json b/internal/atproto/lexicon/social/coves/feed/defs.json index 16c11f9..04ee638 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.community.post.get#postView" + "ref": "social.coves.community.post.defs#postView" }, "reason": { "type": "union", @@ -29,7 +29,7 @@ "properties": { "by": { "type": "ref", - "ref": "social.coves.community.post.get#authorView" + "ref": "social.coves.community.post.defs#authorView" }, "indexedAt": { "type": "string", @@ -44,7 +44,7 @@ "properties": { "community": { "type": "ref", - "ref": "social.coves.community.post.get#communityRef" + "ref": "social.coves.community.post.defs#communityRef" } } }, diff --git a/internal/atproto/lexicon/social/coves/feed/vote/create.json b/internal/atproto/lexicon/social/coves/feed/vote/create.json index 59b71a9..42cfa05 100644 --- a/internal/atproto/lexicon/social/coves/feed/vote/create.json +++ b/internal/atproto/lexicon/social/coves/feed/vote/create.json @@ -28,17 +28,16 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "cid"], "properties": { "uri": { "type": "string", "format": "at-uri", - "description": "AT-URI of the created vote (empty string if vote was toggled off)" + "description": "AT-URI of the created vote. Absent if the vote was toggled off." }, "cid": { "type": "string", "format": "cid", - "description": "CID of the created vote (empty string if vote was toggled off)" + "description": "CID of the created vote. Absent if the vote was toggled off." } } } diff --git a/internal/atproto/lexicon/social/coves/moderation/ban.json b/internal/atproto/lexicon/social/coves/moderation/ban.json index 2a893d8..dcda5cd 100644 --- a/internal/atproto/lexicon/social/coves/moderation/ban.json +++ b/internal/atproto/lexicon/social/coves/moderation/ban.json @@ -8,12 +8,12 @@ "key": "tid", "record": { "type": "object", - "required": ["community", "subject", "banType", "reason", "createdAt"], + "required": ["community", "subject", "banType", "createdAt"], "properties": { "community": { "type": "string", - "format": "at-identifier", - "description": "DID or handle of the community" + "format": "did", + "description": "DID of the community" }, "subject": { "type": "string", @@ -27,33 +27,33 @@ }, "reason": { "type": "string", - "maxLength": 2000, + "maxGraphemes": 2000, + "maxLength": 20000, "description": "Reason for the ban" }, "duration": { "type": "integer", "minimum": 1, - "description": "Ban duration in hours (null for permanent)" + "description": "Ban duration in hours (omit for permanent)" }, "bannedBy": { "type": "string", "format": "did", - "description": "DID of moderator who issued ban (null for tribunal bans)" + "description": "DID of moderator who issued ban (omitted for tribunal bans)" }, "tribunalCase": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of tribunal case that resulted in ban" + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the tribunal case that resulted in this ban" }, "status": { "type": "string", - "knownValues": ["active", "expired", "revoked"], - "default": "active" + "knownValues": ["active", "expired", "revoked"] }, "expiresAt": { "type": "string", "format": "datetime", - "description": "When the ban expires (null for permanent)" + "description": "When the ban expires (omit for permanent)" }, "revokedAt": { "type": "string", diff --git a/internal/atproto/lexicon/social/coves/moderation/banUser.json b/internal/atproto/lexicon/social/coves/moderation/banUser.json index 4d307bb..cd98e95 100644 --- a/internal/atproto/lexicon/social/coves/moderation/banUser.json +++ b/internal/atproto/lexicon/social/coves/moderation/banUser.json @@ -23,7 +23,8 @@ }, "reason": { "type": "string", - "maxLength": 2000, + "maxGraphemes": 2000, + "maxLength": 20000, "description": "Reason for the ban" }, "duration": { @@ -42,7 +43,7 @@ "properties": { "ban": { "type": "ref", - "ref": "social.coves.moderation.ban" + "ref": "social.coves.moderation.defs#banView" } } } diff --git a/internal/atproto/lexicon/social/coves/moderation/defs.json b/internal/atproto/lexicon/social/coves/moderation/defs.json new file mode 100644 index 0000000..9a28a38 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/moderation/defs.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "social.coves.moderation.defs", + "defs": { + "banView": { + "type": "object", + "description": "Hydrated view of a ban record as indexed by the AppView", + "required": [ + "uri", + "cid", + "record", + "indexedAt" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the ban record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID of the ban record" + }, + "record": { + "type": "unknown", + "description": "The social.coves.moderation.ban record, verbatim" + }, + "indexedAt": { + "type": "string", + "format": "datetime", + "description": "When this ban was indexed by the AppView" + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/moderation/getBanStatus.json b/internal/atproto/lexicon/social/coves/moderation/getBanStatus.json index 7818cff..3bc1bab 100644 --- a/internal/atproto/lexicon/social/coves/moderation/getBanStatus.json +++ b/internal/atproto/lexicon/social/coves/moderation/getBanStatus.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Check if a user is banned from a community", + "description": "Check if a user is banned from a community. Requires authentication; callers must be the subject themselves or a moderator of the community.", "parameters": { "type": "params", "required": ["community", "subject"], @@ -33,8 +33,8 @@ }, "ban": { "type": "ref", - "ref": "social.coves.moderation.ban", - "description": "Ban record if user is banned" + "ref": "social.coves.moderation.defs#banView", + "description": "Ban view if user is banned" } } } diff --git a/internal/atproto/lexicon/social/coves/moderation/listBans.json b/internal/atproto/lexicon/social/coves/moderation/listBans.json index 0c2aeb9..a245dbb 100644 --- a/internal/atproto/lexicon/social/coves/moderation/listBans.json +++ b/internal/atproto/lexicon/social/coves/moderation/listBans.json @@ -43,7 +43,7 @@ "type": "array", "items": { "type": "ref", - "ref": "social.coves.moderation.ban" + "ref": "social.coves.moderation.defs#banView" } }, "cursor": { diff --git a/internal/atproto/lexicon/social/coves/moderation/unbanUser.json b/internal/atproto/lexicon/social/coves/moderation/unbanUser.json index bf63d21..d36cb5e 100644 --- a/internal/atproto/lexicon/social/coves/moderation/unbanUser.json +++ b/internal/atproto/lexicon/social/coves/moderation/unbanUser.json @@ -23,7 +23,8 @@ }, "reason": { "type": "string", - "maxLength": 1000, + "maxGraphemes": 1000, + "maxLength": 10000, "description": "Reason for unbanning (optional)" } } @@ -33,13 +34,7 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["success"], - "properties": { - "success": { - "type": "boolean", - "description": "Whether the unban was successful" - } - } + "properties": {} } }, "errors": [ diff --git a/internal/atproto/pds/client.go b/internal/atproto/pds/client.go index d26f867..8d24364 100644 --- a/internal/atproto/pds/client.go +++ b/internal/atproto/pds/client.go @@ -154,6 +154,12 @@ func (c *client) CreateRecord(ctx context.Context, collection string, rkey strin return "", "", wrapAPIError(err, "createRecord") } + // A 200 with an empty uri/cid means the PDS (or a proxy in front of it) + // returned a malformed body; callers must never mistake it for success + if result.URI == "" || result.CID == "" { + return "", "", fmt.Errorf("createRecord: PDS returned success without uri/cid (collection %s)", collection) + } + return result.URI, result.CID, nil } diff --git a/internal/core/posts/blob_transform.go b/internal/core/posts/blob_transform.go index c79b698..a5b812c 100644 --- a/internal/core/posts/blob_transform.go +++ b/internal/core/posts/blob_transform.go @@ -42,6 +42,9 @@ func TransformBlobRefsToURLs(postView *PostView) { if embedType == "social.coves.embed.external" { if external, ok := embedMap["external"].(map[string]interface{}); ok { transformThumbToURL(external, communityDID, pdsURL) + // The served shape no longer matches the record schema (thumb is a + // URL string, not a blob), so declare the view type on the wire + embedMap["$type"] = "social.coves.embed.external#view" } } } @@ -166,9 +169,11 @@ func TransformPostEmbeds(ctx context.Context, postView *PostView, blueskyService "message": errorMessage, "retryable": retryable, } + embedMap["$type"] = "social.coves.embed.post#view" return } // Add resolved data to embed embedMap["resolved"] = result + embedMap["$type"] = "social.coves.embed.post#view" } diff --git a/internal/core/posts/post.go b/internal/core/posts/post.go index 43fa404..f9b1900 100644 --- a/internal/core/posts/post.go +++ b/internal/core/posts/post.go @@ -262,9 +262,9 @@ type ViewerState struct { // Filter constants for GetAuthorPosts const ( - FilterPostsWithReplies = "posts_with_replies" - FilterPostsNoReplies = "posts_no_replies" - FilterPostsWithMedia = "posts_with_media" + FilterPostsWithReplies = "posts-with-replies" + FilterPostsNoReplies = "posts-no-replies" + FilterPostsWithMedia = "posts-with-media" ) // GetAuthorPostsRequest represents input for fetching author's posts diff --git a/internal/core/posts/service.go b/internal/core/posts/service.go index 8970399..679f9cb 100644 --- a/internal/core/posts/service.go +++ b/internal/core/posts/service.go @@ -843,6 +843,8 @@ func (s *postService) validateGetAuthorPostsRequest(req *GetAuthorPostsRequest) } // Validate and set defaults for filter + // Legacy snake_case values are normalized so pre-rename clients keep working + req.Filter = strings.ReplaceAll(req.Filter, "_", "-") validFilters := map[string]bool{ FilterPostsWithReplies: true, FilterPostsNoReplies: true, @@ -852,7 +854,7 @@ func (s *postService) validateGetAuthorPostsRequest(req *GetAuthorPostsRequest) req.Filter = FilterPostsWithReplies // Default } if !validFilters[req.Filter] { - return NewValidationError("filter", "filter must be one of: posts_with_replies, posts_no_replies, posts_with_media") + return NewValidationError("filter", "filter must be one of: posts-with-replies, posts-no-replies, posts-with-media") } // Validate and set defaults for limit diff --git a/tests/lexicon-test-data/community/moderator-valid.json b/tests/lexicon-test-data/community/moderator-valid.json index 2185635..40e4ca3 100644 --- a/tests/lexicon-test-data/community/moderator-valid.json +++ b/tests/lexicon-test-data/community/moderator-valid.json @@ -3,7 +3,7 @@ "user": "did:plc:trustedmoderator", "community": "did:plc:programmingcommunity", "role": "moderator", - "permissions": ["remove_posts", "remove_comments", "manage_wiki"], + "permissions": ["remove-posts", "remove-comments", "manage-wiki"], "createdAt": "2024-06-15T10:00:00Z", "createdBy": "did:plc:communityowner" } \ No newline at end of file diff --git a/tests/lexicon-test-data/community/profile-invalid-moderation-type.json b/tests/lexicon-test-data/community/profile-invalid-moderation-type.json index 45033de..53ecc55 100644 --- a/tests/lexicon-test-data/community/profile-invalid-moderation-type.json +++ b/tests/lexicon-test-data/community/profile-invalid-moderation-type.json @@ -3,7 +3,7 @@ "name": "testcommunity", "displayName": "Test Community", "creator": "did:plc:creator123", - "moderationType": "anarchy", + "moderationType": 12345, "federatedFrom": "coves", "createdAt": "2023-12-01T08:00:00Z" -} \ No newline at end of file +} diff --git a/tests/lexicon_validation_test.go b/tests/lexicon_validation_test.go index 7c0599d..cf45898 100644 --- a/tests/lexicon_validation_test.go +++ b/tests/lexicon_validation_test.go @@ -131,15 +131,14 @@ func TestValidateRecord(t *testing.T) { shouldFail: false, }, { - name: "Invalid actor profile - missing required field", + name: "Invalid actor profile - wrong field type", recordType: "social.coves.actor.profile", recordData: map[string]interface{}{ "$type": "social.coves.actor.profile", - "displayName": "Alice Johnson", - // Missing required createdAt + "displayName": int64(12345), + "createdAt": "2024-01-15T10:30:00Z", }, - shouldFail: true, - errorContains: "required field missing", + shouldFail: true, }, { name: "Valid community profile",