A community based topic aggregation platform built on atproto

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

+3 -3
aggregators/kagi-news/src/coves_client.py
···
Handles:
- Authentication with aggregator credentials
-
- Creating posts in communities (social.coves.post.create)
+
- Creating posts in communities (social.coves.community.post.create)
- External embed formatting
"""
···
self.authenticate()
try:
-
# Prepare post data for social.coves.post.create endpoint
+
# Prepare post data for social.coves.community.post.create endpoint
post_data = {
"community": community_handle,
"content": content,
···
logger.info(f"Creating post in community: {community_handle}")
# Make direct HTTP request to XRPC endpoint
-
url = f"{self.api_url}/xrpc/social.coves.post.create"
+
url = f"{self.api_url}/xrpc/social.coves.community.post.create"
headers = {
"Authorization": f"Bearer {self.client._session.access_jwt}",
"Content-Type": "application/json"
+2 -2
cmd/server/main.go
···
postJetstreamURL := os.Getenv("POST_JETSTREAM_URL")
if postJetstreamURL == "" {
// Listen to post record creation events
-
postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.post.record"
+
postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.post"
}
postEventConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
···
}()
log.Printf("Started Jetstream post consumer: %s", postJetstreamURL)
-
log.Println(" - Indexing: social.coves.post.record CREATE operations")
+
log.Println(" - Indexing: social.coves.community.post CREATE operations")
log.Println(" - UPDATE/DELETE indexing deferred until those features are implemented")
// Start Jetstream consumer for aggregators
+17 -10
cmd/validate-lexicon/main.go
···
}
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 {
···
"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",
+
"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
-
"social.coves.post.record#originalAuthor",
+
// Post record types (removed - no longer exists in new structure)
// Actor definitions
"social.coves.actor.profile#geoLocation",
+5 -5
docs/COMMUNITY_FEEDS.md
···
```go
type PostView struct {
-
URI string // at://did:plc:abc/social.coves.post.record/123
+
URI string // at://did:plc:abc/social.coves.community.post.record/123
CID string // Content ID
RKey string // Record key (TID)
Author *AuthorView // Author with handle, avatar, reputation
···
"feed": [
{
"post": {
-
"uri": "at://did:plc:gaming123/social.coves.post.record/abc",
+
"uri": "at://did:plc:gaming123/social.coves.community.post.record/abc",
"cid": "bafyrei...",
"author": {
"did": "did:plc:alice",
···
GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes
→ Returns: [uri1, uri2, uri3, ...]
-
GET /xrpc/social.coves.post.get?uris=[...]
+
GET /xrpc/social.coves.community.post.get?uris=[...]
→ Returns: [full posts]
```
···
## Lexicon Updates
-
### Updated: `social.coves.post.get`
+
### Updated: `social.coves.community.post.get`
**Changes:**
1. ✅ Batch URIs: `uri` → `uris[]` (max 25)
···
// Custom feed (power users)
GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes
→ Returns URIs
-
GET /xrpc/social.coves.post.get?uris=[...]
+
GET /xrpc/social.coves.community.post.get?uris=[...]
→ Hydrates posts
```
+1 -1
docs/FEED_SYSTEM_IMPLEMENTATION.md
···
"feed": [
{
"post": {
-
"uri": "at://did:plc:community-gaming/social.coves.post.record/3k...",
+
"uri": "at://did:plc:community-gaming/social.coves.community.post.record/3k...",
"cid": "bafyrei...",
"author": {
"did": "did:plc:alice",
+1 -1
docs/PRD_GOVERNANCE.md
···
- [ ] Go structs: `ContentRules` type in community models
- [ ] Repository: Parse and store `contentRules` from community profiles
- [ ] Service: `ValidatePostAgainstRules(post, community)` function
-
- [ ] Handler: Integrate validation into `social.coves.post.create`
+
- [ ] Handler: Integrate validation into `social.coves.community.post.create`
- [ ] AppView indexing: Index post characteristics (embed_type, text_length, etc.)
- [ ] Tests: Comprehensive rule validation tests
- [ ] Documentation: Content rules guide for community creators
+17 -17
docs/PRD_POSTS.md
···
**Repository Structure:**
```
-
Repository: at://did:plc:community789/social.coves.post.record/3k2a4b5c6d7e
+
Repository: at://did:plc:community789/social.coves.community.post.record/3k2a4b5c6d7e
Owner: did:plc:community789 (community owns the post)
Author: did:plc:user123 (tracked in record metadata)
Hosted By: did:web:coves.social (instance manages community credentials)
···
**Implementation checklist:**
- [x] Lexicon: `contentRules` in `social.coves.community.profile` ✅
-
- [x] Lexicon: `postType` removed from `social.coves.post.create` ✅
+
- [x] Lexicon: `postType` removed from `social.coves.community.post.create` ✅
- [ ] Validation: `ValidatePostAgainstRules()` service function
- [ ] Handler: Integrate validation in post creation endpoint
- [ ] AppView: Index derived characteristics (embed_type, text_length, etc.)
···
**Priority:** CRITICAL - Posts are the foundation of the platform
#### Create Post
-
- [x] Lexicon: `social.coves.post.record` ✅
-
- [x] Lexicon: `social.coves.post.create` ✅
+
- [x] Lexicon: `social.coves.community.post.record` ✅
+
- [x] Lexicon: `social.coves.community.post.create` ✅
- [x] Removed `postType` enum in favor of content rules ✅ (2025-10-18)
- [x] Removed `postType` from record and get lexicons ✅ (2025-10-18)
-
- [x] **Handler:** `POST /xrpc/social.coves.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md)
+
- [x] **Handler:** `POST /xrpc/social.coves.community.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md)
- ✅ Accept: community (DID/handle), title (optional), content, facets, embed, contentLabels
- ✅ Validate: User is authenticated, community exists, content within limits
- ✅ Write: Create record in **community's PDS repository**
···
- [x] **E2E Test:** Create text post → Write to **community's PDS** → Index via Jetstream → Verify in AppView ✅
#### Get Post
-
- [x] Lexicon: `social.coves.post.get` ✅
-
- [ ] **Handler:** `GET /xrpc/social.coves.post.get?uri=at://...`
+
- [x] Lexicon: `social.coves.community.post.get` ✅
+
- [ ] **Handler:** `GET /xrpc/social.coves.community.post.get?uri=at://...`
- Accept: AT-URI of post
- Return: Full post view with author, community, stats, viewer state
- [ ] **Service Layer:** `PostService.Get(uri, viewerDID)`
···
- [ ] **E2E Test:** Get post by URI → Verify all fields populated
#### Update Post
-
- [x] Lexicon: `social.coves.post.update` ✅
-
- [ ] **Handler:** `POST /xrpc/social.coves.post.update`
+
- [x] Lexicon: `social.coves.community.post.update` ✅
+
- [ ] **Handler:** `POST /xrpc/social.coves.community.post.update`
- Accept: uri, title, content, facets, embed, contentLabels, editNote
- Validate: User is post author, within 24-hour edit window
- Write: Update record in **community's PDS**
···
- [ ] **E2E Test:** Update post → Verify edit reflected in AppView
#### Delete Post
-
- [x] Lexicon: `social.coves.post.delete` ✅
-
- [ ] **Handler:** `POST /xrpc/social.coves.post.delete`
+
- [x] Lexicon: `social.coves.community.post.delete` ✅
+
- [ ] **Handler:** `POST /xrpc/social.coves.community.post.delete`
- Accept: uri
- Validate: User is post author OR community moderator
- Write: Delete record from **community's PDS**
···
#### Post Event Handling
- [x] **Consumer:** `PostConsumer.HandlePostEvent()` ✅ (2025-10-19)
-
- ✅ Listen for `social.coves.post.record` CREATE from **community repositories**
+
- ✅ Listen for `social.coves.community.post.record` CREATE from **community repositories**
- ✅ Parse post record, extract author DID and community DID (from AT-URI owner)
- ⚠️ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering)
- ✅ Insert in AppView PostgreSQL (CREATE only - UPDATE/DELETE deferred)
···
- [ ] **Tag Storage:** Tags live in **user's repository** (users own their tags)
#### Crossposting
-
- [x] Lexicon: `social.coves.post.crosspost` ✅
+
- [x] Lexicon: `social.coves.community.post.crosspost` ✅
- [ ] **Crosspost Tracking:** Share post to multiple communities
- [ ] **Implementation:** Create new post record in each community's repository
- [ ] **Crosspost Chain:** Track all crosspost relationships
···
- [ ] **AppView Query:** Endpoint to fetch user's saved posts
### Post Search
-
- [x] Lexicon: `social.coves.post.search` ✅
+
- [x] Lexicon: `social.coves.community.post.search` ✅
- [ ] **Search Parameters:**
- Query string (q)
- Filter by community
···
- **Reuses Token Refresh:** Can leverage existing community credential management
**Implementation Details:**
-
- Post AT-URI: `at://community_did/social.coves.post.record/tid`
+
- Post AT-URI: `at://community_did/social.coves.community.post.record/tid`
- Write operations use community's PDS credentials (encrypted, stored in AppView)
- Author tracked in post record's `author` field (DID)
- Moderators can delete any post in their community
···
## Lexicon Summary
-
### `social.coves.post.record`
+
### `social.coves.community.post.record`
**Status:** ✅ Defined, implementation TODO
**Last Updated:** 2025-10-18 (removed `postType` enum)
···
- Post "type" is derived from structure (has embed? what embed type? has title? text length?)
- Community's `contentRules` validate post structure at creation time
-
### `social.coves.post.create` (Procedure)
+
### `social.coves.community.post.create` (Procedure)
**Status:** ✅ Defined, implementation TODO
**Last Updated:** 2025-10-18 (removed `postType` parameter)
+4 -4
docs/aggregators/PRD_AGGREGATORS.md
···
1. **Aggregators are Actors, Not a Separate System**
- Each aggregator has its own DID
- Authenticate as themselves via JWT
-
- Use existing `social.coves.post.create` endpoint
+
- Use existing `social.coves.community.post.create` endpoint
- Post record's `author` field = aggregator DID (server-populated)
- No separate posting API needed
···
Aggregator Service (External)
│ 1. Authenticates as aggregator DID (JWT)
-
│ 2. Calls social.coves.post.create
+
│ 2. Calls social.coves.community.post.create
Coves AppView Handler
···
### For Aggregators
-
- **`social.coves.post.create`** - Modified to handle aggregator auth
+
- **`social.coves.community.post.create`** - Modified to handle aggregator auth
- **`social.coves.aggregator.getAuthorizations`** - Query authorized communities
### For Discovery
···
---
-
### 2025-10-19: Reuse `social.coves.post.create` Endpoint
+
### 2025-10-19: Reuse `social.coves.community.post.create` Endpoint
**Decision:** Aggregators use existing post creation endpoint.
**Rationale:**
+3 -3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
···
│ 3. Deduplication: Tracks posted items via JSON state file │
│ 4. Feed Mapper: Maps feed URLs to community handles │
│ 5. Post Formatter: Converts to Coves post format │
-
│ 6. Post Publisher: Calls social.coves.post.create via XRPC │
+
│ 6. Post Publisher: Calls social.coves.community.post.create via XRPC │
│ 7. Blob Uploader: Handles image upload to ATProto │
└─────────────────────────────────────────────────────────────┘
│ Authenticated XRPC calls
┌─────────────────────────────────────────────────────────────┐
-
│ Coves AppView (social.coves.post.create) │
+
│ Coves AppView (social.coves.community.post.create) │
│ - Validates aggregator authorization │
│ - Creates post with author = did:plc:[aggregator-did] │
│ - Indexes to community feeds │
···
```json
{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post.record",
"author": "did:plc:[aggregator-did]",
"community": "world-news.coves.social",
"title": "{Kagi story title}",
+1 -1
internal/api/handlers/post/create.go
···
}
}
-
// HandleCreate handles POST /xrpc/social.coves.post.create
+
// HandleCreate handles POST /xrpc/social.coves.community.post.create
// Creates a new post in a community's repository
func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
// 1. Check HTTP method
+7 -7
internal/api/routes/post.go
···
)
// RegisterPostRoutes registers post-related XRPC endpoints on the router
-
// Implements social.coves.post.* lexicon endpoints
+
// Implements social.coves.community.post.* lexicon endpoints
func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {
// Initialize handlers
createHandler := post.NewCreateHandler(service)
// Procedure endpoints (POST) - require authentication
-
// social.coves.post.create - create a new post in a community
-
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.create", createHandler.HandleCreate)
+
// social.coves.community.post.create - create a new post in a community
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate)
// Future endpoints (Beta):
-
// r.Get("/xrpc/social.coves.post.get", getHandler.HandleGet)
-
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.update", updateHandler.HandleUpdate)
-
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.delete", deleteHandler.HandleDelete)
-
// r.Get("/xrpc/social.coves.post.list", listHandler.HandleList)
+
// r.Get("/xrpc/social.coves.community.post.get", getHandler.HandleGet)
+
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.update", updateHandler.HandleUpdate)
+
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.delete", deleteHandler.HandleDelete)
+
// r.Get("/xrpc/social.coves.community.post.list", listHandler.HandleList)
}
+11 -11
internal/atproto/jetstream/post_consumer.go
···
)
// PostEventConsumer consumes post-related events from Jetstream
-
// Currently handles only CREATE operations for social.coves.post.record
+
// Currently handles only CREATE operations for social.coves.community.post
// UPDATE and DELETE handlers will be added when those features are implemented
-
type PostEventConsumer struct {
+
type PostEventConsumer struct{
postRepo posts.Repository
communityRepo communities.Repository
userService users.UserService
···
// Only handle post record creation for now
// UPDATE and DELETE will be added when we implement those features
-
if commit.Collection == "social.coves.post.record" && commit.Operation == "create" {
+
if commit.Collection == "social.coves.community.post" && commit.Operation == "create" {
return c.createPost(ctx, event.Did, commit)
}
···
}
// Build AT-URI for this post
-
// Format: at://community_did/social.coves.post.record/rkey
-
uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey)
+
// Format: at://community_did/social.coves.community.post/rkey
+
uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey)
// Parse timestamp from record
createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt)
···
}
}
-
if len(postRecord.ContentLabels) > 0 {
-
labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels)
+
if postRecord.Labels != nil {
+
labelsJSON, marshalErr := json.Marshal(postRecord.Labels)
if marshalErr == nil {
labelsStr := string(labelsJSON)
post.ContentLabels = &labelsStr
···
// This prevents users from creating posts that appear to be from communities they don't control
//
// Example attack prevented:
-
// - User creates post in their own repo (at://user_did/social.coves.post.record/xyz)
+
// - User creates post in their own repo (at://user_did/social.coves.community.post/xyz)
// - Claims it's for community X (community field = community_did)
// - Without this check, fake post would be indexed
//
···
}
// PostRecordFromJetstream represents a post record as received from Jetstream
-
// Matches the structure written to PDS via social.coves.post.record
-
type PostRecordFromJetstream struct {
+
// Matches the structure written to PDS via social.coves.community.post
+
type PostRecordFromJetstream struct{
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
Location interface{} `json:"location,omitempty"`
···
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
-
ContentLabels []string `json:"contentLabels,omitempty"`
+
Labels *posts.SelfLabels `json:"labels,omitempty"`
}
// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+100
internal/atproto/lexicon/social/coves/community/post.json
···
+
{
+
"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"
+
}
+
}
+
}
+
}
+
}
+
}
+6 -6
internal/atproto/lexicon/social/coves/embed/post.json
···
"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)"
}
}
}
+80
internal/atproto/lexicon/social/coves/feed/comment.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.feed.comment",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "A comment on a post or another comment. Comments live in user repositories and support nested threading.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["reply", "content", "createdAt"],
+
"properties": {
+
"reply": {
+
"type": "ref",
+
"ref": "#replyRef",
+
"description": "Reference to the post and parent being replied to"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 3000,
+
"maxLength": 30000,
+
"description": "Comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of comment creation"
+
}
+
}
+
}
+
},
+
"replyRef": {
+
"type": "object",
+
"description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).",
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the original post that started the thread"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the immediate parent (post or comment) being replied to"
+
}
+
}
+
}
+
}
+
}
+3 -3
internal/atproto/lexicon/social/coves/feed/defs.json
···
"properties": {
"post": {
"type": "ref",
-
"ref": "social.coves.post.get#postView"
+
"ref": "social.coves.community.post.get#postView"
},
"reason": {
"type": "union",
···
"properties": {
"by": {
"type": "ref",
-
"ref": "social.coves.post.get#authorView"
+
"ref": "social.coves.community.post.get#authorView"
},
"indexedAt": {
"type": "string",
···
"properties": {
"community": {
"type": "ref",
-
"ref": "social.coves.post.get#communityRef"
+
"ref": "social.coves.community.post.get#communityRef"
}
}
},
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.comment",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A comment on a post or another comment",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "content", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of post or comment being replied to"
-
},
-
"content": {
-
"type": "union",
-
"refs": ["#textContent", "#imageContent", "#stickerContent"]
-
},
-
"location": {
-
"type": "ref",
-
"ref": "social.coves.actor.profile#geoLocation"
-
},
-
"translatedFrom": {
-
"type": "string",
-
"maxLength": 10,
-
"description": "Language code if auto-translated (ISO 639-1)"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
},
-
"textContent": {
-
"type": "object",
-
"required": ["text"],
-
"properties": {
-
"text": {
-
"type": "string",
-
"maxLength": 10000,
-
"description": "Comment text"
-
},
-
"facets": {
-
"type": "array",
-
"description": "Rich text annotations",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
}
-
}
-
},
-
"imageContent": {
-
"type": "object",
-
"required": ["image"],
-
"properties": {
-
"image": {
-
"type": "ref",
-
"ref": "social.coves.embed.images#image"
-
},
-
"caption": {
-
"type": "string",
-
"maxLength": 1000
-
}
-
}
-
},
-
"stickerContent": {
-
"type": "object",
-
"required": ["stickerId"],
-
"properties": {
-
"stickerId": {
-
"type": "string",
-
"description": "Reference to a sticker in a sticker pack"
-
},
-
"stickerPackId": {
-
"type": "string",
-
"description": "Reference to the sticker pack"
-
}
-
}
-
}
-
}
-
}
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.createComment",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Create a comment on a post or another comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["parent", "text"],
-
"properties": {
-
"parent": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment being replied to"
-
},
-
"text": {
-
"type": "string",
-
"maxGraphemes": 3000,
-
"maxLength": 30000,
-
"description": "Comment text"
-
},
-
"textFacets": {
-
"type": "array",
-
"description": "Rich text annotations",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created comment"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created comment"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "ParentNotFound",
-
"description": "Parent post or comment not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to comment"
-
},
-
{
-
"name": "ThreadLocked",
-
"description": "Comment thread is locked"
-
},
-
{
-
"name": "Banned",
-
"description": "User is banned from this community"
-
}
-
]
-
}
-
}
-
}
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.deleteComment",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Delete a comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the comment to delete"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "CommentNotFound",
-
"description": "Comment not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to delete this comment"
-
}
-
]
-
}
-
}
-
}
+23 -22
internal/atproto/lexicon/social/coves/post/create.json internal/atproto/lexicon/social/coves/community/post/create.json
···
{
"lexicon": 1,
-
"id": "social.coves.post.create",
+
"id": "social.coves.community.post.create",
"defs": {
"main": {
"type": "procedure",
···
"type": "string",
"maxGraphemes": 300,
"maxLength": 3000,
-
"description": "Post title (optional for microblog, image, and video posts)"
+
"description": "Post title (optional for media-only posts)"
},
"content": {
"type": "string",
-
"maxLength": 50000,
-
"description": "Post content - main text for text posts, description for media, etc."
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Post content - supports rich text via facets"
},
"facets": {
"type": "array",
-
"description": "Rich text annotations for content",
+
"description": "Annotations for rich text (mentions, links, tags)",
"items": {
"type": "ref",
"ref": "social.coves.richtext.facet"
···
},
"embed": {
"type": "union",
-
"description": "Embedded content - images, videos, external links, or quoted posts",
+
"description": "Embedded media, external links, or quoted posts",
"refs": [
"social.coves.embed.images",
"social.coves.embed.video",
···
"social.coves.embed.post"
]
},
-
"originalAuthor": {
-
"type": "ref",
-
"ref": "social.coves.post.record#originalAuthor",
-
"description": "For microblog posts - information about the original author"
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the post content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
},
-
"federatedFrom": {
+
"labels": {
"type": "ref",
-
"ref": "social.coves.federation.post",
-
"description": "Reference to original federated post (for microblog posts)"
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels (NSFW, spoilers, etc.)"
},
-
"contentLabels": {
+
"tags": {
"type": "array",
-
"description": "Self-applied content labels",
+
"description": "User-applied topic tags",
+
"maxLength": 8,
"items": {
"type": "string",
-
"knownValues": ["nsfw", "spoiler", "violence"],
-
"maxLength": 32
+
"maxLength": 64,
+
"maxGraphemes": 64
}
-
},
-
"location": {
-
"type": "ref",
-
"ref": "social.coves.actor.profile#geoLocation",
-
"description": "Geographic location where post was created"
}
}
}
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.crosspost",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A record tracking crosspost relationships between posts",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["originalPost", "crosspostOf", "createdAt"],
-
"properties": {
-
"originalPost": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the original post in the crosspost chain"
-
},
-
"crosspostOf": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the immediate parent this is a crosspost of"
-
},
-
"allCrossposts": {
-
"type": "array",
-
"description": "Array of AT-URIs of all posts in the crosspost chain",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-
}
+1 -1
internal/atproto/lexicon/social/coves/post/delete.json internal/atproto/lexicon/social/coves/community/post/delete.json
···
{
"lexicon": 1,
-
"id": "social.coves.post.delete",
+
"id": "social.coves.community.post.delete",
"defs": {
"main": {
"type": "procedure",
+1 -1
internal/atproto/lexicon/social/coves/post/get.json internal/atproto/lexicon/social/coves/community/post/get.json
···
{
"lexicon": 1,
-
"id": "social.coves.post.get",
+
"id": "social.coves.community.post.get",
"defs": {
"main": {
"type": "query",
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.getCrosspostChain",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Get all crossposts in a crosspost chain for a given post",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of any post in the crosspost chain"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["crossposts"],
-
"properties": {
-
"crossposts": {
-
"type": "array",
-
"description": "All posts in the crosspost chain",
-
"items": {
-
"type": "ref",
-
"ref": "#crosspostView"
-
}
-
}
-
}
-
}
-
}
-
},
-
"crosspostView": {
-
"type": "object",
-
"required": ["uri", "community", "author", "createdAt"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post"
-
},
-
"community": {
-
"type": "object",
-
"required": ["uri", "name"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the community"
-
},
-
"name": {
-
"type": "string",
-
"description": "Display name of the community"
-
},
-
"handle": {
-
"type": "string",
-
"description": "Handle of the community"
-
}
-
}
-
},
-
"author": {
-
"type": "object",
-
"required": ["did", "handle"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"handle": {
-
"type": "string"
-
},
-
"displayName": {
-
"type": "string"
-
},
-
"avatar": {
-
"type": "string",
-
"format": "uri"
-
}
-
}
-
},
-
"isOriginal": {
-
"type": "boolean",
-
"description": "Whether this is the original post in the chain"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-129
internal/atproto/lexicon/social/coves/post/record.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.record",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A unified post record supporting multiple content types",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["$type", "community", "author", "createdAt"],
-
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.post.record",
-
"description": "The record type identifier"
-
},
-
"community": {
-
"type": "string",
-
"format": "at-identifier",
-
"description": "DID or handle of the community this was posted to"
-
},
-
"author": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user who created this post. Server-populated from authenticated session; clients MUST NOT provide this field. Required for attribution, moderation, and accountability."
-
},
-
"title": {
-
"type": "string",
-
"maxGraphemes": 300,
-
"maxLength": 3000,
-
"description": "Post title (optional for microblog, image, and video posts)"
-
},
-
"content": {
-
"type": "string",
-
"maxLength": 50000,
-
"description": "Post content - main text for text posts, description for media, etc."
-
},
-
"facets": {
-
"type": "array",
-
"description": "Rich text annotations for content",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
},
-
"embed": {
-
"type": "union",
-
"description": "Embedded content - images, videos, external links, or quoted posts",
-
"refs": [
-
"social.coves.embed.images",
-
"social.coves.embed.video",
-
"social.coves.embed.external",
-
"social.coves.embed.post"
-
]
-
},
-
"originalAuthor": {
-
"type": "ref",
-
"ref": "#originalAuthor",
-
"description": "For microblog posts - information about the original author from federated platform"
-
},
-
"contentLabels": {
-
"type": "array",
-
"description": "Self-applied content labels",
-
"items": {
-
"type": "string",
-
"knownValues": ["nsfw", "spoiler", "violence"],
-
"maxLength": 32
-
}
-
},
-
"federatedFrom": {
-
"type": "ref",
-
"ref": "social.coves.federation.post",
-
"description": "Reference to original federated post (if applicable)"
-
},
-
"location": {
-
"type": "ref",
-
"ref": "social.coves.actor.profile#geoLocation",
-
"description": "Geographic location where post was created"
-
},
-
"crosspostOf": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "If this is a crosspost, AT-URI of the post this is a crosspost of"
-
},
-
"crosspostChain": {
-
"type": "array",
-
"description": "Array of AT-URIs of all posts in the crosspost chain (including this one)",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
},
-
"originalAuthor": {
-
"type": "object",
-
"description": "Information about the original author from a federated platform",
-
"required": ["handle"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did",
-
"description": "Original author's DID (if available)"
-
},
-
"handle": {
-
"type": "string",
-
"maxLength": 253,
-
"description": "Original author's handle"
-
},
-
"displayName": {
-
"type": "string",
-
"maxLength": 640,
-
"description": "Original author's display name"
-
},
-
"avatar": {
-
"type": "string",
-
"format": "uri",
-
"description": "URL to original author's avatar"
-
}
-
}
-
}
-
}
-
}
+2 -2
internal/atproto/lexicon/social/coves/post/search.json internal/atproto/lexicon/social/coves/community/post/search.json
···
{
"lexicon": 1,
-
"id": "social.coves.post.search",
+
"id": "social.coves.community.post.search",
"defs": {
"main": {
"type": "query",
···
"type": "array",
"items": {
"type": "ref",
-
"ref": "social.coves.post.getFeed#feedPost"
+
"ref": "social.coves.feed.defs#feedViewPost"
}
},
"cursor": {
+20 -5
internal/atproto/lexicon/social/coves/post/update.json internal/atproto/lexicon/social/coves/community/post/update.json
···
{
"lexicon": 1,
-
"id": "social.coves.post.update",
+
"id": "social.coves.community.post.update",
"defs": {
"main": {
"type": "procedure",
···
"social.coves.embed.post"
]
},
-
"contentLabels": {
+
"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 content labels",
+
"description": "Updated topic tags",
+
"maxLength": 8,
"items": {
"type": "string",
-
"knownValues": ["nsfw", "spoiler", "violence"],
-
"maxLength": 32
+
"maxLength": 64,
+
"maxGraphemes": 64
}
},
"editNote": {
+20 -7
internal/core/posts/post.go
···
"time"
)
+
// SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels
+
// This is the structured format used in atProto for content warnings
+
type SelfLabels struct {
+
Values []SelfLabel `json:"values"`
+
}
+
+
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
+
// Neg is optional and negates the label when true
+
type SelfLabel struct {
+
Val string `json:"val"` // Required: label value (max 128 chars)
+
Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
+
}
+
// Post represents a post in the AppView database
// Posts are indexed from the firehose after being written to community repositories
type Post struct {
···
EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"`
Embed *string `json:"embed,omitempty" db:"embed"`
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
-
ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"`
+
ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
Title *string `json:"title,omitempty" db:"title"`
Content *string `json:"content,omitempty" db:"content"`
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
···
}
// CreatePostRequest represents input for creating a new post
-
// Matches social.coves.post.create lexicon input schema
+
// Matches social.coves.community.post.create lexicon input schema
type CreatePostRequest struct {
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
···
Community string `json:"community"`
AuthorDID string `json:"authorDid"`
Facets []interface{} `json:"facets,omitempty"`
-
ContentLabels []string `json:"contentLabels,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
}
// CreatePostResponse represents the response from creating a post
-
// Matches social.coves.post.create lexicon output schema
-
type CreatePostResponse struct {
+
// Matches social.coves.community.post.create lexicon output schema
+
type CreatePostResponse struct{
URI string `json:"uri"` // AT-URI of created post
CID string `json:"cid"` // CID of created post
}
···
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
-
ContentLabels []string `json:"contentLabels,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
}
// PostView represents the full view of a post with all metadata
-
// Matches social.coves.post.get#postView lexicon
+
// Matches social.coves.community.post.get#postView lexicon
// Used in feeds and get endpoints
type PostView struct {
IndexedAt time.Time `json:"indexedAt"`
+19 -17
internal/core/posts/service.go
···
// 8. Build post record for PDS
postRecord := PostRecord{
-
Type: "social.coves.post.record",
+
Type: "social.coves.community.post",
Community: communityDID,
Author: req.AuthorDID,
Title: req.Title,
Content: req.Content,
Facets: req.Facets,
Embed: req.Embed,
-
ContentLabels: req.ContentLabels,
+
Labels: req.Labels,
OriginalAuthor: req.OriginalAuthor,
FederatedFrom: req.FederatedFrom,
Location: req.Location,
···
func (s *postService) validateCreateRequest(req CreatePostRequest) error {
// Global content limits (from lexicon)
const (
-
maxContentLength = 50000 // 50k characters
-
maxTitleLength = 3000 // 3k bytes
-
maxTitleGraphemes = 300 // 300 graphemes (simplified check)
+
maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon
+
maxTitleLength = 3000 // 3k bytes
+
maxTitleGraphemes = 300 // 300 graphemes (simplified check)
)
// Validate community required
···
}
// Validate content labels are from known values
-
validLabels := map[string]bool{
-
"nsfw": true,
-
"spoiler": true,
-
"violence": true,
-
}
-
for _, label := range req.ContentLabels {
-
if !validLabels[label] {
-
return NewValidationError("contentLabels",
-
fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label))
+
if req.Labels != nil {
+
validLabels := map[string]bool{
+
"nsfw": true,
+
"spoiler": true,
+
"violence": true,
+
}
+
for _, label := range req.Labels.Values {
+
if !validLabels[label.Val] {
+
return NewValidationError("labels",
+
fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val))
+
}
}
}
···
// IMPORTANT: repo is set to community DID, not author DID
// This writes the post to the community's repository
payload := map[string]interface{}{
-
"repo": community.DID, // Community's repository
-
"collection": "social.coves.post.record", // Collection type
-
"record": record, // The post record
+
"repo": community.DID, // Community's repository
+
"collection": "social.coves.community.post", // Collection type
+
"record": record, // The post record
// "rkey" omitted - PDS will auto-generate TID
}
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
···
+
-- +goose Up
+
-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure
+
-- This allows storing the optional 'neg' field and future extensions
+
+
-- Create temporary function to convert TEXT[] to selfLabels JSONB
+
-- +goose StatementBegin
+
CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[])
+
RETURNS JSONB AS $$
+
BEGIN
+
IF labels IS NULL OR array_length(labels, 1) = 0 THEN
+
RETURN NULL;
+
END IF;
+
+
RETURN jsonb_build_object(
+
'values',
+
(SELECT jsonb_agg(jsonb_build_object('val', label))
+
FROM unnest(labels) AS label)
+
);
+
END;
+
$$ LANGUAGE plpgsql IMMUTABLE;
+
-- +goose StatementEnd
+
+
-- Convert column type using the function
+
ALTER TABLE posts
+
ALTER COLUMN content_labels TYPE JSONB
+
USING convert_labels_to_jsonb(content_labels);
+
+
-- Drop the temporary function
+
DROP FUNCTION convert_labels_to_jsonb(TEXT[]);
+
+
-- Update column comment
+
COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+
+
-- +goose Down
+
-- Revert JSONB back to TEXT[] (lossy - drops 'neg' field)
+
ALTER TABLE posts
+
ALTER COLUMN content_labels TYPE TEXT[]
+
USING CASE
+
WHEN content_labels IS NULL THEN NULL
+
ELSE ARRAY(
+
SELECT value->>'val'
+
FROM jsonb_array_elements(content_labels->'values') AS value
+
)
+
END;
+
+
-- Restore original comment
+
COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)';
+11 -8
internal/db/postgres/feed_repo.go
···
"strconv"
"strings"
"time"
-
-
"github.com/lib/pq"
)
type postgresFeedRepo struct {
···
communityRef posts.CommunityRef
title, content sql.NullString
facets, embed sql.NullString
-
labels pq.StringArray
+
labelsJSON sql.NullString
editedAt sql.NullTime
communityAvatar sql.NullString
hotRank sql.NullFloat64
···
&postView.URI, &postView.CID, &postView.RKey,
&authorView.DID, &authorView.Handle,
&communityRef.DID, &communityRef.Name, &communityAvatar,
-
&title, &content, &facets, &embed, &labels,
+
&title, &content, &facets, &embed, &labelsJSON,
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
&hotRank,
···
// Alpha: No viewer state for basic feed
// TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton
-
// Build the record (required by lexicon - social.coves.post.record structure)
+
// Build the record (required by lexicon - social.coves.community.post structure)
record := map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": communityRef.DID,
"author": authorView.DID,
"createdAt": postView.CreatedAt.Format(time.RFC3339),
···
record["embed"] = embedData
}
}
-
if len(labels) > 0 {
-
record["contentLabels"] = labels
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
// Deserialize and include in record
+
var selfLabels posts.SelfLabels
+
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
+
record["labels"] = selfLabels
+
}
}
postView.Record = record
+10 -7
internal/db/postgres/feed_repo_base.go
···
"strconv"
"strings"
"time"
-
-
"github.com/lib/pq"
)
// feedRepoBase contains shared logic for timeline and discover feed repositories
···
communityRef posts.CommunityRef
title, content sql.NullString
facets, embed sql.NullString
-
labels pq.StringArray
+
labelsJSON sql.NullString
editedAt sql.NullTime
communityAvatar sql.NullString
hotRank sql.NullFloat64
···
&postView.URI, &postView.CID, &postView.RKey,
&authorView.DID, &authorView.Handle,
&communityRef.DID, &communityRef.Name, &communityAvatar,
-
&title, &content, &facets, &embed, &labels,
+
&title, &content, &facets, &embed, &labelsJSON,
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
&hotRank,
···
// Build the record (required by lexicon)
record := map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": communityRef.DID,
"author": authorView.DID,
"createdAt": postView.CreatedAt.Format(time.RFC3339),
···
record["embed"] = embedData
}
}
-
if len(labels) > 0 {
-
record["contentLabels"] = labels
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
// Deserialize and include in record
+
var selfLabels posts.SelfLabels
+
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
+
record["labels"] = selfLabels
+
}
}
postView.Record = record
+12 -20
internal/db/postgres/post_repo.go
···
"Coves/internal/core/posts"
"context"
"database/sql"
-
"encoding/json"
"fmt"
"strings"
-
-
"github.com/lib/pq"
)
type postgresPostRepo struct {
···
embedJSON.Valid = true
}
-
// Convert content labels to PostgreSQL array
-
var labelsArray pq.StringArray
+
// Store content labels as JSONB
+
// post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]}
+
// Store the full JSON blob to preserve the 'neg' field and future extensions
+
var labelsJSON sql.NullString
if post.ContentLabels != nil {
-
// Parse JSON array string to []string
-
var labels []string
-
if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil {
-
labelsArray = labels
-
}
+
labelsJSON.String = *post.ContentLabels
+
labelsJSON.Valid = true
}
query := `
···
err := r.db.QueryRowContext(
ctx, query,
post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID,
-
post.Title, post.Content, facetsJSON, embedJSON, labelsArray,
+
post.Title, post.Content, facetsJSON, embedJSON, labelsJSON,
post.CreatedAt,
).Scan(&post.ID, &post.IndexedAt)
if err != nil {
···
`
var post posts.Post
-
var facetsJSON, embedJSON sql.NullString
-
var contentLabels pq.StringArray
+
var facetsJSON, embedJSON, labelsJSON sql.NullString
err := r.db.QueryRowContext(ctx, query, uri).Scan(
&post.ID, &post.URI, &post.CID, &post.RKey,
&post.AuthorDID, &post.CommunityDID,
-
&post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels,
+
&post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON,
&post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt,
&post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount,
)
···
if embedJSON.Valid {
post.Embed = &embedJSON.String
}
-
if len(contentLabels) > 0 {
-
labelsJSON, marshalErr := json.Marshal(contentLabels)
-
if marshalErr == nil {
-
labelsStr := string(labelsJSON)
-
post.ContentLabels = &labelsStr
-
}
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
post.ContentLabels = &labelsJSON.String
}
return &post, nil
+11 -11
internal/db/postgres/vote_repo_test.go
···
CID: "bafyreigtest123",
RKey: "3k1234567890",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/abc123",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/abc123",
SubjectCID: "bafyreigpost123",
Direction: "up",
CreatedAt: time.Now(),
···
CID: "bafyreigtest456",
RKey: "3k9876543210",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/xyz789",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/xyz789",
SubjectCID: "bafyreigpost456",
Direction: "down",
CreatedAt: time.Now(),
···
CID: "bafyreignovoter",
RKey: "3k1111111111",
VoterDID: "did:plc:nonexistentvoter",
-
SubjectURI: "at://did:plc:community/social.coves.post.record/test123",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/test123",
SubjectCID: "bafyreigpost789",
Direction: "up",
CreatedAt: time.Now(),
···
CID: "bafyreigtest789",
RKey: "3k5555555555",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/post123",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/post123",
SubjectCID: "bafyreigpost999",
Direction: "up",
CreatedAt: time.Now(),
···
voterDID := "did:plc:testvoter999"
createTestUser(t, db, "testvoter999.test", voterDID)
-
subjectURI := "at://did:plc:community/social.coves.post.record/subject123"
+
subjectURI := "at://did:plc:community/social.coves.community.post/subject123"
// Create vote
vote := &votes.Vote{
···
repo := NewVoteRepository(db)
ctx := context.Background()
-
_, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.post.record/nopost")
+
_, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.community.post/nopost")
assert.ErrorIs(t, err, votes.ErrVoteNotFound)
}
···
CID: "bafyreigdelete",
RKey: "3k7777777777",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest",
SubjectCID: "bafyreigdeletepost",
Direction: "up",
CreatedAt: time.Now(),
···
CID: "bafyreigdelete2",
RKey: "3k8888888888",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/deletetest2",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest2",
SubjectCID: "bafyreigdeletepost2",
Direction: "down",
CreatedAt: time.Now(),
···
createTestUser(t, db, "testvoterlist1.test", voterDID1)
createTestUser(t, db, "testvoterlist2.test", voterDID2)
-
subjectURI := "at://did:plc:community/social.coves.post.record/listtest"
+
subjectURI := "at://did:plc:community/social.coves.community.post/listtest"
// Create multiple votes on same subject
vote1 := &votes.Vote{
···
CID: "bafyreigvoter1",
RKey: "3k0000000001",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/post1",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/post1",
SubjectCID: "bafyreigp1",
Direction: "up",
CreatedAt: time.Now(),
···
CID: "bafyreigvoter2",
RKey: "3k0000000002",
VoterDID: voterDID,
-
SubjectURI: "at://did:plc:community/social.coves.post.record/post2",
+
SubjectURI: "at://did:plc:community/social.coves.community.post/post2",
SubjectCID: "bafyreigp2",
Direction: "down",
CreatedAt: time.Now(),
+3 -3
internal/validation/lexicon.go
···
// 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
···
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)
}
+7 -3
internal/validation/lexicon_test.go
···
// 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",
···
// 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",
···
// 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"
}`
+8 -8
tests/integration/aggregator_e2e_test.go
···
reqJSON, err := json.Marshal(reqBody)
require.NoError(t, err)
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
// Create JWT for aggregator (not a user)
···
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
-
Collection: "social.coves.post.record",
+
Collection: "social.coves.community.post",
RKey: rkey,
CID: response.CID,
Record: map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": communityDID,
"author": aggregatorDID, // Aggregator is the author
"title": title,
···
reqJSON, err := json.Marshal(reqBody)
require.NoError(t, err)
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
···
reqJSON, err := json.Marshal(reqBody)
require.NoError(t, err)
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
···
reqJSON, err = json.Marshal(reqBody)
require.NoError(t, err)
-
req = httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req = httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
···
reqJSON, err := json.Marshal(reqBody)
require.NoError(t, err)
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(unauthorizedAggDID))
···
reqJSON, err := json.Marshal(reqBody)
require.NoError(t, err)
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+createSimpleTestJWT(aggregatorDID))
+4 -4
tests/integration/aggregator_test.go
···
})
t.Run("records aggregator post for rate limiting", func(t *testing.T) {
-
postURI := fmt.Sprintf("at://%s/social.coves.post.record/post1", communityDID)
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/post1", communityDID)
err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123")
if err != nil {
···
t.Run("allows posts within rate limit", func(t *testing.T) {
// Create 9 posts (under the 10/hour limit)
for i := 0; i < 9; i++ {
-
postURI := fmt.Sprintf("at://%s/social.coves.post.record/post%d", communityDID, i)
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/post%d", communityDID, i)
if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil {
t.Fatalf("Failed to record post %d: %v", i, err)
}
···
t.Run("enforces rate limit at 10 posts/hour", func(t *testing.T) {
// Add one more post to hit the limit (total = 10)
-
postURI := fmt.Sprintf("at://%s/social.coves.post.record/post10", communityDID)
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/post10", communityDID)
if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil {
t.Fatalf("Failed to record 10th post: %v", err)
}
···
// Record 5 posts
for i := 0; i < 5; i++ {
-
postURI := fmt.Sprintf("at://%s/social.coves.post.record/triggerpost%d", communityDID, i)
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/triggerpost%d", communityDID, i)
if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil {
t.Fatalf("Failed to record post %d: %v", i, err)
}
+1 -1
tests/integration/feed_test.go
···
assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
record, ok := feedPost.Post.Record.(map[string]interface{})
require.True(t, ok, "Record should be a map")
-
assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type")
+
assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type")
assert.NotEmpty(t, record["community"], "Record should have community")
assert.NotEmpty(t, record["author"], "Record should have author")
assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
+1 -1
tests/integration/helpers.go
···
// Generate URI
rkey := fmt.Sprintf("post-%d", time.Now().UnixNano())
-
uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", communityDID, rkey)
+
uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey)
// Insert post
_, err := db.ExecContext(ctx, `
+41 -21
tests/integration/post_creation_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/core/communities"
"Coves/internal/core/posts"
···
// This will fail at token refresh step (expected for unit test)
// We're using a fake token that can't be parsed
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
// For now, we expect an error because token is fake
// In a full E2E test with real PDS, this would succeed
···
// Should resolve handle to DID and proceed
// Will still fail at token refresh (expected with fake token)
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
// Should fail at token refresh, not community resolution
assert.Contains(t, err.Error(), "failed to refresh community credentials")
···
// Should resolve handle to DID and proceed
// Will still fail at token refresh (expected with fake token)
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
// Should fail at token refresh, not community resolution
assert.Contains(t, err.Error(), "failed to refresh community credentials")
···
AuthorDID: testUserDID,
}
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
assert.True(t, posts.IsValidationError(err))
})
···
AuthorDID: testUserDID,
}
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
// Should fail with community not found (wrapped in error)
assert.Contains(t, err.Error(), "community not found")
···
AuthorDID: "", // Missing!
}
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
assert.True(t, posts.IsValidationError(err))
assert.Contains(t, err.Error(), "authorDid")
···
AuthorDID: testUserDID,
}
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
assert.Equal(t, posts.ErrCommunityNotFound, err)
})
···
AuthorDID: testUserDID,
}
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
assert.True(t, posts.IsValidationError(err))
assert.Contains(t, err.Error(), "too long")
···
content := "Post with invalid label"
req := posts.CreatePostRequest{
-
Community: testCommunity.DID,
-
Content: &content,
-
ContentLabels: []string{"invalid_label"}, // Not in known values!
-
AuthorDID: testUserDID,
+
Community: testCommunity.DID,
+
Content: &content,
+
Labels: &posts.SelfLabels{
+
Values: []posts.SelfLabel{
+
{Val: "invalid_label"}, // Not in known values!
+
},
+
},
+
AuthorDID: testUserDID,
}
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
assert.True(t, posts.IsValidationError(err))
assert.Contains(t, err.Error(), "unknown content label")
···
content := "Post with valid labels"
req := posts.CreatePostRequest{
-
Community: testCommunity.DID,
-
Content: &content,
-
ContentLabels: []string{"nsfw", "spoiler"},
-
AuthorDID: testUserDID,
+
Community: testCommunity.DID,
+
Content: &content,
+
Labels: &posts.SelfLabels{
+
Values: []posts.SelfLabel{
+
{Val: "nsfw"},
+
{Val: "spoiler"},
+
},
+
},
+
AuthorDID: testUserDID,
}
// Will fail at token refresh (expected with fake token)
-
_, err := postService.CreatePost(ctx, req)
+
authCtx := middleware.SetTestUserDID(ctx, testUserDID)
+
_, err := postService.CreatePost(authCtx, req)
require.Error(t, err)
// Should fail at token refresh, not validation
assert.Contains(t, err.Error(), "failed to refresh community credentials")
···
title := "Test Title"
post := &posts.Post{
-
URI: "at://" + testCommunityDID + "/social.coves.post.record/test123",
+
URI: "at://" + testCommunityDID + "/social.coves.community.post/test123",
CID: "bafy2test123",
RKey: "test123",
AuthorDID: testUserDID,
···
content := "Duplicate post"
post1 := &posts.Post{
-
URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate",
+
URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate",
CID: "bafy2duplicate1",
RKey: "duplicate",
AuthorDID: testUserDID,
···
// Try to insert again with same URI
post2 := &posts.Post{
-
URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate",
+
URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate",
CID: "bafy2duplicate2",
RKey: "duplicate",
AuthorDID: testUserDID,
+15 -15
tests/integration/post_e2e_test.go
···
// XRPC endpoint → AppView Service → PDS write → Jetstream consumer → DB indexing
//
// This is a TRUE E2E test that simulates what happens in production:
-
// 1. Client calls POST /xrpc/social.coves.post.create with auth token
+
// 1. Client calls POST /xrpc/social.coves.community.post.create with auth token
// 2. Handler validates and calls PostService.CreatePost()
// 3. Service writes post to community's PDS repository
// 4. PDS broadcasts event to firehose/Jetstream
···
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
-
Collection: "social.coves.post.record",
+
Collection: "social.coves.community.post",
RKey: rkey,
CID: "bafy2bzaceabc123def456", // Fake CID
Record: map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": community.DID,
"author": author.DID,
"title": *postReq.Title,
···
}
// STEP 4: Verify post was indexed in AppView database
-
expectedURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey)
+
expectedURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
indexedPost, err := postRepo.GetByURI(ctx, expectedURI)
if err != nil {
t.Fatalf("Post not indexed in AppView: %v", err)
···
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
-
Collection: "social.coves.post.record",
+
Collection: "social.coves.community.post",
RKey: generateTID(),
CID: "bafy2bzacefake",
Record: map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": community.DID, // Claims to be for this community
"author": author.DID,
"title": "Fake Post",
···
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
-
Collection: "social.coves.post.record",
+
Collection: "social.coves.community.post",
RKey: rkey,
CID: "bafy2bzaceidempotent",
Record: map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": community.DID,
"author": author.DID,
"title": "Duplicate Test",
···
}
// Verify only one post in database
-
uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey)
+
uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
post, err := postRepo.GetByURI(ctx, uri)
if err != nil {
t.Fatalf("Post not found: %v", err)
···
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
-
Collection: "social.coves.post.record",
+
Collection: "social.coves.community.post",
RKey: generateTID(),
CID: "bafy2bzaceorphaned",
Record: map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": unknownCommunityDID,
"author": author.DID,
"title": "Orphaned Post",
···
}
// TestPostCreation_E2E_LivePDS tests the COMPLETE end-to-end flow with a live PDS:
-
// 1. HTTP POST to /xrpc/social.coves.post.create (with auth)
+
// 1. HTTP POST to /xrpc/social.coves.community.post.create (with auth)
// 2. Handler → Service → Write to community's PDS repository
// 3. PDS → Jetstream firehose event
// 4. Jetstream consumer → Index in AppView database
···
require.NoError(t, err)
// Create HTTP request
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
// Create a simple JWT for testing (Phase 1: no signature verification)
···
pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port
// Build Jetstream URL with filters for post records
-
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.post.record",
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.post",
pdsHostname)
t.Logf(" Jetstream URL: %s", jetstreamURL)
···
// Check if this is a post event for the target DID
if event.Did == targetDID && event.Kind == "commit" &&
-
event.Commit != nil && event.Commit.Collection == "social.coves.post.record" {
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.post" {
// Process the event through the consumer
if err := consumer.HandleEvent(ctx, &event); err != nil {
return fmt.Errorf("failed to process event: %w", err)
+13 -13
tests/integration/post_handler_test.go
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// No auth context set
rec := httptest.NewRecorder()
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
// Invalid JSON
invalidJSON := []byte(`{"community": "did:plc:test123", "content": `)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(invalidJSON))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(invalidJSON))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
// Mock authenticated user context
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
···
for _, method := range methods {
t.Run(method, func(t *testing.T) {
-
req := httptest.NewRequest(method, "/xrpc/social.coves.post.create", nil)
+
req := httptest.NewRequest(method, "/xrpc/social.coves.community.post.create", nil)
rec := httptest.NewRecorder()
handler.HandleCreate(rec, req)
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
req = req.WithContext(ctx)
···
}
body, _ := json.Marshal(payload)
-
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.post.create", bytes.NewReader(body))
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
req = req.WithContext(ctx)
+1 -1
tests/integration/timeline_test.go
···
assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
record, ok := feedPost.Post.Record.(map[string]interface{})
require.True(t, ok, "Record should be a map")
-
assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type")
+
assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type")
assert.NotEmpty(t, record["community"], "Record should have community")
assert.NotEmpty(t, record["author"], "Record should have author")
assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
+1 -1
tests/lexicon-test-data/actor/saved-invalid-type.json
···
{
"$type": "social.coves.actor.saved",
-
"subject": "at://did:plc:exampleuser/social.coves.post.record/3k7a3dmb5bk2c",
+
"subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c",
"type": "article",
"createdAt": "2025-01-09T14:30:00Z"
}
+1 -1
tests/lexicon-test-data/actor/saved-valid.json
···
{
"$type": "social.coves.actor.saved",
-
"subject": "at://did:plc:exampleuser/social.coves.post.record/3k7a3dmb5bk2c",
+
"subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c",
"type": "post",
"createdAt": "2025-01-09T14:30:00Z",
"note": "Great tutorial on Go concurrency patterns"
+12 -3
tests/lexicon-test-data/interaction/comment-invalid-content.json
···
{
-
"$type": "social.coves.interaction.comment",
-
"post": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c",
+
"$type": "social.coves.feed.comment",
+
"reply": {
+
"root": {
+
"uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
},
+
"parent": {
+
"uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
}
+
},
"createdAt": "2025-01-09T16:45:00Z"
-
}
+
}
+12 -7
tests/lexicon-test-data/interaction/comment-valid-sticker.json
···
{
-
"$type": "social.coves.interaction.comment",
-
"subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c",
-
"content": {
-
"$type": "social.coves.interaction.comment#stickerContent",
-
"stickerId": "thumbs-up",
-
"stickerPackId": "default-pack"
+
"$type": "social.coves.feed.comment",
+
"reply": {
+
"root": {
+
"uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
},
+
"parent": {
+
"uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
}
},
+
"content": "👍",
"createdAt": "2025-01-09T16:50:00Z"
-
}
+
}
+26 -20
tests/lexicon-test-data/interaction/comment-valid-text.json
···
{
-
"$type": "social.coves.interaction.comment",
-
"subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c",
-
"content": {
-
"$type": "social.coves.interaction.comment#textContent",
-
"text": "Great post! I especially liked the part about @alice.example.com's contribution to the project.",
-
"facets": [
-
{
-
"index": {
-
"byteStart": 46,
-
"byteEnd": 64
-
},
-
"features": [
-
{
-
"$type": "social.coves.richtext.facet#mention",
-
"did": "did:plc:aliceuser123"
-
}
-
]
-
}
-
]
+
"$type": "social.coves.feed.comment",
+
"reply": {
+
"root": {
+
"uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
},
+
"parent": {
+
"uri": "at://did:plc:test123/social.coves.community.post/3k7a3dmb5bk2c",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
}
},
+
"content": "Great post! I especially liked the part about @alice.example.com's contribution to the project.",
+
"facets": [
+
{
+
"index": {
+
"byteStart": 46,
+
"byteEnd": 64
+
},
+
"features": [
+
{
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:aliceuser123"
+
}
+
]
+
}
+
],
"createdAt": "2025-01-09T16:30:00Z"
-
}
+
}
+1 -1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
···
{
"$type": "social.coves.moderation.tribunalVote",
"tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c",
-
"subject": "at://did:plc:user123/social.coves.post.record/3k7a2clb4bj2b",
+
"subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b",
"decision": "maybe",
"createdAt": "2025-01-09T18:00:00Z"
}
+1 -1
tests/lexicon-test-data/moderation/tribunal-vote-valid.json
···
{
"$type": "social.coves.moderation.tribunalVote",
"tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c",
-
"subject": "at://did:plc:spammer123/social.coves.post.record/3k7a2clb4bj2b",
+
"subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b",
"decision": "remove",
"reasoning": "The moderator's action was justified based on clear violation of Rule 2 (No Spam). The user posted the same promotional content across multiple communities within a short timeframe.",
"precedents": [
+5 -5
tests/lexicon-test-data/post/post-invalid-enum-type.json
···
{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": "did:plc:programming123",
+
"author": "did:plc:testauthor123",
"postType": "invalid-type",
"title": "This has an invalid post type",
-
"text": "The postType field has an invalid value",
+
"content": "The postType field is not defined in the schema and should be rejected",
"tags": [],
-
"language": "en",
-
"contentWarnings": [],
+
"langs": ["en"],
"createdAt": "2025-01-09T14:30:00Z"
-
}
+
}
+5 -6
tests/lexicon-test-data/post/post-invalid-missing-community.json
···
{
-
"$type": "social.coves.post.record",
-
"postType": "text",
+
"$type": "social.coves.community.post",
+
"author": "did:plc:testauthor123",
"title": "Test Post",
-
"text": "This post is missing the required community field",
+
"content": "This post is missing the required community field",
"tags": ["test"],
-
"language": "en",
-
"contentWarnings": [],
+
"langs": ["en"],
"createdAt": "2025-01-09T14:30:00Z"
-
}
+
}
+6 -7
tests/lexicon-test-data/post/post-valid-text.json
···
{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": "did:plc:programming123",
-
"postType": "text",
+
"author": "did:plc:testauthor123",
"title": "Best practices for error handling in Go",
-
"text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
-
"textFacets": [
+
"content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
+
"facets": [
{
"index": {
"byteStart": 20,
···
}
],
"tags": ["golang", "error-handling", "best-practices"],
-
"language": "en",
-
"contentWarnings": [],
+
"langs": ["en"],
"createdAt": "2025-01-09T14:30:00Z"
-
}
+
}
+10 -4
tests/lexicon_validation_test.go
···
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)
}
···
},
{
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",
···
},
{
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",