···291291- [ ] Go structs: `ContentRules` type in community models
292292- [ ] Repository: Parse and store `contentRules` from community profiles
293293- [ ] Service: `ValidatePostAgainstRules(post, community)` function
294294-- [ ] Handler: Integrate validation into `social.coves.post.create`
294294+- [ ] Handler: Integrate validation into `social.coves.community.post.create`
295295- [ ] AppView indexing: Index post characteristics (embed_type, text_length, etc.)
296296- [ ] Tests: Comprehensive rule validation tests
297297- [ ] Documentation: Content rules guide for community creators
+17-17
docs/PRD_POSTS.md
···45454646**Repository Structure:**
4747```
4848-Repository: at://did:plc:community789/social.coves.post.record/3k2a4b5c6d7e
4848+Repository: at://did:plc:community789/social.coves.community.post.record/3k2a4b5c6d7e
4949Owner: did:plc:community789 (community owns the post)
5050Author: did:plc:user123 (tracked in record metadata)
5151Hosted By: did:web:coves.social (instance manages community credentials)
···77777878**Implementation checklist:**
7979- [x] Lexicon: `contentRules` in `social.coves.community.profile` ✅
8080-- [x] Lexicon: `postType` removed from `social.coves.post.create` ✅
8080+- [x] Lexicon: `postType` removed from `social.coves.community.post.create` ✅
8181- [ ] Validation: `ValidatePostAgainstRules()` service function
8282- [ ] Handler: Integrate validation in post creation endpoint
8383- [ ] AppView: Index derived characteristics (embed_type, text_length, etc.)
···9090**Priority:** CRITICAL - Posts are the foundation of the platform
91919292#### Create Post
9393-- [x] Lexicon: `social.coves.post.record` ✅
9494-- [x] Lexicon: `social.coves.post.create` ✅
9393+- [x] Lexicon: `social.coves.community.post.record` ✅
9494+- [x] Lexicon: `social.coves.community.post.create` ✅
9595- [x] Removed `postType` enum in favor of content rules ✅ (2025-10-18)
9696- [x] Removed `postType` from record and get lexicons ✅ (2025-10-18)
9797-- [x] **Handler:** `POST /xrpc/social.coves.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md)
9797+- [x] **Handler:** `POST /xrpc/social.coves.community.post.create` ✅ (Alpha - see IMPLEMENTATION_POST_CREATION.md)
9898 - ✅ Accept: community (DID/handle), title (optional), content, facets, embed, contentLabels
9999 - ✅ Validate: User is authenticated, community exists, content within limits
100100 - ✅ Write: Create record in **community's PDS repository**
···124124- [x] **E2E Test:** Create text post → Write to **community's PDS** → Index via Jetstream → Verify in AppView ✅
125125126126#### Get Post
127127-- [x] Lexicon: `social.coves.post.get` ✅
128128-- [ ] **Handler:** `GET /xrpc/social.coves.post.get?uri=at://...`
127127+- [x] Lexicon: `social.coves.community.post.get` ✅
128128+- [ ] **Handler:** `GET /xrpc/social.coves.community.post.get?uri=at://...`
129129 - Accept: AT-URI of post
130130 - Return: Full post view with author, community, stats, viewer state
131131- [ ] **Service Layer:** `PostService.Get(uri, viewerDID)`
···139139- [ ] **E2E Test:** Get post by URI → Verify all fields populated
140140141141#### Update Post
142142-- [x] Lexicon: `social.coves.post.update` ✅
143143-- [ ] **Handler:** `POST /xrpc/social.coves.post.update`
142142+- [x] Lexicon: `social.coves.community.post.update` ✅
143143+- [ ] **Handler:** `POST /xrpc/social.coves.community.post.update`
144144 - Accept: uri, title, content, facets, embed, contentLabels, editNote
145145 - Validate: User is post author, within 24-hour edit window
146146 - Write: Update record in **community's PDS**
···157157- [ ] **E2E Test:** Update post → Verify edit reflected in AppView
158158159159#### Delete Post
160160-- [x] Lexicon: `social.coves.post.delete` ✅
161161-- [ ] **Handler:** `POST /xrpc/social.coves.post.delete`
160160+- [x] Lexicon: `social.coves.community.post.delete` ✅
161161+- [ ] **Handler:** `POST /xrpc/social.coves.community.post.delete`
162162 - Accept: uri
163163 - Validate: User is post author OR community moderator
164164 - Write: Delete record from **community's PDS**
···251251252252#### Post Event Handling
253253- [x] **Consumer:** `PostConsumer.HandlePostEvent()` ✅ (2025-10-19)
254254- - ✅ Listen for `social.coves.post.record` CREATE from **community repositories**
254254+ - ✅ Listen for `social.coves.community.post.record` CREATE from **community repositories**
255255 - ✅ Parse post record, extract author DID and community DID (from AT-URI owner)
256256 - ⚠️ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering)
257257 - ✅ Insert in AppView PostgreSQL (CREATE only - UPDATE/DELETE deferred)
···447447- [ ] **Tag Storage:** Tags live in **user's repository** (users own their tags)
448448449449#### Crossposting
450450-- [x] Lexicon: `social.coves.post.crosspost` ✅
450450+- [x] Lexicon: `social.coves.community.post.crosspost` ✅
451451- [ ] **Crosspost Tracking:** Share post to multiple communities
452452- [ ] **Implementation:** Create new post record in each community's repository
453453- [ ] **Crosspost Chain:** Track all crosspost relationships
···461461- [ ] **AppView Query:** Endpoint to fetch user's saved posts
462462463463### Post Search
464464-- [x] Lexicon: `social.coves.post.search` ✅
464464+- [x] Lexicon: `social.coves.community.post.search` ✅
465465- [ ] **Search Parameters:**
466466 - Query string (q)
467467 - Filter by community
···583583- **Reuses Token Refresh:** Can leverage existing community credential management
584584585585**Implementation Details:**
586586-- Post AT-URI: `at://community_did/social.coves.post.record/tid`
586586+- Post AT-URI: `at://community_did/social.coves.community.post.record/tid`
587587- Write operations use community's PDS credentials (encrypted, stored in AppView)
588588- Author tracked in post record's `author` field (DID)
589589- Moderators can delete any post in their community
···756756757757## Lexicon Summary
758758759759-### `social.coves.post.record`
759759+### `social.coves.community.post.record`
760760**Status:** ✅ Defined, implementation TODO
761761**Last Updated:** 2025-10-18 (removed `postType` enum)
762762···781781- Post "type" is derived from structure (has embed? what embed type? has title? text length?)
782782- Community's `contentRules` validate post structure at creation time
783783784784-### `social.coves.post.create` (Procedure)
784784+### `social.coves.community.post.create` (Procedure)
785785**Status:** ✅ Defined, implementation TODO
786786**Last Updated:** 2025-10-18 (removed `postType` parameter)
787787
+4-4
docs/aggregators/PRD_AGGREGATORS.md
···23231. **Aggregators are Actors, Not a Separate System**
2424 - Each aggregator has its own DID
2525 - Authenticate as themselves via JWT
2626- - Use existing `social.coves.post.create` endpoint
2626+ - Use existing `social.coves.community.post.create` endpoint
2727 - Post record's `author` field = aggregator DID (server-populated)
2828 - No separate posting API needed
2929···8989Aggregator Service (External)
9090 │
9191 │ 1. Authenticates as aggregator DID (JWT)
9292- │ 2. Calls social.coves.post.create
9292+ │ 2. Calls social.coves.community.post.create
9393 ▼
9494Coves AppView Handler
9595 │
···120120121121### For Aggregators
122122123123-- **`social.coves.post.create`** - Modified to handle aggregator auth
123123+- **`social.coves.community.post.create`** - Modified to handle aggregator auth
124124- **`social.coves.aggregator.getAuthorizations`** - Query authorized communities
125125126126### For Discovery
···312312313313---
314314315315-### 2025-10-19: Reuse `social.coves.post.create` Endpoint
315315+### 2025-10-19: Reuse `social.coves.community.post.create` Endpoint
316316**Decision:** Aggregators use existing post creation endpoint.
317317318318**Rationale:**
+3-3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
···172172│ 3. Deduplication: Tracks posted items via JSON state file │
173173│ 4. Feed Mapper: Maps feed URLs to community handles │
174174│ 5. Post Formatter: Converts to Coves post format │
175175-│ 6. Post Publisher: Calls social.coves.post.create via XRPC │
175175+│ 6. Post Publisher: Calls social.coves.community.post.create via XRPC │
176176│ 7. Blob Uploader: Handles image upload to ATProto │
177177└─────────────────────────────────────────────────────────────┘
178178 │
179179 │ Authenticated XRPC calls
180180 ▼
181181┌─────────────────────────────────────────────────────────────┐
182182-│ Coves AppView (social.coves.post.create) │
182182+│ Coves AppView (social.coves.community.post.create) │
183183│ - Validates aggregator authorization │
184184│ - Creates post with author = did:plc:[aggregator-did] │
185185│ - Indexes to community feeds │
···271271272272```json
273273{
274274- "$type": "social.coves.post.record",
274274+ "$type": "social.coves.community.post.record",
275275 "author": "did:plc:[aggregator-did]",
276276 "community": "world-news.coves.social",
277277 "title": "{Kagi story title}",
+1-1
internal/api/handlers/post/create.go
···2121 }
2222}
23232424-// HandleCreate handles POST /xrpc/social.coves.post.create
2424+// HandleCreate handles POST /xrpc/social.coves.community.post.create
2525// Creates a new post in a community's repository
2626func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
2727 // 1. Check HTTP method
+7-7
internal/api/routes/post.go
···99)
10101111// RegisterPostRoutes registers post-related XRPC endpoints on the router
1212-// Implements social.coves.post.* lexicon endpoints
1212+// Implements social.coves.community.post.* lexicon endpoints
1313func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {
1414 // Initialize handlers
1515 createHandler := post.NewCreateHandler(service)
16161717 // Procedure endpoints (POST) - require authentication
1818- // social.coves.post.create - create a new post in a community
1919- r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.create", createHandler.HandleCreate)
1818+ // social.coves.community.post.create - create a new post in a community
1919+ r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate)
20202121 // Future endpoints (Beta):
2222- // r.Get("/xrpc/social.coves.post.get", getHandler.HandleGet)
2323- // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.update", updateHandler.HandleUpdate)
2424- // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.delete", deleteHandler.HandleDelete)
2525- // r.Get("/xrpc/social.coves.post.list", listHandler.HandleList)
2222+ // r.Get("/xrpc/social.coves.community.post.get", getHandler.HandleGet)
2323+ // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.update", updateHandler.HandleUpdate)
2424+ // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.delete", deleteHandler.HandleDelete)
2525+ // r.Get("/xrpc/social.coves.community.post.list", listHandler.HandleList)
2626}
+11-11
internal/atproto/jetstream/post_consumer.go
···1313)
14141515// PostEventConsumer consumes post-related events from Jetstream
1616-// Currently handles only CREATE operations for social.coves.post.record
1616+// Currently handles only CREATE operations for social.coves.community.post
1717// UPDATE and DELETE handlers will be added when those features are implemented
1818-type PostEventConsumer struct {
1818+type PostEventConsumer struct{
1919 postRepo posts.Repository
2020 communityRepo communities.Repository
2121 userService users.UserService
···46464747 // Only handle post record creation for now
4848 // UPDATE and DELETE will be added when we implement those features
4949- if commit.Collection == "social.coves.post.record" && commit.Operation == "create" {
4949+ if commit.Collection == "social.coves.community.post" && commit.Operation == "create" {
5050 return c.createPost(ctx, event.Did, commit)
5151 }
5252···7373 }
74747575 // Build AT-URI for this post
7676- // Format: at://community_did/social.coves.post.record/rkey
7777- uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey)
7676+ // Format: at://community_did/social.coves.community.post/rkey
7777+ uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey)
78787979 // Parse timestamp from record
8080 createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt)
···119119 }
120120 }
121121122122- if len(postRecord.ContentLabels) > 0 {
123123- labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels)
122122+ if postRecord.Labels != nil {
123123+ labelsJSON, marshalErr := json.Marshal(postRecord.Labels)
124124 if marshalErr == nil {
125125 labelsStr := string(labelsJSON)
126126 post.ContentLabels = &labelsStr
···151151 // This prevents users from creating posts that appear to be from communities they don't control
152152 //
153153 // Example attack prevented:
154154- // - User creates post in their own repo (at://user_did/social.coves.post.record/xyz)
154154+ // - User creates post in their own repo (at://user_did/social.coves.community.post/xyz)
155155 // - Claims it's for community X (community field = community_did)
156156 // - Without this check, fake post would be indexed
157157 //
···199199}
200200201201// PostRecordFromJetstream represents a post record as received from Jetstream
202202-// Matches the structure written to PDS via social.coves.post.record
203203-type PostRecordFromJetstream struct {
202202+// Matches the structure written to PDS via social.coves.community.post
203203+type PostRecordFromJetstream struct{
204204 OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
205205 FederatedFrom interface{} `json:"federatedFrom,omitempty"`
206206 Location interface{} `json:"location,omitempty"`
···212212 Author string `json:"author"`
213213 CreatedAt string `json:"createdAt"`
214214 Facets []interface{} `json:"facets,omitempty"`
215215- ContentLabels []string `json:"contentLabels,omitempty"`
215215+ Labels *posts.SelfLabels `json:"labels,omitempty"`
216216}
217217218218// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
···44 "time"
55)
6677+// SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels
88+// This is the structured format used in atProto for content warnings
99+type SelfLabels struct {
1010+ Values []SelfLabel `json:"values"`
1111+}
1212+1313+// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
1414+// Neg is optional and negates the label when true
1515+type SelfLabel struct {
1616+ Val string `json:"val"` // Required: label value (max 128 chars)
1717+ Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
1818+}
1919+720// Post represents a post in the AppView database
821// Posts are indexed from the firehose after being written to community repositories
922type Post struct {
···1225 EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"`
1326 Embed *string `json:"embed,omitempty" db:"embed"`
1427 DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
1515- ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"`
2828+ ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
1629 Title *string `json:"title,omitempty" db:"title"`
1730 Content *string `json:"content,omitempty" db:"content"`
1831 ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
···2942}
30433144// CreatePostRequest represents input for creating a new post
3232-// Matches social.coves.post.create lexicon input schema
4545+// Matches social.coves.community.post.create lexicon input schema
3346type CreatePostRequest struct {
3447 OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
3548 FederatedFrom interface{} `json:"federatedFrom,omitempty"`
···4053 Community string `json:"community"`
4154 AuthorDID string `json:"authorDid"`
4255 Facets []interface{} `json:"facets,omitempty"`
4343- ContentLabels []string `json:"contentLabels,omitempty"`
5656+ Labels *SelfLabels `json:"labels,omitempty"`
4457}
45584659// CreatePostResponse represents the response from creating a post
4747-// Matches social.coves.post.create lexicon output schema
4848-type CreatePostResponse struct {
6060+// Matches social.coves.community.post.create lexicon output schema
6161+type CreatePostResponse struct{
4962 URI string `json:"uri"` // AT-URI of created post
5063 CID string `json:"cid"` // CID of created post
5164}
···6477 Author string `json:"author"`
6578 CreatedAt string `json:"createdAt"`
6679 Facets []interface{} `json:"facets,omitempty"`
6767- ContentLabels []string `json:"contentLabels,omitempty"`
8080+ Labels *SelfLabels `json:"labels,omitempty"`
6881}
69827083// PostView represents the full view of a post with all metadata
7171-// Matches social.coves.post.get#postView lexicon
8484+// Matches social.coves.community.post.get#postView lexicon
7285// Used in feeds and get endpoints
7386type PostView struct {
7487 IndexedAt time.Time `json:"indexedAt"`
+19-17
internal/core/posts/service.go
···145145146146 // 8. Build post record for PDS
147147 postRecord := PostRecord{
148148- Type: "social.coves.post.record",
148148+ Type: "social.coves.community.post",
149149 Community: communityDID,
150150 Author: req.AuthorDID,
151151 Title: req.Title,
152152 Content: req.Content,
153153 Facets: req.Facets,
154154 Embed: req.Embed,
155155- ContentLabels: req.ContentLabels,
155155+ Labels: req.Labels,
156156 OriginalAuthor: req.OriginalAuthor,
157157 FederatedFrom: req.FederatedFrom,
158158 Location: req.Location,
···187187func (s *postService) validateCreateRequest(req CreatePostRequest) error {
188188 // Global content limits (from lexicon)
189189 const (
190190- maxContentLength = 50000 // 50k characters
191191- maxTitleLength = 3000 // 3k bytes
192192- maxTitleGraphemes = 300 // 300 graphemes (simplified check)
190190+ maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon
191191+ maxTitleLength = 3000 // 3k bytes
192192+ maxTitleGraphemes = 300 // 300 graphemes (simplified check)
193193 )
194194195195 // Validate community required
···219219 }
220220221221 // Validate content labels are from known values
222222- validLabels := map[string]bool{
223223- "nsfw": true,
224224- "spoiler": true,
225225- "violence": true,
226226- }
227227- for _, label := range req.ContentLabels {
228228- if !validLabels[label] {
229229- return NewValidationError("contentLabels",
230230- fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label))
222222+ if req.Labels != nil {
223223+ validLabels := map[string]bool{
224224+ "nsfw": true,
225225+ "spoiler": true,
226226+ "violence": true,
227227+ }
228228+ for _, label := range req.Labels.Values {
229229+ if !validLabels[label.Val] {
230230+ return NewValidationError("labels",
231231+ fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val))
232232+ }
231233 }
232234 }
233235···257259 // IMPORTANT: repo is set to community DID, not author DID
258260 // This writes the post to the community's repository
259261 payload := map[string]interface{}{
260260- "repo": community.DID, // Community's repository
261261- "collection": "social.coves.post.record", // Collection type
262262- "record": record, // The post record
262262+ "repo": community.DID, // Community's repository
263263+ "collection": "social.coves.community.post", // Collection type
264264+ "record": record, // The post record
263265 // "rkey" omitted - PDS will auto-generate TID
264266 }
265267
···11+-- +goose Up
22+-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure
33+-- This allows storing the optional 'neg' field and future extensions
44+55+-- Create temporary function to convert TEXT[] to selfLabels JSONB
66+-- +goose StatementBegin
77+CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[])
88+RETURNS JSONB AS $$
99+BEGIN
1010+ IF labels IS NULL OR array_length(labels, 1) = 0 THEN
1111+ RETURN NULL;
1212+ END IF;
1313+1414+ RETURN jsonb_build_object(
1515+ 'values',
1616+ (SELECT jsonb_agg(jsonb_build_object('val', label))
1717+ FROM unnest(labels) AS label)
1818+ );
1919+END;
2020+$$ LANGUAGE plpgsql IMMUTABLE;
2121+-- +goose StatementEnd
2222+2323+-- Convert column type using the function
2424+ALTER TABLE posts
2525+ ALTER COLUMN content_labels TYPE JSONB
2626+ USING convert_labels_to_jsonb(content_labels);
2727+2828+-- Drop the temporary function
2929+DROP FUNCTION convert_labels_to_jsonb(TEXT[]);
3030+3131+-- Update column comment
3232+COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
3333+3434+-- +goose Down
3535+-- Revert JSONB back to TEXT[] (lossy - drops 'neg' field)
3636+ALTER TABLE posts
3737+ ALTER COLUMN content_labels TYPE TEXT[]
3838+ USING CASE
3939+ WHEN content_labels IS NULL THEN NULL
4040+ ELSE ARRAY(
4141+ SELECT value->>'val'
4242+ FROM jsonb_array_elements(content_labels->'values') AS value
4343+ )
4444+ END;
4545+4646+-- Restore original comment
4747+COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)';
+11-8
internal/db/postgres/feed_repo.go
···1111 "strconv"
1212 "strings"
1313 "time"
1414-1515- "github.com/lib/pq"
1614)
17151816type postgresFeedRepo struct {
···329327 communityRef posts.CommunityRef
330328 title, content sql.NullString
331329 facets, embed sql.NullString
332332- labels pq.StringArray
330330+ labelsJSON sql.NullString
333331 editedAt sql.NullTime
334332 communityAvatar sql.NullString
335333 hotRank sql.NullFloat64
···339337 &postView.URI, &postView.CID, &postView.RKey,
340338 &authorView.DID, &authorView.Handle,
341339 &communityRef.DID, &communityRef.Name, &communityAvatar,
342342- &title, &content, &facets, &embed, &labels,
340340+ &title, &content, &facets, &embed, &labelsJSON,
343341 &postView.CreatedAt, &editedAt, &postView.IndexedAt,
344342 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
345343 &hotRank,
···386384 // Alpha: No viewer state for basic feed
387385 // TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton
388386389389- // Build the record (required by lexicon - social.coves.post.record structure)
387387+ // Build the record (required by lexicon - social.coves.community.post structure)
390388 record := map[string]interface{}{
391391- "$type": "social.coves.post.record",
389389+ "$type": "social.coves.community.post",
392390 "community": communityRef.DID,
393391 "author": authorView.DID,
394392 "createdAt": postView.CreatedAt.Format(time.RFC3339),
···413411 record["embed"] = embedData
414412 }
415413 }
416416- if len(labels) > 0 {
417417- record["contentLabels"] = labels
414414+ if labelsJSON.Valid {
415415+ // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
416416+ // Deserialize and include in record
417417+ var selfLabels posts.SelfLabels
418418+ if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
419419+ record["labels"] = selfLabels
420420+ }
418421 }
419422420423 postView.Record = record
+10-7
internal/db/postgres/feed_repo_base.go
···1212 "strconv"
1313 "strings"
1414 "time"
1515-1616- "github.com/lib/pq"
1715)
18161917// feedRepoBase contains shared logic for timeline and discover feed repositories
···283281 communityRef posts.CommunityRef
284282 title, content sql.NullString
285283 facets, embed sql.NullString
286286- labels pq.StringArray
284284+ labelsJSON sql.NullString
287285 editedAt sql.NullTime
288286 communityAvatar sql.NullString
289287 hotRank sql.NullFloat64
···293291 &postView.URI, &postView.CID, &postView.RKey,
294292 &authorView.DID, &authorView.Handle,
295293 &communityRef.DID, &communityRef.Name, &communityAvatar,
296296- &title, &content, &facets, &embed, &labels,
294294+ &title, &content, &facets, &embed, &labelsJSON,
297295 &postView.CreatedAt, &editedAt, &postView.IndexedAt,
298296 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
299297 &hotRank,
···339337340338 // Build the record (required by lexicon)
341339 record := map[string]interface{}{
342342- "$type": "social.coves.post.record",
340340+ "$type": "social.coves.community.post",
343341 "community": communityRef.DID,
344342 "author": authorView.DID,
345343 "createdAt": postView.CreatedAt.Format(time.RFC3339),
···364362 record["embed"] = embedData
365363 }
366364 }
367367- if len(labels) > 0 {
368368- record["contentLabels"] = labels
365365+ if labelsJSON.Valid {
366366+ // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
367367+ // Deserialize and include in record
368368+ var selfLabels posts.SelfLabels
369369+ if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
370370+ record["labels"] = selfLabels
371371+ }
369372 }
370373371374 postView.Record = record
+12-20
internal/db/postgres/post_repo.go
···44 "Coves/internal/core/posts"
55 "context"
66 "database/sql"
77- "encoding/json"
87 "fmt"
98 "strings"
1010-1111- "github.com/lib/pq"
129)
13101411type postgresPostRepo struct {
···3633 embedJSON.Valid = true
3734 }
38353939- // Convert content labels to PostgreSQL array
4040- var labelsArray pq.StringArray
3636+ // Store content labels as JSONB
3737+ // post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]}
3838+ // Store the full JSON blob to preserve the 'neg' field and future extensions
3939+ var labelsJSON sql.NullString
4140 if post.ContentLabels != nil {
4242- // Parse JSON array string to []string
4343- var labels []string
4444- if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil {
4545- labelsArray = labels
4646- }
4141+ labelsJSON.String = *post.ContentLabels
4242+ labelsJSON.Valid = true
4743 }
48444945 query := `
···6258 err := r.db.QueryRowContext(
6359 ctx, query,
6460 post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID,
6565- post.Title, post.Content, facetsJSON, embedJSON, labelsArray,
6161+ post.Title, post.Content, facetsJSON, embedJSON, labelsJSON,
6662 post.CreatedAt,
6763 ).Scan(&post.ID, &post.IndexedAt)
6864 if err != nil {
···10197 `
1029810399 var post posts.Post
104104- var facetsJSON, embedJSON sql.NullString
105105- var contentLabels pq.StringArray
100100+ var facetsJSON, embedJSON, labelsJSON sql.NullString
106101107102 err := r.db.QueryRowContext(ctx, query, uri).Scan(
108103 &post.ID, &post.URI, &post.CID, &post.RKey,
109104 &post.AuthorDID, &post.CommunityDID,
110110- &post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels,
105105+ &post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON,
111106 &post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt,
112107 &post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount,
113108 )
···126121 if embedJSON.Valid {
127122 post.Embed = &embedJSON.String
128123 }
129129- if len(contentLabels) > 0 {
130130- labelsJSON, marshalErr := json.Marshal(contentLabels)
131131- if marshalErr == nil {
132132- labelsStr := string(labelsJSON)
133133- post.ContentLabels = &labelsStr
134134- }
124124+ if labelsJSON.Valid {
125125+ // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
126126+ post.ContentLabels = &labelsJSON.String
135127 }
136128137129 return &post, nil
···544544 })
545545546546 t.Run("records aggregator post for rate limiting", func(t *testing.T) {
547547- postURI := fmt.Sprintf("at://%s/social.coves.post.record/post1", communityDID)
547547+ postURI := fmt.Sprintf("at://%s/social.coves.community.post/post1", communityDID)
548548549549 err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123")
550550 if err != nil {
···627627 t.Run("allows posts within rate limit", func(t *testing.T) {
628628 // Create 9 posts (under the 10/hour limit)
629629 for i := 0; i < 9; i++ {
630630- postURI := fmt.Sprintf("at://%s/social.coves.post.record/post%d", communityDID, i)
630630+ postURI := fmt.Sprintf("at://%s/social.coves.community.post/post%d", communityDID, i)
631631 if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil {
632632 t.Fatalf("Failed to record post %d: %v", i, err)
633633 }
···642642643643 t.Run("enforces rate limit at 10 posts/hour", func(t *testing.T) {
644644 // Add one more post to hit the limit (total = 10)
645645- postURI := fmt.Sprintf("at://%s/social.coves.post.record/post10", communityDID)
645645+ postURI := fmt.Sprintf("at://%s/social.coves.community.post/post10", communityDID)
646646 if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil {
647647 t.Fatalf("Failed to record 10th post: %v", err)
648648 }
···801801802802 // Record 5 posts
803803 for i := 0; i < 5; i++ {
804804- postURI := fmt.Sprintf("at://%s/social.coves.post.record/triggerpost%d", communityDID, i)
804804+ postURI := fmt.Sprintf("at://%s/social.coves.community.post/triggerpost%d", communityDID, i)
805805 if err := aggRepo.RecordAggregatorPost(ctx, aggregatorDID, communityDID, postURI, "bafy123"); err != nil {
806806 t.Fatalf("Failed to record post %d: %v", i, err)
807807 }
+1-1
tests/integration/feed_test.go
···8181 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
8282 record, ok := feedPost.Post.Record.(map[string]interface{})
8383 require.True(t, ok, "Record should be a map")
8484- assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type")
8484+ assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type")
8585 assert.NotEmpty(t, record["community"], "Record should have community")
8686 assert.NotEmpty(t, record["author"], "Record should have author")
8787 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
+1-1
tests/integration/helpers.go
···274274275275 // Generate URI
276276 rkey := fmt.Sprintf("post-%d", time.Now().UnixNano())
277277- uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", communityDID, rkey)
277277+ uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", communityDID, rkey)
278278279279 // Insert post
280280 _, err := db.ExecContext(ctx, `
+41-21
tests/integration/post_creation_test.go
···11package integration
2233import (
44+ "Coves/internal/api/middleware"
45 "Coves/internal/atproto/identity"
56 "Coves/internal/core/communities"
67 "Coves/internal/core/posts"
···97989899 // This will fail at token refresh step (expected for unit test)
99100 // We're using a fake token that can't be parsed
100100- _, err := postService.CreatePost(ctx, req)
101101+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
102102+ _, err := postService.CreatePost(authCtx, req)
101103102104 // For now, we expect an error because token is fake
103105 // In a full E2E test with real PDS, this would succeed
···123125124126 // Should resolve handle to DID and proceed
125127 // Will still fail at token refresh (expected with fake token)
126126- _, err := postService.CreatePost(ctx, req)
128128+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
129129+ _, err := postService.CreatePost(authCtx, req)
127130 require.Error(t, err)
128131 // Should fail at token refresh, not community resolution
129132 assert.Contains(t, err.Error(), "failed to refresh community credentials")
···152155153156 // Should resolve handle to DID and proceed
154157 // Will still fail at token refresh (expected with fake token)
155155- _, err := postService.CreatePost(ctx, req)
158158+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
159159+ _, err := postService.CreatePost(authCtx, req)
156160 require.Error(t, err)
157161 // Should fail at token refresh, not community resolution
158162 assert.Contains(t, err.Error(), "failed to refresh community credentials")
···167171 AuthorDID: testUserDID,
168172 }
169173170170- _, err := postService.CreatePost(ctx, req)
174174+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
175175+ _, err := postService.CreatePost(authCtx, req)
171176 require.Error(t, err)
172177 assert.True(t, posts.IsValidationError(err))
173178 })
···181186 AuthorDID: testUserDID,
182187 }
183188184184- _, err := postService.CreatePost(ctx, req)
189189+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
190190+ _, err := postService.CreatePost(authCtx, req)
185191 require.Error(t, err)
186192 // Should fail with community not found (wrapped in error)
187193 assert.Contains(t, err.Error(), "community not found")
···196202 AuthorDID: "", // Missing!
197203 }
198204199199- _, err := postService.CreatePost(ctx, req)
205205+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
206206+ _, err := postService.CreatePost(authCtx, req)
200207 require.Error(t, err)
201208 assert.True(t, posts.IsValidationError(err))
202209 assert.Contains(t, err.Error(), "authorDid")
···211218 AuthorDID: testUserDID,
212219 }
213220214214- _, err := postService.CreatePost(ctx, req)
221221+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
222222+ _, err := postService.CreatePost(authCtx, req)
215223 require.Error(t, err)
216224 assert.Equal(t, posts.ErrCommunityNotFound, err)
217225 })
···226234 AuthorDID: testUserDID,
227235 }
228236229229- _, err := postService.CreatePost(ctx, req)
237237+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
238238+ _, err := postService.CreatePost(authCtx, req)
230239 require.Error(t, err)
231240 assert.True(t, posts.IsValidationError(err))
232241 assert.Contains(t, err.Error(), "too long")
···236245 content := "Post with invalid label"
237246238247 req := posts.CreatePostRequest{
239239- Community: testCommunity.DID,
240240- Content: &content,
241241- ContentLabels: []string{"invalid_label"}, // Not in known values!
242242- AuthorDID: testUserDID,
248248+ Community: testCommunity.DID,
249249+ Content: &content,
250250+ Labels: &posts.SelfLabels{
251251+ Values: []posts.SelfLabel{
252252+ {Val: "invalid_label"}, // Not in known values!
253253+ },
254254+ },
255255+ AuthorDID: testUserDID,
243256 }
244257245245- _, err := postService.CreatePost(ctx, req)
258258+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
259259+ _, err := postService.CreatePost(authCtx, req)
246260 require.Error(t, err)
247261 assert.True(t, posts.IsValidationError(err))
248262 assert.Contains(t, err.Error(), "unknown content label")
···252266 content := "Post with valid labels"
253267254268 req := posts.CreatePostRequest{
255255- Community: testCommunity.DID,
256256- Content: &content,
257257- ContentLabels: []string{"nsfw", "spoiler"},
258258- AuthorDID: testUserDID,
269269+ Community: testCommunity.DID,
270270+ Content: &content,
271271+ Labels: &posts.SelfLabels{
272272+ Values: []posts.SelfLabel{
273273+ {Val: "nsfw"},
274274+ {Val: "spoiler"},
275275+ },
276276+ },
277277+ AuthorDID: testUserDID,
259278 }
260279261280 // Will fail at token refresh (expected with fake token)
262262- _, err := postService.CreatePost(ctx, req)
281281+ authCtx := middleware.SetTestUserDID(ctx, testUserDID)
282282+ _, err := postService.CreatePost(authCtx, req)
263283 require.Error(t, err)
264284 // Should fail at token refresh, not validation
265285 assert.Contains(t, err.Error(), "failed to refresh community credentials")
···316336 title := "Test Title"
317337318338 post := &posts.Post{
319319- URI: "at://" + testCommunityDID + "/social.coves.post.record/test123",
339339+ URI: "at://" + testCommunityDID + "/social.coves.community.post/test123",
320340 CID: "bafy2test123",
321341 RKey: "test123",
322342 AuthorDID: testUserDID,
···335355 content := "Duplicate post"
336356337357 post1 := &posts.Post{
338338- URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate",
358358+ URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate",
339359 CID: "bafy2duplicate1",
340360 RKey: "duplicate",
341361 AuthorDID: testUserDID,
···348368349369 // Try to insert again with same URI
350370 post2 := &posts.Post{
351351- URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate",
371371+ URI: "at://" + testCommunityDID + "/social.coves.community.post/duplicate",
352372 CID: "bafy2duplicate2",
353373 RKey: "duplicate",
354374 AuthorDID: testUserDID,
+15-15
tests/integration/post_e2e_test.go
···3333// XRPC endpoint → AppView Service → PDS write → Jetstream consumer → DB indexing
3434//
3535// This is a TRUE E2E test that simulates what happens in production:
3636-// 1. Client calls POST /xrpc/social.coves.post.create with auth token
3636+// 1. Client calls POST /xrpc/social.coves.community.post.create with auth token
3737// 2. Handler validates and calls PostService.CreatePost()
3838// 3. Service writes post to community's PDS repository
3939// 4. PDS broadcasts event to firehose/Jetstream
···116116 Kind: "commit",
117117 Commit: &jetstream.CommitEvent{
118118 Operation: "create",
119119- Collection: "social.coves.post.record",
119119+ Collection: "social.coves.community.post",
120120 RKey: rkey,
121121 CID: "bafy2bzaceabc123def456", // Fake CID
122122 Record: map[string]interface{}{
123123- "$type": "social.coves.post.record",
123123+ "$type": "social.coves.community.post",
124124 "community": community.DID,
125125 "author": author.DID,
126126 "title": *postReq.Title,
···138138 }
139139140140 // STEP 4: Verify post was indexed in AppView database
141141- expectedURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey)
141141+ expectedURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
142142 indexedPost, err := postRepo.GetByURI(ctx, expectedURI)
143143 if err != nil {
144144 t.Fatalf("Post not indexed in AppView: %v", err)
···187187 Kind: "commit",
188188 Commit: &jetstream.CommitEvent{
189189 Operation: "create",
190190- Collection: "social.coves.post.record",
190190+ Collection: "social.coves.community.post",
191191 RKey: generateTID(),
192192 CID: "bafy2bzacefake",
193193 Record: map[string]interface{}{
194194- "$type": "social.coves.post.record",
194194+ "$type": "social.coves.community.post",
195195 "community": community.DID, // Claims to be for this community
196196 "author": author.DID,
197197 "title": "Fake Post",
···227227 Kind: "commit",
228228 Commit: &jetstream.CommitEvent{
229229 Operation: "create",
230230- Collection: "social.coves.post.record",
230230+ Collection: "social.coves.community.post",
231231 RKey: rkey,
232232 CID: "bafy2bzaceidempotent",
233233 Record: map[string]interface{}{
234234- "$type": "social.coves.post.record",
234234+ "$type": "social.coves.community.post",
235235 "community": community.DID,
236236 "author": author.DID,
237237 "title": "Duplicate Test",
···256256 }
257257258258 // Verify only one post in database
259259- uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey)
259259+ uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
260260 post, err := postRepo.GetByURI(ctx, uri)
261261 if err != nil {
262262 t.Fatalf("Post not found: %v", err)
···281281 Kind: "commit",
282282 Commit: &jetstream.CommitEvent{
283283 Operation: "create",
284284- Collection: "social.coves.post.record",
284284+ Collection: "social.coves.community.post",
285285 RKey: generateTID(),
286286 CID: "bafy2bzaceorphaned",
287287 Record: map[string]interface{}{
288288- "$type": "social.coves.post.record",
288288+ "$type": "social.coves.community.post",
289289 "community": unknownCommunityDID,
290290 "author": author.DID,
291291 "title": "Orphaned Post",
···313313}
314314315315// TestPostCreation_E2E_LivePDS tests the COMPLETE end-to-end flow with a live PDS:
316316-// 1. HTTP POST to /xrpc/social.coves.post.create (with auth)
316316+// 1. HTTP POST to /xrpc/social.coves.community.post.create (with auth)
317317// 2. Handler → Service → Write to community's PDS repository
318318// 3. PDS → Jetstream firehose event
319319// 4. Jetstream consumer → Index in AppView database
···472472 require.NoError(t, err)
473473474474 // Create HTTP request
475475- req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
475475+ req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
476476 req.Header.Set("Content-Type", "application/json")
477477478478 // Create a simple JWT for testing (Phase 1: no signature verification)
···511511 pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port
512512513513 // Build Jetstream URL with filters for post records
514514- jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.post.record",
514514+ jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.post",
515515 pdsHostname)
516516517517 t.Logf(" Jetstream URL: %s", jetstreamURL)
···653653654654 // Check if this is a post event for the target DID
655655 if event.Did == targetDID && event.Kind == "commit" &&
656656- event.Commit != nil && event.Commit.Collection == "social.coves.post.record" {
656656+ event.Commit != nil && event.Commit.Collection == "social.coves.community.post" {
657657 // Process the event through the consumer
658658 if err := consumer.HandleEvent(ctx, &event); err != nil {
659659 return fmt.Errorf("failed to process event: %w", err)
···100100 assert.NotNil(t, feedPost.Post.Record, "Post %d should have Record field", i)
101101 record, ok := feedPost.Post.Record.(map[string]interface{})
102102 require.True(t, ok, "Record should be a map")
103103- assert.Equal(t, "social.coves.post.record", record["$type"], "Record should have correct $type")
103103+ assert.Equal(t, "social.coves.community.post", record["$type"], "Record should have correct $type")
104104 assert.NotEmpty(t, record["community"], "Record should have community")
105105 assert.NotEmpty(t, record["author"], "Record should have author")
106106 assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
···11{
22 "$type": "social.coves.moderation.tribunalVote",
33 "tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c",
44- "subject": "at://did:plc:spammer123/social.coves.post.record/3k7a2clb4bj2b",
44+ "subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b",
55 "decision": "remove",
66 "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.",
77 "precedents": [
···11{
22- "$type": "social.coves.post.record",
22+ "$type": "social.coves.community.post",
33 "community": "did:plc:programming123",
44+ "author": "did:plc:testauthor123",
45 "postType": "invalid-type",
56 "title": "This has an invalid post type",
66- "text": "The postType field has an invalid value",
77+ "content": "The postType field is not defined in the schema and should be rejected",
78 "tags": [],
88- "language": "en",
99- "contentWarnings": [],
99+ "langs": ["en"],
1010 "createdAt": "2025-01-09T14:30:00Z"
1111-}1111+}
···11{
22- "$type": "social.coves.post.record",
33- "postType": "text",
22+ "$type": "social.coves.community.post",
33+ "author": "did:plc:testauthor123",
44 "title": "Test Post",
55- "text": "This post is missing the required community field",
55+ "content": "This post is missing the required community field",
66 "tags": ["test"],
77- "language": "en",
88- "contentWarnings": [],
77+ "langs": ["en"],
98 "createdAt": "2025-01-09T14:30:00Z"
1010-}99+}
+6-7
tests/lexicon-test-data/post/post-valid-text.json
···11{
22- "$type": "social.coves.post.record",
22+ "$type": "social.coves.community.post",
33 "community": "did:plc:programming123",
44- "postType": "text",
44+ "author": "did:plc:testauthor123",
55 "title": "Best practices for error handling in Go",
66- "text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
77- "textFacets": [
66+ "content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
77+ "facets": [
88 {
99 "index": {
1010 "byteStart": 20,
···1818 }
1919 ],
2020 "tags": ["golang", "error-handling", "best-practices"],
2121- "language": "en",
2222- "contentWarnings": [],
2121+ "langs": ["en"],
2322 "createdAt": "2025-01-09T14:30:00Z"
2424-}2323+}
+10-4
tests/lexicon_validation_test.go
···4848 schemaID := strings.ReplaceAll(relPath, string(filepath.Separator), ".")
49495050 t.Run(schemaID, func(t *testing.T) {
5151+ // Skip validation for definition-only files (*.defs) - they don't need a "main" section
5252+ // These files only contain shared type definitions referenced by other schemas
5353+ if strings.HasSuffix(schemaID, ".defs") {
5454+ t.Skip("Skipping defs-only file (no main section required)")
5555+ }
5656+5157 if _, resolveErr := catalog.Resolve(schemaID); resolveErr != nil {
5258 t.Errorf("Failed to resolve schema %s: %v", schemaID, resolveErr)
5359 }
···137143 },
138144 {
139145 name: "Valid post record",
140140- recordType: "social.coves.post.record",
146146+ recordType: "social.coves.community.post",
141147 recordData: map[string]interface{}{
142142- "$type": "social.coves.post.record",
148148+ "$type": "social.coves.community.post",
143149 "community": "did:plc:programming123",
144150 "author": "did:plc:testauthor123",
145151 "title": "Test Post",
···150156 },
151157 {
152158 name: "Invalid post record - missing required field",
153153- recordType: "social.coves.post.record",
159159+ recordType: "social.coves.community.post",
154160 recordData: map[string]interface{}{
155155- "$type": "social.coves.post.record",
161161+ "$type": "social.coves.community.post",
156162 "community": "did:plc:programming123",
157163 // Missing required "author" field
158164 "title": "Test Post",