A community based topic aggregation platform built on atproto

Merge feat/post-creation-alpha into main

Implements Alpha post creation feature with full write-forward to community
PDS and real-time Jetstream indexing.

Features:
- POST /xrpc/social.coves.post.create endpoint
- Write-forward architecture (posts written to community's PDS repository)
- Real-time AppView indexing via Jetstream consumer
- Comprehensive security validation (auth, repository ownership, FK integrity)
- Support for all 4 at-identifier formats (DIDs, canonical, @-prefixed, scoped)
- Database schema with proper indexing (migration 011)
- Full integration test suite (service, repository, handler, E2E with live PDS)

Implementation:
- Domain layer: Post models, service, validation
- Repository layer: PostgreSQL with JSON support
- Handler layer: XRPC endpoint with OAuth auth
- Consumer layer: Jetstream real-time indexing with security checks
- 13 commits, 30+ files, ~2,700 lines

Deferred to Beta:
- Content rules validation
- Post read operations (get, list)
- Post update/delete operations
- Voting system

See docs/PRD_POSTS.md for complete status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+30
cmd/server/main.go
···
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
"Coves/internal/core/users"
"bytes"
"context"
···
log.Println("Started JWKS cache cleanup background job (runs hourly)")
// Register XRPC routes
routes.RegisterUserRoutes(r, userService)
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
log.Println("Community XRPC endpoints registered with OAuth authentication")
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
···
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
"Coves/internal/core/users"
"bytes"
"context"
···
log.Println("Started JWKS cache cleanup background job (runs hourly)")
+
// Initialize post service
+
postRepo := postgresRepo.NewPostRepository(db)
+
postService := posts.NewPostService(postRepo, communityService, defaultPDS)
+
+
// Start Jetstream consumer for posts
+
// This consumer indexes posts created in community repositories via the firehose
+
// Currently handles only CREATE operations - UPDATE/DELETE deferred until those features exist
+
postJetstreamURL := os.Getenv("POST_JETSTREAM_URL")
+
if postJetstreamURL == "" {
+
// Listen to post record creation events
+
postJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.post.record"
+
}
+
+
postEventConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
+
postJetstreamConnector := jetstream.NewPostJetstreamConnector(postEventConsumer, postJetstreamURL)
+
+
go func() {
+
if startErr := postJetstreamConnector.Start(ctx); startErr != nil {
+
log.Printf("Post Jetstream consumer stopped: %v", startErr)
+
}
+
}()
+
+
log.Printf("Started Jetstream post consumer: %s", postJetstreamURL)
+
log.Println(" - Indexing: social.coves.post.record CREATE operations")
+
log.Println(" - UPDATE/DELETE indexing deferred until those features are implemented")
+
// Register XRPC routes
routes.RegisterUserRoutes(r, userService)
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
log.Println("Community XRPC endpoints registered with OAuth authentication")
+
+
routes.RegisterPostRoutes(r, postService, authMiddleware)
+
log.Println("Post XRPC endpoints registered with OAuth authentication")
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
+92
docs/PRD_BACKLOG.md
···
## 🟡 P1: Important (Alpha Blockers)
### did:web Domain Verification & hostedByDID Auto-Population
**Added:** 2025-10-11 | **Updated:** 2025-10-16 | **Effort:** 2-3 days | **Priority:** ALPHA BLOCKER
···
## 🟡 P1: Important (Alpha Blockers)
+
### at-identifier Handle Resolution in Endpoints
+
**Added:** 2025-10-18 | **Effort:** 2-3 hours | **Priority:** ALPHA BLOCKER
+
+
**Problem:**
+
Current implementation rejects handles in endpoints that declare `"format": "at-identifier"` in their lexicon schemas, violating atProto best practices and breaking legitimate client usage.
+
+
**Impact:**
+
- ❌ Post creation fails when client sends community handle (e.g., `!gardening.communities.coves.social`)
+
- ❌ Subscribe/unsubscribe endpoints reject handles despite lexicon declaring `at-identifier`
+
- ❌ Block endpoints use `"format": "did"` but should use `at-identifier` for consistency
+
- 🔴 **P0 Issue:** API contract violation - clients following the schema are rejected
+
+
**Root Cause:**
+
Handlers and services validate `strings.HasPrefix(req.Community, "did:")` instead of calling `ResolveCommunityIdentifier()`.
+
+
**Affected Endpoints:**
+
1. **Post Creation** - [create.go:54](../internal/api/handlers/post/create.go#L54), [service.go:51](../internal/core/posts/service.go#L51)
+
- Lexicon declares `at-identifier`: [post/create.json:16](../internal/atproto/lexicon/social/coves/post/create.json#L16)
+
+
2. **Subscribe** - [subscribe.go:52](../internal/api/handlers/community/subscribe.go#L52)
+
- Lexicon declares `at-identifier`: [subscribe.json:16](../internal/atproto/lexicon/social/coves/community/subscribe.json#L16)
+
+
3. **Unsubscribe** - [subscribe.go:120](../internal/api/handlers/community/subscribe.go#L120)
+
- Lexicon declares `at-identifier`: [unsubscribe.json:16](../internal/atproto/lexicon/social/coves/community/unsubscribe.json#L16)
+
+
4. **Block/Unblock** - [block.go:58](../internal/api/handlers/community/block.go#L58), [block.go:132](../internal/api/handlers/community/block.go#L132)
+
- Lexicon declares `"format": "did"`: [block.json:15](../internal/atproto/lexicon/social/coves/community/block.json#L15)
+
- Should be changed to `at-identifier` for consistency and best practice
+
+
**atProto Best Practice (from docs):**
+
- ✅ API endpoints should accept both DIDs and handles via `at-identifier` format
+
- ✅ Resolve handles to DIDs immediately at API boundary
+
- ✅ Use DIDs internally for all business logic and storage
+
- ✅ Handles are weak refs (changeable), DIDs are strong refs (permanent)
+
- ⚠️ Bidirectional verification required (already handled by `identity.CachingResolver`)
+
+
**Solution:**
+
Replace direct DID validation with handle resolution using existing `ResolveCommunityIdentifier()`:
+
+
```go
+
// BEFORE (wrong) ❌
+
if !strings.HasPrefix(req.Community, "did:") {
+
return error
+
}
+
+
// AFTER (correct) ✅
+
communityDID, err := h.communityService.ResolveCommunityIdentifier(ctx, req.Community)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found")
+
return
+
}
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
return
+
}
+
// Now use communityDID (guaranteed to be a DID)
+
```
+
+
**Implementation Plan:**
+
1. ✅ **Phase 1 (Alpha Blocker):** Fix post creation endpoint
+
- Update handler validation in `internal/api/handlers/post/create.go`
+
- Update service validation in `internal/core/posts/service.go`
+
- Add integration tests for handle resolution in post creation
+
+
2. 📋 **Phase 2 (Beta):** Fix subscription endpoints
+
- Update subscribe/unsubscribe handlers
+
- Add tests for handle resolution in subscriptions
+
+
3. 📋 **Phase 3 (Beta):** Fix block endpoints
+
- Update lexicon from `"format": "did"` → `"format": "at-identifier"`
+
- Update block/unblock handlers
+
- Add tests for handle resolution in blocking
+
+
**Files to Modify (Phase 1 - Post Creation):**
+
- `internal/api/handlers/post/create.go` - Remove DID validation, add handle resolution
+
- `internal/core/posts/service.go` - Remove DID validation, add handle resolution
+
- `internal/core/posts/interfaces.go` - Add `CommunityService` dependency
+
- `cmd/server/main.go` - Pass community service to post service constructor
+
- `tests/integration/post_creation_test.go` - Add handle resolution test cases
+
+
**Existing Infrastructure:**
+
✅ `ResolveCommunityIdentifier()` already implemented at [service.go:843](../internal/core/communities/service.go#L843)
+
✅ `identity.CachingResolver` handles bidirectional verification and caching
+
✅ Supports both handle (`!name.communities.instance.com`) and DID formats
+
+
**Current Status:**
+
- ⚠️ **BLOCKING POST CREATION PR**: Identified as P0 issue in code review
+
- 📋 Phase 1 (post creation) - To be implemented immediately
+
- 📋 Phase 2-3 (other endpoints) - Deferred to Beta
+
+
---
+
### did:web Domain Verification & hostedByDID Auto-Population
**Added:** 2025-10-11 | **Updated:** 2025-10-16 | **Effort:** 2-3 days | **Priority:** ALPHA BLOCKER
+60 -2
docs/PRD_COMMUNITIES.md
···
## 📍 Beta Features (High Priority - Post Alpha)
### Posts in Communities
**Status:** Lexicon designed, implementation TODO
**Priority:** HIGHEST for Beta 1
-
- [ ] `social.coves.post` already has `community` field ✅
-
- [ ] Create post endpoint (decide: membership validation?)
- [ ] Feed generation for community posts
- [ ] Post consumer (index community posts from firehose)
- [ ] Community post count tracking
···
- `banner` - Blob reference for banner image
- `moderationType` - `"moderator"` or `"sortition"`
- `contentWarnings` - Array of content warning types
- `memberCount` - Cached count
- `subscriberCount` - Cached count
···
---
## Technical Decisions Log
### 2025-10-11: Single Handle Field (atProto-Compliant)
**Decision:** Use single `handle` field containing DNS-resolvable atProto handle; remove `atprotoHandle` field
···
## 📍 Beta Features (High Priority - Post Alpha)
+
### Content Rules System
+
**Status:** Lexicon complete (2025-10-18), implementation TODO
+
**Priority:** CRITICAL for Alpha - Enables community content policies
+
+
Communities can define content posting restrictions via the `contentRules` object in their profile:
+
+
**Key Features:**
+
- ✅ Lexicon: `contentRules` object defined in `social.coves.community.profile`
+
- [ ] Validation: Post creation validates against community rules
+
- [ ] AppView indexing: Index post characteristics (embed_type, text_length)
+
- [ ] Error handling: Clear `ContentRuleViolation` errors
+
+
**Example Use Cases:**
+
- **Text-only communities:** "AskCoves" requires text, blocks all embeds
+
- **Image communities:** "CovesPics" requires at least 1 image
+
- **No restrictions:** General communities allow all content types
+
+
**See:** [PRD_GOVERNANCE.md - Content Rules System](PRD_GOVERNANCE.md#content-rules-system) for full details
+
+
---
+
### Posts in Communities
**Status:** Lexicon designed, implementation TODO
**Priority:** HIGHEST for Beta 1
+
- [x] `social.coves.post` already has `community` field ✅
+
- [x] Removed `postType` enum in favor of content rules ✅ (2025-10-18)
+
- [ ] Create post endpoint with content rules validation
- [ ] Feed generation for community posts
- [ ] Post consumer (index community posts from firehose)
- [ ] Community post count tracking
···
- `banner` - Blob reference for banner image
- `moderationType` - `"moderator"` or `"sortition"`
- `contentWarnings` - Array of content warning types
+
- `contentRules` - Content posting restrictions (see [PRD_GOVERNANCE.md](PRD_GOVERNANCE.md#content-rules-system))
+
- `allowedEmbedTypes` - Array of allowed embed types (images, video, external, record)
+
- `requireText` - Whether text content is required
+
- `minTextLength` / `maxTextLength` - Text length constraints
+
- `requireTitle` - Whether title is required
+
- `minImages` / `maxImages` - Image count constraints
+
- `allowFederated` - Whether federated posts allowed
- `memberCount` - Cached count
- `subscriberCount` - Cached count
···
---
## Technical Decisions Log
+
+
### 2025-10-18: Content Rules Over Post Type Enum
+
**Decision:** Remove `postType` enum from post creation; use flexible `contentRules` in community profile instead
+
+
**Rationale:**
+
- `postType` enum forced users to explicitly select type (bad UX - app should infer from structure)
+
- Enum was rigid - couldn't support nuanced rules like "text required, images optional"
+
- Content rules are more extensible (add new constraints without changing post lexicon)
+
- Follows atProto philosophy: describe data structure, not UI intent
+
- Enables both community restrictions ("text only") AND user filtering ("show videos only")
+
+
**Implementation:**
+
- Community profile contains optional `contentRules` object
+
- Post validation checks structure against rules at creation time
+
- AppView indexes post characteristics (embed_type, text_length) for filtering
+
- Errors use `ContentRuleViolation` instead of `InvalidPostType`
+
+
**Examples:**
+
- "AskCoves": `allowedEmbedTypes: []` + `requireText: true` + `minTextLength: 50`
+
- "CovesPics": `allowedEmbedTypes: ["images"]` + `minImages: 1`
+
- General communities: `contentRules: null` (no restrictions)
+
+
**Trade-offs Accepted:**
+
- Validation logic more complex than simple enum check (but more powerful)
+
- Communities can't programmatically restrict to exact "article" vs "text" types (but structure-based rules are better)
+
+
**See:** [PRD_GOVERNANCE.md - Content Rules System](PRD_GOVERNANCE.md#content-rules-system)
+
+
---
### 2025-10-11: Single Handle Field (atProto-Compliant)
**Decision:** Use single `handle` field containing DNS-resolvable atProto handle; remove `atprotoHandle` field
+134 -72
docs/PRD_GOVERNANCE.md
···
**Current State (2025-10-15):**
- Communities own their own atProto repositories (V2 architecture)
- Instance holds PDS credentials for infrastructure management
-
- Basic authorization exists: only `createdBy` user can update communities
- No moderator management system exists yet
**Note:** Moderator management and advanced governance are **post-alpha** (Beta Phase 1) work. Alpha focuses on basic community CRUD operations.
···
2. **Community lifecycle:** No way to transfer ownership or add co-managers
3. **Scaling moderation:** Single-owner model doesn't scale to large communities
4. **User expectations:** Forum users expect moderator teams, not single-admin models
-
-
**User Stories:**
-
- As a **self-hosted instance owner**, I want to create communities and assign moderators so I don't have to manage everything myself
-
- As a **community creator**, I want to add trusted moderators to help manage the community
-
- As a **moderator**, I want clear permissions on what I can/cannot do
-
- As an **instance admin**, I need emergency moderation powers for compliance/safety
## Architecture Evolution
···
- Should NOT be used for day-to-day community management
- Authority derived from instance DID matching `hostedBy`
-
**Database Schema:**
-
```
-
community_moderators
-
- id (UUID, primary key)
-
- community_did (references communities.did)
-
- moderator_did (user DID)
-
- role (enum: 'creator', 'moderator')
-
- added_by (DID of user who granted role)
-
- added_at (timestamp)
-
- UNIQUE(community_did, moderator_did)
-
```
-
-
**Authorization Checks:**
-
- **Update community profile:** Creator OR Moderator
-
- **Add/remove moderators:** Creator only
-
- **Delete community:** Creator only
-
- **Transfer creator role:** Creator only
-
- **Instance moderation:** Instance admin only (emergency use)
-
-
**Implementation Approach:**
-
- Add `community_moderators` table to schema
-
- Create authorization middleware for XRPC endpoints
-
- Update service layer to check permissions before operations
-
- Store moderator list in both AppView DB and optionally in atProto repository
-
-
**Benefits:**
-
- ✅ Familiar to forum users (creator/moderator model is standard)
-
- ✅ Works for both centralized and self-hosted instances
-
- ✅ Clear separation of concerns (community vs instance authority)
-
- ✅ Easy to implement on top of existing V2 architecture
-
- ✅ Provides foundation for future governance features
-
-
**Limitations:**
-
- ❌ Still centralized (creator has ultimate authority)
-
- ❌ No democratic decision-making
-
- ❌ Moderator removal is unilateral (creator decision)
-
- ❌ No community input on governance changes
-
-
---
### V2: Moderator Tiers & Permissions
···
---
## Implementation Roadmap
### Phase 1: V1 Role-Based System (Months 0-3)
**Goals:**
···
**Success Criteria:**
- Community creators can add/remove moderators
-
- Moderators can update community profile but not delete
- Authorization prevents unauthorized operations
- Works seamlessly for both centralized and self-hosted instances
···
---
## Technical Decisions Log
-
-
### 2025-10-18: Moderator Lexicon Extensibility
-
**Decision:** Use `knownValues` instead of `enum` for moderator roles and permissions in `social.coves.community.moderator` record schema
-
-
**Rationale:**
-
- Moderator records are immutable once published (atProto record semantics)
-
- Closed `enum` values cannot be extended without breaking schema evolution rules
-
- Using `knownValues` allows adding new roles/permissions in Beta Phase 2 without requiring V2 schema migration
-
- Zero cost to fix during alpha planning; expensive to migrate once records exist in production
-
-
**Changes Made:**
-
- `role` field: Changed from `enum: ["moderator", "admin"]` to `knownValues: ["moderator", "admin"]` with `maxLength: 64`
-
- `permissions` array items: Changed from closed enum to `knownValues` with `maxLength: 64`
-
-
**Future Extensibility Examples:**
-
- **New roles**: "owner" (full transfer rights), "trainee" (limited trial moderator), "emeritus" (honorary former moderator)
-
- **New permissions**: "manage_bots", "manage_flairs", "manage_automoderator", "manage_federation", "pin_posts"
-
- Can add these values during Phase 2 (Moderator Tiers & Permissions) without breaking existing moderator records
-
-
**atProto Style Guide Reference:**
-
Per [atproto#4245](https://github.com/bluesky-social/atproto/discussions/4245): "Enum sets are 'closed' and can not be updated or extended without breaking schema evolution rules. For this reason they should almost always be avoided. For strings, `knownValues` provides more flexible alternative."
-
-
**Implementation Status:** ✅ Fixed in lexicon before alpha launch
-
-
---
### 2025-10-11: Moderator Records Storage Location
**Decision:** Store moderator records in community's repository (`at://community_did/social.coves.community.moderator/{tid}`), not user's repository
···
**Current State (2025-10-15):**
- Communities own their own atProto repositories (V2 architecture)
- Instance holds PDS credentials for infrastructure management
+
- Basic authorization exists: only `createdBy` user can update communities (Admins too?)
- No moderator management system exists yet
**Note:** Moderator management and advanced governance are **post-alpha** (Beta Phase 1) work. Alpha focuses on basic community CRUD operations.
···
2. **Community lifecycle:** No way to transfer ownership or add co-managers
3. **Scaling moderation:** Single-owner model doesn't scale to large communities
4. **User expectations:** Forum users expect moderator teams, not single-admin models
## Architecture Evolution
···
- Should NOT be used for day-to-day community management
- Authority derived from instance DID matching `hostedBy`
### V2: Moderator Tiers & Permissions
···
---
+
## Content Rules System
+
+
**Status:** Designed (2025-10-18)
+
**Priority:** Alpha - Enables community-specific content policies
+
+
### Overview
+
+
Content rules allow communities to define restrictions on what types of content can be posted, replacing the rejected `postType` enum approach with flexible, structure-based validation.
+
+
### Lexicon Design
+
+
Content rules are stored in `social.coves.community.profile` under the `contentRules` object:
+
+
```json
+
{
+
"contentRules": {
+
"allowedEmbedTypes": ["images"], // Only images allowed
+
"requireText": true, // Posts must have text
+
"minTextLength": 50, // Minimum 50 characters
+
"maxTextLength": 5000, // Maximum 5000 characters
+
"requireTitle": false, // Title optional
+
"minImages": 1, // At least 1 image
+
"maxImages": 10, // Maximum 10 images
+
"allowFederated": false // No federated posts
+
}
+
}
+
```
+
+
### Example Community Configurations
+
+
**"AskCoves" - Text-Only Q&A:**
+
```json
+
{
+
"contentRules": {
+
"allowedEmbedTypes": [], // No embeds at all
+
"requireText": true,
+
"minTextLength": 50, // Substantive questions only
+
"requireTitle": true, // Must have question title
+
"allowFederated": false
+
}
+
}
+
```
+
+
**"CovesPics" - Image Community:**
+
```json
+
{
+
"contentRules": {
+
"allowedEmbedTypes": ["images"],
+
"requireText": false, // Description optional
+
"minImages": 1, // Must have at least 1 image
+
"maxImages": 20,
+
"allowFederated": true // Allow Bluesky image posts
+
}
+
}
+
```
+
+
**"CovesGeneral" - No Restrictions:**
+
```json
+
{
+
"contentRules": null // Or omit entirely - all content types allowed
+
}
+
```
+
+
### Implementation Flow
+
+
1. **Community Creation/Update:**
+
- Creator/moderator sets `contentRules` in community profile
+
- Rules stored in community's repository (`at://community_did/social.coves.community.profile/self`)
+
- AppView indexes rules for validation
+
+
2. **Post Creation:**
+
- Handler receives post creation request
+
- Fetches community profile (including `contentRules`)
+
- Validates post structure against rules
+
- Returns `ContentRuleViolation` error if validation fails
+
+
3. **Validation Logic:**
+
- Check embed types against `allowedEmbedTypes`
+
- Verify text requirements (`requireText`, `minTextLength`, `maxTextLength`)
+
- Check title requirements (`requireTitle`)
+
- Validate image counts (`minImages`, `maxImages`)
+
- Block federated posts if `allowFederated: false`
+
+
4. **User Filtering (Client-Side):**
+
- AppView indexes derived post characteristics (has_embed, embed_type, text_length)
+
- UI can filter "show only videos" or "show only text posts"
+
- Filters don't need protocol support - just AppView queries
+
+
### Benefits Over `postType` Enum
+
+
✅ **More Flexible:** Communities can define granular rules (e.g., "text required but images optional")
+
✅ **Extensible:** Add new rules without changing post lexicon
+
✅ **Federation-Friendly:** Rules describe structure, not arbitrary types
+
✅ **Client Freedom:** Different clients interpret same data differently
+
✅ **Separation of Concerns:** Post structure (protocol) vs. community policy (governance)
+
+
### Security Considerations
+
+
- **Validation is advisory:** Malicious PDS could ignore rules, but AppView can filter out violating posts
+
- **Rate limiting:** Prevent spam of posts that get rejected for rule violations
+
- **Audit logging:** Track rule violations for moderation review
+
+
---
+
## Implementation Roadmap
+
### Phase 0: Content Rules System (Month 0 - Alpha Blocker)
+
+
**Status:** Lexicon complete, implementation TODO
+
**Priority:** CRITICAL - Required for alpha launch
+
+
**Goals:**
+
- Enable communities to restrict content types
+
- Validate posts against community rules
+
- Support common use cases (text-only, images-only, etc.)
+
+
**Deliverables:**
+
- [x] Lexicon: `contentRules` object in `social.coves.community.profile` ✅
+
- [ ] 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`
+
- [ ] AppView indexing: Index post characteristics (embed_type, text_length, etc.)
+
- [ ] Tests: Comprehensive rule validation tests
+
- [ ] Documentation: Content rules guide for community creators
+
+
**Success Criteria:**
+
- "AskCoves" text-only community rejects image posts
+
- "CovesPics" image community requires at least one image
+
- Validation errors are clear and actionable
+
- No performance impact on post creation (< 10ms validation)
+
+
---
+
### Phase 1: V1 Role-Based System (Months 0-3)
**Goals:**
···
**Success Criteria:**
- Community creators can add/remove moderators
+
- Moderators can update community profile (including content rules) but not delete
- Authorization prevents unauthorized operations
- Works seamlessly for both centralized and self-hosted instances
···
---
## Technical Decisions Log
### 2025-10-11: Moderator Records Storage Location
**Decision:** Store moderator records in community's repository (`at://community_did/social.coves.community.moderator/{tid}`), not user's repository
+840
docs/PRD_POSTS.md
···
···
+
# Posts PRD: Forum Content System
+
+
**Status:** ✅ Alpha CREATE Complete (2025-10-19) | Get/Update/Delete/Voting TODO
+
**Owner:** Platform Team
+
**Last Updated:** 2025-10-19
+
+
## 🎯 Implementation Status
+
+
### ✅ COMPLETED (Alpha - 2025-10-19)
+
- **Post Creation:** Full write-forward to community PDS with real-time Jetstream indexing
+
- **Handler Layer:** HTTP endpoint with authentication, validation, and security checks
+
- **Service Layer:** Business logic with token refresh and community resolution
+
- **Repository Layer:** PostgreSQL storage with proper indexing
+
- **Jetstream Consumer:** Real-time indexing with security validation
+
- **Database Migration:** Posts table created (migration 011)
+
- **E2E Tests:** Live PDS + Jetstream integration tests passing
+
- **at-identifier Support:** All 4 formats (DIDs, canonical, @-prefixed, scoped handles)
+
+
### ⚠️ DEFERRED TO BETA
+
- Content rules validation (text-only, image-only communities)
+
- Post read operations (get, list)
+
- Post update/edit operations
+
- Post deletion
+
- Voting system (upvotes/downvotes)
+
- Derived characteristics indexing (embed_type, text_length, etc.)
+
+
**See:** [IMPLEMENTATION_POST_CREATION.md](IMPLEMENTATION_POST_CREATION.md) for complete implementation details.
+
+
---
+
+
## Overview
+
+
Posts are the core content unit in Coves communities. Built on atProto, each post is stored in the **community's repository** and indexed by the AppView for discovery and interaction. Posts support rich text, embeds, voting, tagging, and federation with other atProto platforms.
+
+
## Architecture
+
+
### atProto Data Flow
+
Posts follow the community-owned repository pattern, matching the V2 Communities architecture:
+
+
```
+
User creates post → Written to COMMUNITY's PDS repository (using community credentials) →
+
Firehose broadcasts event → AppView Jetstream consumer indexes →
+
Post appears in feeds
+
```
+
+
**Repository Structure:**
+
```
+
Repository: at://did:plc:community789/social.coves.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)
+
```
+
+
**Key Architectural Principles:**
+
- ✅ Communities own posts (posts live in community repos, like traditional forums)
+
- ✅ Author tracked in metadata (post.author field references user DID)
+
- ✅ Communities are portable (migrate instance = posts move with community)
+
- ✅ Matches V2 Communities pattern (community owns repository, instance manages credentials)
+
- ✅ Write operations use community's PDS credentials (not user credentials)
+
+
---
+
+
## Alpha Features (MVP - Ship First)
+
+
### Content Rules Integration
+
**Status:** Lexicon complete (2025-10-18), validation TODO
+
**Priority:** CRITICAL - Required for community content policies
+
+
Posts are validated against community-specific content rules at creation time. Communities can restrict:
+
- Allowed embed types (images, video, external, record)
+
- Text requirements (min/max length, required/optional)
+
- Title requirements
+
- Image count limits
+
- Federated content policies
+
+
**See:** [PRD_GOVERNANCE.md - Content Rules System](PRD_GOVERNANCE.md#content-rules-system) for full details.
+
+
**Implementation checklist:**
+
- [x] Lexicon: `contentRules` in `social.coves.community.profile` ✅
+
- [x] Lexicon: `postType` removed from `social.coves.post.create` ✅
+
- [ ] Validation: `ValidatePostAgainstRules()` service function
+
- [ ] Handler: Integrate validation in post creation endpoint
+
- [ ] AppView: Index derived characteristics (embed_type, text_length, etc.)
+
- [ ] Tests: Validate content rule enforcement
+
+
---
+
+
### Core Post Management
+
**Status:** ✅ CREATE COMPLETE (2025-10-19) - Get/Update/Delete TODO
+
**Priority:** CRITICAL - Posts are the foundation of the platform
+
+
#### Create Post
+
- [x] Lexicon: `social.coves.post.record` ✅
+
- [x] Lexicon: `social.coves.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)
+
- ✅ 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**
+
- ✅ Return: AT-URI and CID of created post
+
- ⚠️ Content rules validation deferred to Beta
+
- [x] **Service Layer:** `PostService.Create()` ✅
+
- ✅ Resolve community identifier to DID (supports all 4 at-identifier formats)
+
- ✅ Validate community exists and is not private
+
- ✅ Fetch community from AppView
+
- ⚠️ **Validate post against content rules** DEFERRED (see [PRD_GOVERNANCE.md](PRD_GOVERNANCE.md#content-rules-system))
+
- ✅ Fetch community's PDS credentials with automatic token refresh
+
- ✅ Build post record with author DID, timestamp, content
+
- ✅ **Write to community's PDS** using community's access token
+
- ✅ Return URI/CID for AppView indexing
+
- [x] **Validation:** ✅
+
- ✅ Community reference is valid (supports DIDs and handles)
+
- ✅ Content length ≤ 50,000 characters
+
- ✅ Title (if provided) ≤ 3,000 bytes
+
- ✅ ContentLabels are from known values (nsfw, spoiler, violence)
+
- ⚠️ **Content rules compliance:** DEFERRED TO BETA
+
- Check embed types against `allowedEmbedTypes`
+
- Verify `requireText` / `minTextLength` / `maxTextLength`
+
- Verify `requireTitle` if set
+
- Check image counts against `minImages` / `maxImages`
+
- Block federated posts if `allowFederated: false`
+
- Return `ContentRuleViolation` error if validation fails
+
- [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://...`
+
- Accept: AT-URI of post
+
- Return: Full post view with author, community, stats, viewer state
+
- [ ] **Service Layer:** `PostService.Get(uri, viewerDID)`
+
- Fetch post from AppView PostgreSQL
+
- Join with user/community data
+
- Calculate stats (upvotes, downvotes, score, comment count)
+
- Include viewer state (vote status, saved status, tags)
+
- [ ] **Repository:** `PostRepository.GetByURI()`
+
- Single query with JOINs for author, community, stats
+
- Handle missing posts gracefully (deleted or not indexed)
+
- [ ] **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`
+
- 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**
+
- Return: New CID
+
- [ ] **Service Layer:** `PostService.Update()`
+
- Fetch existing post from AppView
+
- Verify authorship (post.author == authenticated user DID)
+
- Verify edit window (createdAt + 24 hours > now)
+
- Fetch community's PDS credentials (with token refresh)
+
- **Update record in community's PDS** using community's access token
+
- Track edit timestamp (editedAt field)
+
- [ ] **Edit Window:** 24 hours from creation (hardcoded for Alpha)
+
- [ ] **Edit Note:** Optional explanation field (stored in record)
+
- [ ] **E2E Test:** Update post → Verify edit reflected in AppView
+
+
#### Delete Post
+
- [x] Lexicon: `social.coves.post.delete` ✅
+
- [ ] **Handler:** `POST /xrpc/social.coves.post.delete`
+
- Accept: uri
+
- Validate: User is post author OR community moderator
+
- Write: Delete record from **community's PDS**
+
- [ ] **Service Layer:** `PostService.Delete()`
+
- Verify authorship OR moderator permission
+
- Fetch community's PDS credentials
+
- **Delete from community's PDS** (broadcasts DELETE event to firehose)
+
- Consumer handles soft delete in AppView
+
- [ ] **AppView Behavior:** Mark as deleted (soft delete), hide from feeds
+
- [ ] **Moderator Delete:** Community moderators can delete any post in their community
+
- [ ] **E2E Test:** Delete post → Verify hidden from queries
+
+
---
+
+
### Post Content Features
+
+
#### Rich Text Support
+
- [x] Lexicon: Facets reference `social.coves.richtext.facet` ✅
+
- [ ] **Supported Facets:**
+
- Mentions: `@user.bsky.social` → Links to user profile
+
- Links: `https://example.com` → Clickable URLs
+
- Community mentions: `!community@instance` → Links to community
+
- Hashtags: `#topic` → Tag-based discovery (Future)
+
- [ ] **Implementation:**
+
- Store facets as JSON array in post record
+
- Validate byte ranges match content
+
- Render facets in AppView responses
+
+
#### Embeds (Alpha Scope)
+
- [x] Lexicon: Embed union type ✅
+
- [ ] **Alpha Support:**
+
- **Images:** Upload to community's PDS blob storage, reference in embed
+
- **External Links:** URL, title, description, thumbnail (client-fetched)
+
- **Quoted Posts:** Reference another post's AT-URI
+
- [ ] **Defer to Beta:**
+
- Video embeds (requires video processing infrastructure)
+
+
#### Content Labels
+
- [x] Lexicon: Self-applied labels ✅
+
- [ ] **Alpha Labels:**
+
- `nsfw` - Not safe for work
+
- `spoiler` - Spoiler content (blur/hide by default)
+
- `violence` - Violent or graphic content
+
- [ ] **Implementation:**
+
- Store as string array in post record
+
- AppView respects labels in feed filtering
+
- Client renders appropriate warnings/blurs
+
+
---
+
+
### Voting System
+
+
#### Upvotes & Downvotes
+
- [x] Lexicon: `social.coves.interaction.vote` ✅
+
- [ ] **Handler:** `POST /xrpc/social.coves.interaction.createVote`
+
- Accept: subject (post AT-URI), direction (up/down)
+
- Write: Create vote record in **user's repository**
+
- [ ] **Handler:** `POST /xrpc/social.coves.interaction.deleteVote`
+
- Accept: voteUri (AT-URI of vote record)
+
- Write: Delete vote record from **user's repository**
+
- [ ] **Vote Toggling:**
+
- Upvote → Upvote = Delete upvote
+
- Upvote → Downvote = Delete upvote + Create downvote
+
- No vote → Upvote = Create upvote
+
- [ ] **Downvote Controls (Alpha):**
+
- Global default: Downvotes enabled
+
- Community-level toggle: `allowDownvotes` (Boolean in community.profile)
+
- Instance-level toggle: Environment variable `ALLOW_DOWNVOTES` (Future)
+
- [ ] **AppView Indexing:**
+
- Consumer tracks vote CREATE/DELETE events
+
- Aggregate counts: upvotes, downvotes, score (upvotes - downvotes)
+
- Track viewer's vote state (for "already voted" UI)
+
- [ ] **E2E Test:** Create vote → Index → Verify count updates → Delete vote → Verify count decrements
+
+
**Note:** Votes live in user's repository (user owns their voting history), but posts live in community's repository.
+
+
#### Vote Statistics
+
- [x] Lexicon: `postStats` in post view ✅
+
- [ ] **Stats Fields:**
+
- `upvotes` - Total upvote count
+
- `downvotes` - Total downvote count (0 if community disables)
+
- `score` - Calculated score (upvotes - downvotes)
+
- `commentCount` - Total comments (placeholder for Beta)
+
- `shareCount` - Share tracking (Future)
+
- `tagCounts` - Aggregate tag counts (Future)
+
+
---
+
+
### Jetstream Consumer (Indexing)
+
+
#### Post Event Handling
+
- [x] **Consumer:** `PostConsumer.HandlePostEvent()` ✅ (2025-10-19)
+
- ✅ Listen for `social.coves.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)
+
- ✅ Index: uri, cid, author_did, community_did, title, content, created_at, indexed_at
+
- ✅ **Security Validation:**
+
- ✅ Verify event.repo matches community DID (posts must come from community repos)
+
- ✅ Verify community exists in AppView (foreign key integrity)
+
- ✅ Verify author exists in AppView (foreign key integrity)
+
- ✅ Idempotent indexing for Jetstream replays
+
+
#### Vote Event Handling
+
- [ ] **Consumer:** `PostConsumer.HandleVoteEvent()` - DEFERRED TO BETA (voting system not yet implemented)
+
- Listen for `social.coves.interaction.vote` CREATE/DELETE from **user repositories**
+
- Parse subject URI (extract post)
+
- Increment/decrement vote counts atomically
+
- Track vote URI for viewer state queries
+
- **Validation:** Verify event.repo matches voter DID (votes must come from user repos)
+
+
#### Error Handling
+
- [x] Invalid community references → Reject post (foreign key enforcement) ✅
+
- [x] Invalid author references → Reject post (foreign key enforcement) ✅
+
- [x] Malformed records → Skip indexing, log error ✅
+
- [x] Duplicate events → Idempotent operations (unique constraint on URI) ✅
+
- [x] Posts from user repos → Reject (repository DID validation) ✅
+
+
---
+
+
### Database Schema
+
+
#### Posts Table ✅ IMPLEMENTED (2025-10-19)
+
+
**Migration:** [internal/db/migrations/011_create_posts_table.sql](../internal/db/migrations/011_create_posts_table.sql)
+
+
```sql
+
CREATE TABLE posts (
+
id BIGSERIAL PRIMARY KEY,
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://community_did/collection/rkey)
+
cid TEXT NOT NULL, -- Content ID
+
rkey TEXT NOT NULL, -- Record key (TID)
+
author_did TEXT NOT NULL, -- Author's DID (from record metadata)
+
community_did TEXT NOT NULL, -- Community DID (from AT-URI owner)
+
title TEXT, -- Post title (nullable)
+
content TEXT, -- Post content
+
content_facets JSONB, -- Rich text facets
+
embed JSONB, -- Embedded content
+
content_labels TEXT[], -- Self-applied labels
+
+
-- ⚠️ Derived characteristics DEFERRED TO BETA (for content rules filtering)
+
-- Will be added when content rules are implemented:
+
-- embed_type TEXT, -- images, video, external, record (NULL if no embed)
+
-- text_length INT NOT NULL DEFAULT 0, -- Character count of content
+
-- has_title BOOLEAN NOT NULL DEFAULT FALSE,
+
-- has_embed BOOLEAN NOT NULL DEFAULT FALSE,
+
+
created_at TIMESTAMPTZ NOT NULL, -- Author's timestamp
+
edited_at TIMESTAMPTZ, -- Last edit timestamp
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed
+
deleted_at TIMESTAMPTZ, -- Soft delete
+
+
-- Stats (denormalized for performance)
+
upvote_count INT NOT NULL DEFAULT 0,
+
downvote_count INT NOT NULL DEFAULT 0,
+
score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count
+
comment_count INT NOT NULL DEFAULT 0,
+
+
CONSTRAINT fk_author FOREIGN KEY (author_did) REFERENCES users(did) ON DELETE CASCADE,
+
CONSTRAINT fk_community FOREIGN KEY (community_did) REFERENCES communities(did) ON DELETE CASCADE
+
);
+
+
-- ✅ Implemented indexes
+
CREATE INDEX idx_posts_community_created ON posts(community_did, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_posts_community_score ON posts(community_did, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_posts_author ON posts(author_did, created_at DESC);
+
CREATE INDEX idx_posts_uri ON posts(uri);
+
+
-- ⚠️ Deferred until content rules are implemented:
+
-- CREATE INDEX idx_posts_embed_type ON posts(community_did, embed_type) WHERE deleted_at IS NULL;
+
```
+
+
#### Votes Table
+
```sql
+
CREATE TABLE votes (
+
id BIGSERIAL PRIMARY KEY,
+
uri TEXT UNIQUE NOT NULL, -- Vote record AT-URI (at://voter_did/collection/rkey)
+
cid TEXT NOT NULL,
+
rkey TEXT NOT NULL,
+
voter_did TEXT NOT NULL, -- User who voted (from AT-URI owner)
+
subject_uri TEXT NOT NULL, -- Post/comment AT-URI
+
direction TEXT NOT NULL CHECK (direction IN ('up', 'down')),
+
created_at TIMESTAMPTZ NOT NULL,
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
deleted_at TIMESTAMPTZ,
+
+
CONSTRAINT fk_voter FOREIGN KEY (voter_did) REFERENCES users(did),
+
UNIQUE (voter_did, subject_uri, deleted_at) -- One active vote per user per subject
+
);
+
+
CREATE INDEX idx_votes_subject ON votes(subject_uri, direction) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_votes_voter_subject ON votes(voter_did, subject_uri) WHERE deleted_at IS NULL;
+
```
+
+
---
+
+
## Alpha Blockers (Must Complete)
+
+
### Critical Path
+
- [x] **Post CREATE Endpoint:** ✅ COMPLETE (2025-10-19)
+
- ✅ Handler with authentication and validation
+
- ✅ Service layer with business logic
+
- ✅ Repository layer for database access
+
- ⚠️ Get, Update, Delete deferred to Beta
+
- [x] **Community Credentials:** ✅ Use community's PDS credentials for post writes
+
- [x] **Token Refresh Integration:** ✅ Reuse community token refresh logic for post operations
+
- [x] **Jetstream Consumer:** ✅ Posts indexed in real-time (2025-10-19)
+
- ✅ CREATE operations indexed
+
- ✅ Security validation (repository ownership)
+
- ⚠️ UPDATE/DELETE deferred until those features exist
+
- ⚠️ Derived characteristics deferred to Beta
+
- [x] **Database Migrations:** ✅ Posts table created (migration 011)
+
- [x] **E2E Tests:** ✅ Full flow tested (handler → community PDS → Jetstream → AppView)
+
- ✅ Service layer tests (9 subtests)
+
- ✅ Repository tests (2 subtests)
+
- ✅ Handler security tests (10+ subtests)
+
- ✅ Live PDS + Jetstream E2E test
+
- [x] **Community Integration:** ✅ Posts correctly reference communities via at-identifiers
+
- [x] **at-identifier Support:** ✅ All 4 formats supported (DIDs, canonical, @-prefixed, scoped)
+
- [ ] **Content Rules Validation:** ⚠️ DEFERRED TO BETA - Posts validated against community content rules
+
- [ ] **Vote System:** ⚠️ DEFERRED TO BETA - Upvote/downvote with community-level controls
+
- [ ] **Moderator Permissions:** ⚠️ DEFERRED TO BETA - Community moderators can delete posts
+
+
### Testing Requirements
+
- [x] Create text post in community → Appears in AppView ✅
+
- [x] Post lives in community's repository (verify AT-URI owner) ✅
+
- [x] Post written to PDS → Broadcast to Jetstream → Indexed in AppView ✅
+
- [x] Handler security: Rejects client-provided authorDid ✅
+
- [x] Handler security: Requires authentication ✅
+
- [x] Handler security: Validates request body size ✅
+
- [x] Handler security: All 4 at-identifier formats accepted ✅
+
- [x] Consumer security: Rejects posts from wrong repository ✅
+
- [x] Consumer security: Verifies community and author exist ✅
+
- [ ] **Content rules validation:** Text-only community rejects image posts ⚠️ DEFERRED
+
- [ ] **Content rules validation:** Image community rejects posts without images ⚠️ DEFERRED
+
- [ ] **Content rules validation:** Post with too-short text rejected ⚠️ DEFERRED
+
- [ ] **Content rules validation:** Federated post rejected if `allowFederated: false` ⚠️ DEFERRED
+
- [ ] Update post within 24 hours → Edit reflected ⚠️ DEFERRED
+
- [ ] Delete post as author → Hidden from queries ⚠️ DEFERRED
+
- [ ] Delete post as moderator → Hidden from queries ⚠️ DEFERRED
+
- [ ] Upvote post → Count increments ⚠️ DEFERRED
+
- [ ] Downvote post → Count increments (if enabled) ⚠️ DEFERRED
+
- [ ] Toggle vote → Counts update correctly ⚠️ DEFERRED
+
- [ ] Community with downvotes disabled → Downvote returns error ⚠️ DEFERRED
+
+
---
+
+
## Beta Features (Post-Alpha)
+
+
### Advanced Post Types
+
**Status:** Deferred - Simplify for Alpha
+
**Rationale:** Text posts are sufficient for MVP, other types need more infrastructure
+
+
- [ ] **Image Posts:** Full image upload/processing pipeline
+
- Multi-image support (up to 4 images)
+
- Upload to community's PDS blob storage
+
- Thumbnail generation
+
- Alt text requirements (accessibility)
+
+
- [ ] **Video Posts:** Video hosting and processing
+
- Video upload to community's PDS blob storage
+
- Thumbnail extraction
+
- Format validation
+
- Streaming support
+
+
- [ ] **Microblog Posts:** Bluesky federation integration
+
- Fetch Bluesky posts by AT-URI
+
- Display inline with native posts
+
- Track original author info
+
- Federation metadata
+
+
- [ ] **Decision Point:** Remove "Article" type entirely?
+
- Obsoleted by planned RSS aggregation service
+
- LLMs will break down articles into digestible content
+
- May not need native article posting
+
+
### Post Interaction Features
+
+
#### Tagging System
+
- [x] Lexicon: `social.coves.interaction.tag` ✅
+
- [ ] **Known Tags:** helpful, insightful, spam, hostile, offtopic, misleading
+
- [ ] **Community Custom Tags:** Communities define their own tags
+
- [ ] **Aggregate Counts:** Track tag distribution on posts
+
- [ ] **Moderation Integration:** High spam/hostile tags trigger tribunal review
+
- [ ] **Reputation Impact:** Helpful/insightful tags boost author reputation
+
- [ ] **Tag Storage:** Tags live in **user's repository** (users own their tags)
+
+
#### Crossposting
+
- [x] Lexicon: `social.coves.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
+
- [ ] **Deduplication:** Show original + crosspost count (don't spam feeds)
+
- [ ] **Rules:** Communities can disable crossposting
+
+
#### Save Posts
+
- [ ] **Lexicon:** Create `social.coves.actor.savedPost` record type
+
- [ ] **Functionality:** Bookmark posts for later reading
+
- [ ] **Private List:** Saved posts stored in **user's repository**
+
- [ ] **AppView Query:** Endpoint to fetch user's saved posts
+
+
### Post Search
+
- [x] Lexicon: `social.coves.post.search` ✅
+
- [ ] **Search Parameters:**
+
- Query string (q)
+
- Filter by community
+
- Filter by author
+
- Filter by post type
+
- Filter by tags
+
- Sort: relevance, new, top
+
- Timeframe: hour, day, week, month, year, all
+
- [ ] **Implementation:**
+
- PostgreSQL full-text search (tsvector on title + content)
+
- Relevance ranking algorithm
+
- Pagination with cursor
+
+
### Edit History
+
- [ ] **Track Edits:** Store edit history in AppView (not in atProto record)
+
- [ ] **Edit Diff:** Show what changed between versions
+
- [ ] **Edit Log:** List all edits with timestamps and edit notes
+
- [ ] **Revision Viewing:** View previous versions of post
+
+
### Advanced Voting
+
+
#### Vote Weight by Reputation
+
- [ ] **Reputation Multiplier:** High-reputation users' votes count more
+
- [ ] **Community-Specific:** Reputation calculated per-community
+
- [ ] **Transparency:** Show vote weight in moderation logs (not public)
+
+
#### Fuzzing & Vote Obfuscation
+
- [ ] **Count Fuzzing:** Add noise to vote counts (prevent manipulation detection)
+
- [ ] **Delay Display:** Don't show exact counts for new posts (first hour)
+
- [ ] **Rate Limiting:** Prevent vote brigading
+
+
---
+
+
## Future Features
+
+
### Federation
+
+
#### Bluesky Integration
+
- [ ] **Display Bluesky Posts:** Show Bluesky posts in community feeds (microblog type)
+
- [ ] **Original Author Info:** Track Bluesky user metadata
+
- [ ] **No Native Commenting:** Users see Bluesky posts, can't comment (yet)
+
- [ ] **Reference Storage:** Store Bluesky AT-URI, don't duplicate content
+
+
#### ActivityPub Integration
+
- [ ] **Lemmy/Mbin Posts:** Convert ActivityPub posts to Coves posts
+
- [ ] **Bidirectional Sync:** Coves posts appear on Lemmy instances
+
- [ ] **User Identity Mapping:** Assign DIDs to ActivityPub users
+
- [ ] **Action Translation:** Upvotes ↔ ActivityPub likes
+
+
### Advanced Features
+
+
#### Post Scheduling
+
- [ ] Schedule posts for future publishing
+
- [ ] Edit scheduled posts before they go live
+
- [ ] Cancel scheduled posts
+
+
#### Post Templates
+
- [ ] Communities define post templates
+
- [ ] Auto-fill fields for common post types
+
- [ ] Game threads, event announcements, etc.
+
+
#### Polls
+
- [ ] Create polls in posts
+
- [ ] Multiple choice, ranked choice, approval voting
+
- [ ] Time-limited voting windows
+
- [ ] Results visualization
+
+
#### Location-Based Posting
+
- [x] Lexicon: `location` field in post record ✅
+
- [ ] **Geo-Tagging:** Attach coordinates to posts
+
- [ ] **Community Rules:** Require location for certain posts (local events)
+
- [ ] **Privacy:** User controls location precision
+
- [ ] **Discovery:** Filter posts by location
+
+
---
+
+
## Technical Decisions Log
+
+
### 2025-10-18: Content Rules Over Post Type Enum
+
**Decision:** Remove `postType` from post creation input; validate posts against community's `contentRules` instead
+
+
**Rationale:**
+
- `postType` enum forced users to explicitly select type (bad UX - app should infer from structure)
+
- Structure-based validation is more flexible ("text required, images optional" vs rigid type categories)
+
- Content rules are extensible without changing post lexicon
+
- Enables both community restrictions (governance) AND user filtering (UI preferences)
+
- Follows atProto philosophy: describe data structure, not UI intent
+
+
**Implementation:**
+
- Post creation no longer accepts `postType` parameter
+
- Community profile contains optional `contentRules` object
+
- Handler validates post structure against community's content rules
+
- AppView indexes derived characteristics (embed_type, text_length, has_title, has_embed)
+
- Validation error changed from `InvalidPostType` to `ContentRuleViolation`
+
+
**Database Changes:**
+
- Remove `post_type` enum column
+
- Add derived fields: `embed_type`, `text_length`, `has_title`, `has_embed`
+
- Add index on `embed_type` for filtering
+
+
**Example Rules:**
+
- Text-only community: `allowedEmbedTypes: []` + `requireText: true`
+
- Image community: `allowedEmbedTypes: ["images"]` + `minImages: 1`
+
- No restrictions: `contentRules: null`
+
+
**See:** [PRD_GOVERNANCE.md - Content Rules System](PRD_GOVERNANCE.md#content-rules-system)
+
+
---
+
+
### 2025-10-18: Posts Live in Community Repositories
+
**Decision:** Posts are stored in community's repository, not user's repository
+
+
**Rationale:**
+
- **Matches V2 Communities Architecture:** Communities own their repositories
+
- **Traditional Forum Model:** Community owns content, author tracked in metadata
+
- **Simpler Permissions:** Use community credentials for all post writes
+
- **Portability:** Posts migrate with community when changing instances
+
- **Moderation:** Community has full control over content
+
- **Reuses Token Refresh:** Can leverage existing community credential management
+
+
**Implementation Details:**
+
- Post AT-URI: `at://community_did/social.coves.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
+
- Token refresh reuses community's refresh logic
+
+
**Trade-offs vs User-Owned Posts:**
+
- ❌ Users can't take posts when leaving community/instance
+
- ❌ Less "web3" (content not user-owned)
+
- ✅ Traditional forum UX (users expect community to own content)
+
- ✅ Simpler implementation (one credential store per community)
+
- ✅ Easier moderation (community has full control)
+
- ✅ Posts move with community during migration
+
+
**Comparison to Bluesky:**
+
- Bluesky: Users own posts (posts in user repo)
+
- Coves: Communities own posts (posts in community repo)
+
- This is acceptable - different platforms, different models
+
- Still atProto-compliant (just different ownership pattern)
+
+
---
+
+
### 2025-10-18: Votes Live in User Repositories
+
**Decision:** Vote records are stored in user's repository, not community's
+
+
**Rationale:**
+
- Users own their voting history (personal preference)
+
- Matches Bluesky pattern (likes in user's repo)
+
- Enables portable voting history across instances
+
- User controls their own voting record
+
+
**Implementation Details:**
+
- Vote AT-URI: `at://user_did/social.coves.interaction.vote/tid`
+
- Write operations use user's PDS credentials
+
- Subject field references post AT-URI (in community's repo)
+
- Consumer aggregates votes from all users into post stats
+
+
---
+
+
### 2025-10-18: Simplify Post Types for Alpha
+
**Decision:** Launch with text posts only, defer other embed types to Beta
+
**Status:** SUPERSEDED by content rules approach (see above)
+
+
**Rationale:**
+
- Text posts are sufficient for forum discussions (core use case)
+
- Image/video embeds require additional infrastructure (blob storage, processing)
+
- Article format can be handled with long-form text posts
+
- Microblog type is for Bluesky federation (not immediate priority)
+
- Simplicity accelerates alpha launch
+
+
**Updated Approach (2025-10-18):**
+
- Post structure determines "type" (not explicit enum)
+
- Communities use `contentRules` to restrict embed types
+
- AppView derives `embed_type` from post structure for filtering
+
- More flexible than rigid type system
+
+
---
+
+
### 2025-10-18: Include Downvotes with Community Controls
+
**Decision:** Support both upvotes and downvotes, with toggles to disable downvotes
+
+
**Rationale:**
+
- Downvotes provide valuable signal for content quality
+
- Some communities prefer upvote-only (toxic negativity concerns)
+
- Instance operators should have global control option
+
- Reddit/HN have proven downvotes work with good moderation
+
+
**Implementation:**
+
- Community-level: `allowDownvotes` boolean in community profile
+
- Instance-level: Environment variable `ALLOW_DOWNVOTES` (future)
+
- Downvote attempts on disabled communities return error
+
- Stats show 0 downvotes when disabled
+
+
---
+
+
### 2025-10-18: 24-Hour Edit Window (Hardcoded for Alpha)
+
**Decision:** Posts can be edited for 24 hours after creation
+
+
**Rationale:**
+
- Allows fixing typos and errors
+
- Prevents historical revisionism (can't change old posts)
+
- 24 hours balances flexibility with integrity
+
- Future: Community-configurable edit windows
+
+
**Future Enhancements:**
+
- Edit history tracking (show what changed)
+
- Community-specific edit windows (0-72 hours)
+
- Moderator override (edit any post)
+
+
---
+
+
### 2025-10-18: Comments Separate from Posts PRD
+
**Decision:** Comments get their own dedicated PRD
+
+
**Rationale:**
+
- Comments are complex enough to warrant separate planning
+
- Threaded replies, vote inheritance, moderation all need design
+
- Posts are usable without comments (voting, tagging still work)
+
- Allows shipping posts sooner
+
+
**Scope Boundary:**
+
- **Posts PRD:** Post CRUD, voting, tagging, search
+
- **Comments PRD:** Comment threads, reply depth, sorting, moderation
+
+
---
+
+
### 2025-10-18: Feeds Separate from Posts PRD
+
**Decision:** Feed generation gets its own PRD
+
+
**Rationale:**
+
- Feed algorithms are complex (ranking, personalization, filtering)
+
- Posts need to exist before feeds can be built
+
- Feed work includes: Home feed, Community feed, All feed, read state tracking
+
- Allows iterating on feed algorithms independently
+
+
**Scope Boundary:**
+
- **Posts PRD:** Post creation, indexing, retrieval
+
- **Feeds PRD:** Feed generation, ranking algorithms, read state, personalization
+
+
---
+
+
## Success Metrics
+
+
### Alpha Launch Checklist ✅ COMPLETE (2025-10-19)
+
- [x] Users can create text posts in communities ✅
+
- [x] Posts are stored in community's repository (verify AT-URI) ✅
+
- [x] Posts use community's PDS credentials for writes ✅
+
- [x] Posts are indexed from firehose within 1 second ✅ (real-time Jetstream)
+
- [x] E2E tests cover full write-forward flow ✅
+
- [x] Database handles posts without performance issues ✅
+
- [x] Handler security tests passing (authentication, validation, body size) ✅
+
- [x] Consumer security validation (repository ownership, community/author checks) ✅
+
- [x] All 4 at-identifier formats supported ✅
+
+
### Beta Checklist (TODO)
+
- [ ] Post editing works within 24-hour window ⚠️ DEFERRED
+
- [ ] Upvote/downvote system functional ⚠️ DEFERRED
+
- [ ] Community downvote toggle works ⚠️ DEFERRED
+
- [ ] Post deletion soft-deletes and hides from queries ⚠️ DEFERRED
+
- [ ] Moderators can delete posts in their community ⚠️ DEFERRED
+
- [ ] Get post endpoint returns full post view with stats ⚠️ DEFERRED
+
- [ ] Content rules validation working ⚠️ DEFERRED
+
- [ ] Database handles 100,000+ posts (load testing)
+
+
### Beta Goals
+
- [ ] All post types supported (text, image, video, microblog)
+
- [ ] Tagging system enables community moderation
+
- [ ] Post search returns relevant results
+
- [ ] Edit history tracked and viewable
+
- [ ] Crossposting works across communities
+
- [ ] Save posts feature functional
+
+
### V1 Goals
+
- [ ] Bluesky posts display inline (federation)
+
- [ ] Vote fuzzing prevents manipulation
+
- [ ] Reputation affects vote weight
+
- [ ] Location-based posting for local communities
+
- [ ] Post templates reduce friction for common posts
+
+
---
+
+
## Related Documents
+
+
- [PRD_COMMUNITIES.md](PRD_COMMUNITIES.md) - Community system (posts require communities)
+
- [DOMAIN_KNOWLEDGE.md](DOMAIN_KNOWLEDGE.md) - Overall platform architecture
+
- [PRD_GOVERNANCE.md](PRD_GOVERNANCE.md) - Moderation and tagging systems
+
- **PRD_COMMENTS.md** (TODO) - Comment threading and replies
+
- **PRD_FEEDS.md** (TODO) - Feed generation and ranking algorithms
+
+
---
+
+
## Lexicon Summary
+
+
### `social.coves.post.record`
+
**Status:** ✅ Defined, implementation TODO
+
**Last Updated:** 2025-10-18 (removed `postType` enum)
+
+
**Required Fields:**
+
- `community` - DID of community (owner of repository)
+
- `createdAt` - Timestamp
+
+
**Optional Fields:**
+
- `title` - Post title (300 graphemes / 3000 bytes)
+
- `content` - Post content (50,000 characters max)
+
- `facets` - Rich text annotations
+
- `embed` - Images, video, external links, quoted posts (union type)
+
- `contentLabels` - Self-applied labels (nsfw, spoiler, violence)
+
- `originalAuthor` - For microblog posts (federated author info)
+
- `federatedFrom` - Reference to federated post
+
- `location` - Geographic coordinates
+
- `crosspostOf` - AT-URI of original post
+
- `crosspostChain` - Array of crosspost URIs
+
+
**Notes:**
+
- Author DID is inferred from the creation context (authenticated user), not stored in record
+
- 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)
+
**Status:** ✅ Defined, implementation TODO
+
**Last Updated:** 2025-10-18 (removed `postType` parameter)
+
+
**Input Parameters:**
+
- `community` (required) - DID or handle of community to post in
+
- `title` (optional) - Post title
+
- `content` (optional) - Post content
+
- `facets` (optional) - Rich text annotations
+
- `embed` (optional) - Embedded content (images, video, external, post)
+
- `contentLabels` (optional) - Self-applied labels
+
- `originalAuthor` (optional) - For federated posts
+
- `federatedFrom` (optional) - Reference to federated post
+
- `location` (optional) - Geographic coordinates
+
+
**Validation:**
+
- Community exists and is accessible
+
- Post structure complies with community's `contentRules`
+
- Content within global limits (unless community sets stricter limits)
+
+
**Errors:**
+
- `CommunityNotFound` - Community doesn't exist
+
- `NotAuthorized` - User not authorized to post
+
- `Banned` - User is banned from community
+
- `InvalidContent` - Content violates general rules
+
- `ContentRuleViolation` - Post violates community's content rules
+
+
---
+
+
### `social.coves.interaction.vote`
+
**Status:** ✅ Defined, implementation TODO
+
+
**Fields:**
+
- `subject` - AT-URI of post/comment being voted on
+
- `createdAt` - Timestamp
+
+
**Note:** Direction (up/down) inferred from record creation/deletion pattern. Stored in user's repository (user owns votes).
+
+
### `social.coves.interaction.tag`
+
**Status:** ✅ Defined, deferred to Beta
+
+
**Fields:**
+
- `subject` - AT-URI of post/comment
+
- `tag` - Tag string (known values: helpful, insightful, spam, hostile, offtopic, misleading)
+
- `createdAt` - Timestamp
+
+
**Note:** Tags live in user's repository (users own their tags).
+
+
---
+
+
## References
+
+
- atProto Lexicon Spec: https://atproto.com/specs/lexicon
+
- atProto Repository Spec: https://atproto.com/specs/repository
+
- Bluesky Post Record: https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json
+
- Rich Text Facets: https://atproto.com/specs/rich-text
+
- Coves V2 Communities Architecture: [PRD_COMMUNITIES.md](PRD_COMMUNITIES.md)
+106
internal/api/handlers/post/create.go
···
···
+
package post
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
+
"encoding/json"
+
"log"
+
"net/http"
+
"strings"
+
)
+
+
// CreateHandler handles post creation requests
+
type CreateHandler struct {
+
service posts.Service
+
}
+
+
// NewCreateHandler creates a new create handler
+
func NewCreateHandler(service posts.Service) *CreateHandler {
+
return &CreateHandler{
+
service: service,
+
}
+
}
+
+
// HandleCreate handles POST /xrpc/social.coves.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
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks
+
// 1MB allows for large content + embeds while preventing abuse
+
r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024)
+
+
// 3. Parse request body
+
var req posts.CreatePostRequest
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
// Check if error is due to body size limit
+
if err.Error() == "http: request body too large" {
+
writeError(w, http.StatusRequestEntityTooLarge, "RequestTooLarge",
+
"Request body too large (max 1MB)")
+
return
+
}
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Extract authenticated user DID from request context (injected by auth middleware)
+
userDID := middleware.GetUserDID(r)
+
if userDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Validate required fields
+
if req.Community == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// 5b. Basic format validation for better UX (fail fast on obviously invalid input)
+
// Valid formats accepted by resolver:
+
// - DID: did:plc:xyz, did:web:example.com
+
// - Scoped handle: !name@instance
+
// - Canonical handle: name.community.instance
+
// - @-prefixed handle: @name.community.instance
+
//
+
// We only reject obviously invalid formats here (no prefix, no dots, no @ for !)
+
// The service layer (ResolveCommunityIdentifier) does comprehensive validation
+
+
// Scoped handles must include @ symbol
+
if strings.HasPrefix(req.Community, "!") && !strings.Contains(req.Community, "@") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"scoped handle must include @ symbol (!name@instance)")
+
return
+
}
+
+
// 6. SECURITY: Reject client-provided authorDid
+
// This prevents users from impersonating other users
+
if req.AuthorDID != "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"authorDid must not be provided - derived from authenticated user")
+
return
+
}
+
+
// 7. Set author from authenticated user context
+
req.AuthorDID = userDID
+
+
// 8. Call service to create post (write-forward to PDS)
+
// Note: Service layer will resolve community at-identifier (handle or DID) to DID
+
response, err := h.service.CreatePost(r.Context(), req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 9. Return success response matching lexicon output
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
// Log encoding errors but don't return error response (headers already sent)
+
log.Printf("Failed to encode post creation response: %v", err)
+
}
+
}
+57
internal/api/handlers/post/errors.go
···
···
+
package post
+
+
import (
+
"Coves/internal/core/posts"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
type errorResponse struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
// writeError writes a JSON error response
+
func writeError(w http.ResponseWriter, statusCode int, errorType, message string) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(statusCode)
+
if err := json.NewEncoder(w).Encode(errorResponse{
+
Error: errorType,
+
Message: message,
+
}); err != nil {
+
log.Printf("Failed to encode error response: %v", err)
+
}
+
}
+
+
// handleServiceError maps service errors to HTTP responses
+
func handleServiceError(w http.ResponseWriter, err error) {
+
switch {
+
case err == posts.ErrCommunityNotFound:
+
writeError(w, http.StatusNotFound, "CommunityNotFound",
+
"Community not found")
+
+
case err == posts.ErrNotAuthorized:
+
writeError(w, http.StatusForbidden, "NotAuthorized",
+
"You are not authorized to post in this community")
+
+
case err == posts.ErrBanned:
+
writeError(w, http.StatusForbidden, "Banned",
+
"You are banned from this community")
+
+
case posts.IsContentRuleViolation(err):
+
writeError(w, http.StatusBadRequest, "ContentRuleViolation", err.Error())
+
+
case posts.IsValidationError(err):
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
+
case posts.IsNotFound(err):
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
+
default:
+
// Don't leak internal error details to clients
+
log.Printf("Unexpected error in post handler: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError",
+
"An internal error occurred")
+
}
+
}
+6
internal/api/middleware/auth.go
···
return claims
}
// GetUserAccessToken extracts the user's access token from the request context
// Returns empty string if not authenticated
func GetUserAccessToken(r *http.Request) string {
···
return claims
}
+
// SetTestUserDID sets the user DID in the context for testing purposes
+
// This function should ONLY be used in tests to mock authenticated users
+
func SetTestUserDID(ctx context.Context, userDID string) context.Context {
+
return context.WithValue(ctx, UserDIDKey, userDID)
+
}
+
// GetUserAccessToken extracts the user's access token from the request context
// Returns empty string if not authenticated
func GetUserAccessToken(r *http.Request) string {
+26
internal/api/routes/post.go
···
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterPostRoutes registers post-related XRPC endpoints on the router
+
// Implements social.coves.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)
+
+
// 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)
+
}
+243
internal/atproto/jetstream/post_consumer.go
···
···
+
package jetstream
+
+
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"strings"
+
"time"
+
)
+
+
// PostEventConsumer consumes post-related events from Jetstream
+
// Currently handles only CREATE operations for social.coves.post.record
+
// UPDATE and DELETE handlers will be added when those features are implemented
+
type PostEventConsumer struct {
+
postRepo posts.Repository
+
communityRepo communities.Repository
+
userService users.UserService
+
}
+
+
// NewPostEventConsumer creates a new Jetstream consumer for post events
+
func NewPostEventConsumer(
+
postRepo posts.Repository,
+
communityRepo communities.Repository,
+
userService users.UserService,
+
) *PostEventConsumer {
+
return &PostEventConsumer{
+
postRepo: postRepo,
+
communityRepo: communityRepo,
+
userService: userService,
+
}
+
}
+
+
// HandleEvent processes a Jetstream event for post records
+
// Currently only handles CREATE operations - UPDATE/DELETE deferred until those features exist
+
func (c *PostEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error {
+
// We only care about commit events for post records
+
if event.Kind != "commit" || event.Commit == nil {
+
return nil
+
}
+
+
commit := event.Commit
+
+
// 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" {
+
return c.createPost(ctx, event.Did, commit)
+
}
+
+
// Silently ignore other operations (update, delete) and other collections
+
return nil
+
}
+
+
// createPost indexes a new post from the firehose
+
func (c *PostEventConsumer) createPost(ctx context.Context, repoDID string, commit *CommitEvent) error {
+
if commit.Record == nil {
+
return fmt.Errorf("post create event missing record data")
+
}
+
+
// Parse the post record
+
postRecord, err := parsePostRecord(commit.Record)
+
if err != nil {
+
return fmt.Errorf("failed to parse post record: %w", err)
+
}
+
+
// SECURITY: Validate this is a legitimate post event
+
if err := c.validatePostEvent(ctx, repoDID, postRecord); err != nil {
+
log.Printf("🚨 SECURITY: Rejecting post event: %v", err)
+
return err
+
}
+
+
// 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)
+
+
// Parse timestamp from record
+
createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt)
+
if err != nil {
+
// Fallback to current time if parsing fails
+
log.Printf("Warning: Failed to parse createdAt timestamp, using current time: %v", err)
+
createdAt = time.Now()
+
}
+
+
// Build post entity
+
post := &posts.Post{
+
URI: uri,
+
CID: commit.CID,
+
RKey: commit.RKey,
+
AuthorDID: postRecord.Author,
+
CommunityDID: postRecord.Community,
+
Title: postRecord.Title,
+
Content: postRecord.Content,
+
CreatedAt: createdAt,
+
IndexedAt: time.Now(),
+
// Stats remain at 0 (no votes yet)
+
UpvoteCount: 0,
+
DownvoteCount: 0,
+
Score: 0,
+
CommentCount: 0,
+
}
+
+
// Serialize JSON fields (facets, embed, labels)
+
if postRecord.Facets != nil {
+
facetsJSON, marshalErr := json.Marshal(postRecord.Facets)
+
if marshalErr == nil {
+
facetsStr := string(facetsJSON)
+
post.ContentFacets = &facetsStr
+
}
+
}
+
+
if postRecord.Embed != nil {
+
embedJSON, marshalErr := json.Marshal(postRecord.Embed)
+
if marshalErr == nil {
+
embedStr := string(embedJSON)
+
post.Embed = &embedStr
+
}
+
}
+
+
if len(postRecord.ContentLabels) > 0 {
+
labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels)
+
if marshalErr == nil {
+
labelsStr := string(labelsJSON)
+
post.ContentLabels = &labelsStr
+
}
+
}
+
+
// Index in AppView database (idempotent - safe for Jetstream replays)
+
err = c.postRepo.Create(ctx, post)
+
if err != nil {
+
// Check if it already exists (idempotency)
+
if posts.IsConflict(err) {
+
log.Printf("Post already indexed: %s", uri)
+
return nil
+
}
+
return fmt.Errorf("failed to index post: %w", err)
+
}
+
+
log.Printf("✓ Indexed post: %s (author: %s, community: %s, rkey: %s)",
+
uri, post.AuthorDID, post.CommunityDID, commit.RKey)
+
return nil
+
}
+
+
// validatePostEvent performs security validation on post events
+
// This prevents malicious actors from indexing fake posts
+
func (c *PostEventConsumer) validatePostEvent(ctx context.Context, repoDID string, post *PostRecordFromJetstream) error {
+
// CRITICAL SECURITY CHECK:
+
// Posts MUST come from community repositories, not user repositories
+
// 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)
+
// - Claims it's for community X (community field = community_did)
+
// - Without this check, fake post would be indexed
+
//
+
// With this check:
+
// - We verify event.Did (repo owner) == post.community (claimed community)
+
// - Reject if mismatch
+
if repoDID != post.Community {
+
return fmt.Errorf("repository DID (%s) doesn't match community DID (%s) - posts must come from community repos",
+
repoDID, post.Community)
+
}
+
+
// CRITICAL: Verify community exists in AppView
+
// Posts MUST reference valid communities (enforced by FK constraint)
+
// If community isn't indexed yet, we must reject the post
+
// Jetstream will replay events, so the post will be indexed once community is ready
+
_, err := c.communityRepo.GetByDID(ctx, post.Community)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
// Reject - community must be indexed before posts
+
// This maintains referential integrity and prevents orphaned posts
+
return fmt.Errorf("community not found: %s - cannot index post before community", post.Community)
+
}
+
// Database error or other issue
+
return fmt.Errorf("failed to verify community exists: %w", err)
+
}
+
+
// CRITICAL: Verify author exists in AppView
+
// Every post MUST have a valid author (enforced by FK constraint)
+
// Even though posts live in community repos, they belong to specific authors
+
// If author isn't indexed yet, we must reject the post
+
_, err = c.userService.GetUserByDID(ctx, post.Author)
+
if err != nil {
+
// Check if it's a "not found" error using string matching
+
// (users package doesn't export IsNotFound)
+
if err.Error() == "user not found" || strings.Contains(err.Error(), "not found") {
+
// Reject - author must be indexed before posts
+
// This maintains referential integrity and prevents orphaned posts
+
return fmt.Errorf("author not found: %s - cannot index post before author", post.Author)
+
}
+
// Database error or other issue
+
return fmt.Errorf("failed to verify author exists: %w", err)
+
}
+
+
return nil
+
}
+
+
// PostRecordFromJetstream represents a post record as received from Jetstream
+
// Matches the structure written to PDS via social.coves.post.record
+
type PostRecordFromJetstream struct {
+
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
+
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
+
Location interface{} `json:"location,omitempty"`
+
Title *string `json:"title,omitempty"`
+
Content *string `json:"content,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Type string `json:"$type"`
+
Community string `json:"community"`
+
Author string `json:"author"`
+
CreatedAt string `json:"createdAt"`
+
Facets []interface{} `json:"facets,omitempty"`
+
ContentLabels []string `json:"contentLabels,omitempty"`
+
}
+
+
// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+
func parsePostRecord(record map[string]interface{}) (*PostRecordFromJetstream, error) {
+
// Marshal to JSON and back to ensure proper type conversion
+
recordJSON, err := json.Marshal(record)
+
if err != nil {
+
return nil, fmt.Errorf("failed to marshal record: %w", err)
+
}
+
+
var post PostRecordFromJetstream
+
if err := json.Unmarshal(recordJSON, &post); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal post record: %w", err)
+
}
+
+
// Validate required fields
+
if post.Community == "" {
+
return nil, fmt.Errorf("post record missing community field")
+
}
+
if post.Author == "" {
+
return nil, fmt.Errorf("post record missing author field")
+
}
+
if post.CreatedAt == "" {
+
return nil, fmt.Errorf("post record missing createdAt field")
+
}
+
+
return &post, nil
+
}
+125
internal/atproto/jetstream/post_jetstream_connector.go
···
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
+
"github.com/gorilla/websocket"
+
)
+
+
// PostJetstreamConnector handles WebSocket connection to Jetstream for post events
+
type PostJetstreamConnector struct {
+
consumer *PostEventConsumer
+
wsURL string
+
}
+
+
// NewPostJetstreamConnector creates a new Jetstream WebSocket connector for post events
+
func NewPostJetstreamConnector(consumer *PostEventConsumer, wsURL string) *PostJetstreamConnector {
+
return &PostJetstreamConnector{
+
consumer: consumer,
+
wsURL: wsURL,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *PostJetstreamConnector) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream post consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream post consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream post connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *PostJetstreamConnector) connect(ctx context.Context) error {
+
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() {
+
if closeErr := conn.Close(); closeErr != nil {
+
log.Printf("Failed to close WebSocket connection: %v", closeErr)
+
}
+
}()
+
+
log.Println("Connected to Jetstream (post consumer)")
+
+
// Set read deadline to detect connection issues
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline in pong handler: %v", err)
+
}
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
var closeOnce sync.Once // Ensure done channel is only closed once
+
+
// Ping goroutine
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+
log.Printf("Failed to send ping: %v", err)
+
closeOnce.Do(func() { close(done) })
+
return
+
}
+
case <-done:
+
return
+
}
+
}
+
}()
+
+
// Read loop
+
for {
+
select {
+
case <-done:
+
return fmt.Errorf("connection closed by ping failure")
+
default:
+
}
+
+
_, message, err := conn.ReadMessage()
+
if err != nil {
+
closeOnce.Do(func() { close(done) })
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Parse Jetstream event
+
var event JetstreamEvent
+
if err := json.Unmarshal(message, &event); err != nil {
+
log.Printf("Failed to parse Jetstream event: %v", err)
+
continue
+
}
+
+
// Process event through consumer
+
if err := c.consumer.HandleEvent(ctx, &event); err != nil {
+
log.Printf("Failed to handle post event: %v", err)
+
// Continue processing other events even if one fails
+
}
+
}
+
}
+54
internal/atproto/lexicon/social/coves/community/profile.json
···
"ref": "#federationConfig",
"description": "Federation and discovery configuration"
},
"moderationType": {
"type": "string",
"knownValues": ["moderator", "sortition"],
···
"type": "boolean",
"default": true,
"description": "Whether other Coves instances can index and discover this community"
}
}
}
···
"ref": "#federationConfig",
"description": "Federation and discovery configuration"
},
+
"contentRules": {
+
"type": "ref",
+
"ref": "#contentRules",
+
"description": "Content posting rules and restrictions for this community"
+
},
"moderationType": {
"type": "string",
"knownValues": ["moderator", "sortition"],
···
"type": "boolean",
"default": true,
"description": "Whether other Coves instances can index and discover this community"
+
}
+
}
+
},
+
"contentRules": {
+
"type": "object",
+
"description": "Content posting rules and restrictions for this community. Rules are validated at post creation time.",
+
"properties": {
+
"allowedEmbedTypes": {
+
"type": "array",
+
"description": "Allowed embed types. Empty array = no embeds allowed. Null/undefined = all embed types allowed.",
+
"items": {
+
"type": "string",
+
"knownValues": ["images", "video", "external", "record"]
+
}
+
},
+
"requireText": {
+
"type": "boolean",
+
"default": false,
+
"description": "Whether posts must have text content (non-empty content field)"
+
},
+
"minTextLength": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Minimum character length for post content (0 = no minimum)"
+
},
+
"maxTextLength": {
+
"type": "integer",
+
"minimum": 1,
+
"description": "Maximum character length for post content (overrides global limit if lower)"
+
},
+
"requireTitle": {
+
"type": "boolean",
+
"default": false,
+
"description": "Whether posts must have a title"
+
},
+
"minImages": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Minimum number of images required (0 = no minimum). Only enforced if images embed is present."
+
},
+
"maxImages": {
+
"type": "integer",
+
"minimum": 1,
+
"description": "Maximum number of images allowed per post (overrides global limit if lower)"
+
},
+
"allowFederated": {
+
"type": "boolean",
+
"default": true,
+
"description": "Whether federated posts (e.g., from app.bsky) are allowed in this community"
}
}
}
+2 -2
internal/atproto/lexicon/social/coves/feed/getAll.json
···
"postType": {
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"],
-
"description": "Filter by a single post type"
},
"postTypes": {
"type": "array",
···
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"]
},
-
"description": "Filter by multiple post types"
},
"timeframe": {
"type": "string",
···
"postType": {
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"],
+
"description": "Filter by a single post type (computed from embed structure)"
},
"postTypes": {
"type": "array",
···
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"]
},
+
"description": "Filter by multiple post types (computed from embed structure)"
},
"timeframe": {
"type": "string",
+2 -2
internal/atproto/lexicon/social/coves/feed/getCommunity.json
···
"postType": {
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"],
-
"description": "Filter by a single post type"
},
"postTypes": {
"type": "array",
···
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"]
},
-
"description": "Filter by multiple post types"
},
"timeframe": {
"type": "string",
···
"postType": {
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"],
+
"description": "Filter by a single post type (computed from embed structure)"
},
"postTypes": {
"type": "array",
···
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"]
},
+
"description": "Filter by multiple post types (computed from embed structure)"
},
"timeframe": {
"type": "string",
+2 -2
internal/atproto/lexicon/social/coves/feed/getTimeline.json
···
"postType": {
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"],
-
"description": "Filter by a single post type"
},
"postTypes": {
"type": "array",
···
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"]
},
-
"description": "Filter by multiple post types"
},
"limit": {
"type": "integer",
···
"postType": {
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"],
+
"description": "Filter by a single post type (computed from embed structure)"
},
"postTypes": {
"type": "array",
···
"type": "string",
"enum": ["text", "article", "image", "video", "microblog"]
},
+
"description": "Filter by multiple post types (computed from embed structure)"
},
"limit": {
"type": "integer",
+3 -8
internal/atproto/lexicon/social/coves/post/create.json
···
"encoding": "application/json",
"schema": {
"type": "object",
-
"required": ["community", "postType"],
"properties": {
"community": {
"type": "string",
"format": "at-identifier",
"description": "DID or handle of the community to post in"
-
},
-
"postType": {
-
"type": "string",
-
"enum": ["text", "article", "image", "video", "microblog"],
-
"description": "Type of post to create"
},
"title": {
"type": "string",
···
"description": "Post content violates community rules"
},
{
-
"name": "InvalidPostType",
-
"description": "Community does not allow this post type"
}
]
}
···
"encoding": "application/json",
"schema": {
"type": "object",
+
"required": ["community"],
"properties": {
"community": {
"type": "string",
"format": "at-identifier",
"description": "DID or handle of the community to post in"
},
"title": {
"type": "string",
···
"description": "Post content violates community rules"
},
{
+
"name": "ContentRuleViolation",
+
"description": "Post violates community content rules (e.g., embeds not allowed, text too short)"
}
]
}
+1 -5
internal/atproto/lexicon/social/coves/post/get.json
···
},
"postView": {
"type": "object",
-
"required": ["uri", "cid", "author", "record", "community", "postType", "createdAt", "indexedAt"],
"properties": {
"uri": {
"type": "string",
···
"community": {
"type": "ref",
"ref": "#communityRef"
-
},
-
"postType": {
-
"type": "string",
-
"enum": ["text", "image", "video", "article", "microblog"]
},
"title": {
"type": "string"
···
},
"postView": {
"type": "object",
+
"required": ["uri", "cid", "author", "record", "community", "createdAt", "indexedAt"],
"properties": {
"uri": {
"type": "string",
···
"community": {
"type": "ref",
"ref": "#communityRef"
},
"title": {
"type": "string"
+4 -4
internal/atproto/lexicon/social/coves/post/record.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": ["$type", "community", "postType", "createdAt"],
"properties": {
"$type": {
"type": "string",
···
"format": "at-identifier",
"description": "DID or handle of the community this was posted to"
},
-
"postType": {
"type": "string",
-
"enum": ["text", "article", "image", "video", "microblog"],
-
"description": "Discriminator for post type to enable filtering and specialized rendering"
},
"title": {
"type": "string",
···
"key": "tid",
"record": {
"type": "object",
+
"required": ["$type", "community", "author", "createdAt"],
"properties": {
"$type": {
"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",
+3 -3
internal/core/communities/community.go
···
HostedByDID string `json:"hostedByDid" db:"hosted_by_did"`
PDSEmail string `json:"-" db:"pds_email"`
PDSPassword string `json:"-" db:"pds_password_encrypted"`
-
Name string `json:"name" db:"name"` // Short name (e.g., "gardening")
-
DisplayHandle string `json:"displayHandle,omitempty" db:"-"` // UI hint: !gardening@coves.social (computed, not stored)
RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
FederatedID string `json:"federatedId,omitempty" db:"federated_id"`
PDSAccessToken string `json:"-" db:"pds_access_token"`
SigningKeyPEM string `json:"-" db:"signing_key_encrypted"`
ModerationType string `json:"moderationType,omitempty" db:"moderation_type"`
-
Handle string `json:"handle" db:"handle"` // Canonical atProto handle (e.g., gardening.community.coves.social)
PDSRefreshToken string `json:"-" db:"pds_refresh_token"`
Visibility string `json:"visibility" db:"visibility"`
RotationKeyPEM string `json:"-" db:"rotation_key_encrypted"`
···
HostedByDID string `json:"hostedByDid" db:"hosted_by_did"`
PDSEmail string `json:"-" db:"pds_email"`
PDSPassword string `json:"-" db:"pds_password_encrypted"`
+
Name string `json:"name" db:"name"` // Short name (e.g., "gardening")
+
DisplayHandle string `json:"displayHandle,omitempty" db:"-"` // UI hint: !gardening@coves.social (computed, not stored)
RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
FederatedID string `json:"federatedId,omitempty" db:"federated_id"`
PDSAccessToken string `json:"-" db:"pds_access_token"`
SigningKeyPEM string `json:"-" db:"signing_key_encrypted"`
ModerationType string `json:"moderationType,omitempty" db:"moderation_type"`
+
Handle string `json:"handle" db:"handle"` // Canonical atProto handle (e.g., gardening.community.coves.social)
PDSRefreshToken string `json:"-" db:"pds_refresh_token"`
Visibility string `json:"visibility" db:"visibility"`
RotationKeyPEM string `json:"-" db:"rotation_key_encrypted"`
+6
internal/core/communities/interfaces.go
···
// Validation helpers
ValidateHandle(handle string) error
ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) // Returns DID from handle or DID
}
···
// Validation helpers
ValidateHandle(handle string) error
ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) // Returns DID from handle or DID
+
+
// Token management (for post service to use when writing to community repos)
+
EnsureFreshToken(ctx context.Context, community *Community) (*Community, error)
+
+
// Direct repository access (for post service)
+
GetByDID(ctx context.Context, did string) (*Community, error)
}
+25 -10
internal/core/communities/service.go
···
return nil, NewValidationError("identifier", "must be a DID or handle")
}
// UpdateCommunity updates a community via write-forward to PDS
func (s *communityService) UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error) {
if req.CommunityDID == "" {
···
// CRITICAL: Ensure fresh PDS access token before write operation
// Community PDS tokens expire every ~2 hours and must be refreshed
-
existing, err = s.ensureFreshToken(ctx, existing)
if err != nil {
return nil, fmt.Errorf("failed to ensure fresh credentials: %w", err)
}
···
// ensureFreshToken checks if a community's access token needs refresh and updates if needed
// Returns updated community with fresh credentials (or original if no refresh needed)
// Thread-safe: Uses per-community mutex to prevent concurrent refresh attempts
-
func (s *communityService) ensureFreshToken(ctx context.Context, community *Community) (*Community, error) {
// Get or create mutex for this specific community DID
mutex := s.getOrCreateRefreshMutex(community.DID)
···
// Following Bluesky's pattern with Coves extensions:
//
// Accepts (like Bluesky's at-identifier):
-
// 1. DID: did:plc:abc123 (pass through)
-
// 2. Canonical handle: gardening.community.coves.social (atProto standard)
-
// 3. At-identifier: @gardening.community.coves.social (strip @ prefix)
//
// Coves-specific extensions:
-
// 4. Scoped format: !gardening@coves.social (parse and resolve)
//
// Returns: DID string
func (s *communityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
···
}
// 3. At-identifier format: @handle (Bluesky standard - strip @ prefix)
-
if strings.HasPrefix(identifier, "@") {
-
identifier = strings.TrimPrefix(identifier, "@")
-
}
// 4. Canonical handle: name.community.instance.com (Bluesky standard)
if strings.Contains(identifier, ".") {
···
// resolveScopedIdentifier handles Coves-specific !name@instance format
// Formats accepted:
-
// !gardening@coves.social -> gardening.community.coves.social
func (s *communityService) resolveScopedIdentifier(ctx context.Context, scoped string) (string, error) {
// Remove ! prefix
scoped = strings.TrimPrefix(scoped, "!")
···
return nil, NewValidationError("identifier", "must be a DID or handle")
}
+
// GetByDID retrieves a community by its DID
+
// Exported for use by post service when validating community references
+
func (s *communityService) GetByDID(ctx context.Context, did string) (*Community, error) {
+
if did == "" {
+
return nil, ErrInvalidInput
+
}
+
+
if !strings.HasPrefix(did, "did:") {
+
return nil, NewValidationError("did", "must be a valid DID")
+
}
+
+
return s.repo.GetByDID(ctx, did)
+
}
+
// UpdateCommunity updates a community via write-forward to PDS
func (s *communityService) UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error) {
if req.CommunityDID == "" {
···
// CRITICAL: Ensure fresh PDS access token before write operation
// Community PDS tokens expire every ~2 hours and must be refreshed
+
existing, err = s.EnsureFreshToken(ctx, existing)
if err != nil {
return nil, fmt.Errorf("failed to ensure fresh credentials: %w", err)
}
···
// ensureFreshToken checks if a community's access token needs refresh and updates if needed
// Returns updated community with fresh credentials (or original if no refresh needed)
// Thread-safe: Uses per-community mutex to prevent concurrent refresh attempts
+
// EnsureFreshToken ensures the community's PDS access token is valid
+
// Exported for use by post service when writing posts to community repos
+
func (s *communityService) EnsureFreshToken(ctx context.Context, community *Community) (*Community, error) {
// Get or create mutex for this specific community DID
mutex := s.getOrCreateRefreshMutex(community.DID)
···
// Following Bluesky's pattern with Coves extensions:
//
// Accepts (like Bluesky's at-identifier):
+
// 1. DID: did:plc:abc123 (pass through)
+
// 2. Canonical handle: gardening.community.coves.social (atProto standard)
+
// 3. At-identifier: @gardening.community.coves.social (strip @ prefix)
//
// Coves-specific extensions:
+
// 4. Scoped format: !gardening@coves.social (parse and resolve)
//
// Returns: DID string
func (s *communityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
···
}
// 3. At-identifier format: @handle (Bluesky standard - strip @ prefix)
+
identifier = strings.TrimPrefix(identifier, "@")
// 4. Canonical handle: name.community.instance.com (Bluesky standard)
if strings.Contains(identifier, ".") {
···
// resolveScopedIdentifier handles Coves-specific !name@instance format
// Formats accepted:
+
//
+
// !gardening@coves.social -> gardening.community.coves.social
func (s *communityService) resolveScopedIdentifier(ctx context.Context, scoped string) (string, error) {
// Remove ! prefix
scoped = strings.TrimPrefix(scoped, "!")
+123
internal/core/posts/errors.go
···
···
+
package posts
+
+
import (
+
"errors"
+
"fmt"
+
)
+
+
// Sentinel errors for common post operations
+
var (
+
// ErrCommunityNotFound is returned when the community doesn't exist in AppView
+
ErrCommunityNotFound = errors.New("community not found")
+
+
// ErrNotAuthorized is returned when user isn't authorized to post in community
+
// (e.g., banned, private community without membership - Beta)
+
ErrNotAuthorized = errors.New("user not authorized to post in this community")
+
+
// ErrBanned is returned when user is banned from community (Beta)
+
ErrBanned = errors.New("user is banned from this community")
+
+
// ErrInvalidContent is returned for general content violations
+
ErrInvalidContent = errors.New("invalid post content")
+
+
// ErrNotFound is returned when a post is not found by URI
+
ErrNotFound = errors.New("post not found")
+
)
+
+
// ValidationError represents a validation error with field context
+
type ValidationError struct {
+
Field string
+
Message string
+
}
+
+
func (e *ValidationError) Error() string {
+
return fmt.Sprintf("validation error (%s): %s", e.Field, e.Message)
+
}
+
+
// NewValidationError creates a new validation error
+
func NewValidationError(field, message string) error {
+
return &ValidationError{
+
Field: field,
+
Message: message,
+
}
+
}
+
+
// IsValidationError checks if error is a validation error
+
func IsValidationError(err error) bool {
+
var valErr *ValidationError
+
return errors.As(err, &valErr)
+
}
+
+
// ContentRuleViolation represents a violation of community content rules
+
// (Deferred to Beta - included here for future compatibility)
+
type ContentRuleViolation struct {
+
Rule string // e.g., "requireText", "allowedEmbedTypes"
+
Message string // Human-readable explanation
+
}
+
+
func (e *ContentRuleViolation) Error() string {
+
return fmt.Sprintf("content rule violation (%s): %s", e.Rule, e.Message)
+
}
+
+
// NewContentRuleViolation creates a new content rule violation error
+
func NewContentRuleViolation(rule, message string) error {
+
return &ContentRuleViolation{
+
Rule: rule,
+
Message: message,
+
}
+
}
+
+
// IsContentRuleViolation checks if error is a content rule violation
+
func IsContentRuleViolation(err error) bool {
+
var violation *ContentRuleViolation
+
return errors.As(err, &violation)
+
}
+
+
// NotFoundError represents a resource not found error
+
type NotFoundError struct {
+
Resource string // e.g., "post", "community"
+
ID string // Resource identifier
+
}
+
+
func (e *NotFoundError) Error() string {
+
return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
+
}
+
+
// NewNotFoundError creates a new not found error
+
func NewNotFoundError(resource, id string) error {
+
return &NotFoundError{
+
Resource: resource,
+
ID: id,
+
}
+
}
+
+
// IsNotFound checks if error is a not found error
+
func IsNotFound(err error) bool {
+
var notFoundErr *NotFoundError
+
return errors.As(err, &notFoundErr) || err == ErrCommunityNotFound || err == ErrNotFound
+
}
+
+
// IsConflict checks if error is due to duplicate/conflict
+
func IsConflict(err error) bool {
+
if err == nil {
+
return false
+
}
+
// Check for common conflict indicators in error message
+
errStr := err.Error()
+
return contains(errStr, "already indexed") ||
+
contains(errStr, "duplicate key") ||
+
contains(errStr, "already exists")
+
}
+
+
func contains(s, substr string) bool {
+
return len(s) >= len(substr) && anySubstring(s, substr)
+
}
+
+
func anySubstring(s, substr string) bool {
+
for i := 0; i <= len(s)-len(substr); i++ {
+
if s[i:i+len(substr)] == substr {
+
return true
+
}
+
}
+
return false
+
}
+35
internal/core/posts/interfaces.go
···
···
+
package posts
+
+
import "context"
+
+
// Service defines the business logic interface for posts
+
// Coordinates between Repository, community service, and PDS
+
type Service interface {
+
// CreatePost creates a new post in a community
+
// Flow: Validate -> Fetch community -> Ensure fresh token -> Write to PDS -> Return URI/CID
+
// AppView indexing happens asynchronously via Jetstream consumer
+
CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error)
+
+
// Future methods (Beta):
+
// GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error)
+
// UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error)
+
// DeletePost(ctx context.Context, uri string, userDID string) error
+
// ListCommunityPosts(ctx context.Context, communityDID string, limit, offset int) ([]*Post, error)
+
}
+
+
// Repository defines the data access interface for posts
+
// Used by Jetstream consumer to index posts from firehose
+
type Repository interface {
+
// Create inserts a new post into the AppView database
+
// Called by Jetstream consumer after post is created on PDS
+
Create(ctx context.Context, post *Post) error
+
+
// GetByURI retrieves a post by its AT-URI
+
// Used for E2E test verification and future GET endpoint
+
GetByURI(ctx context.Context, uri string) (*Post, error)
+
+
// Future methods (Beta):
+
// Update(ctx context.Context, post *Post) error
+
// Delete(ctx context.Context, uri string) error
+
// List(ctx context.Context, communityDID string, limit, offset int) ([]*Post, int, error)
+
}
+68
internal/core/posts/post.go
···
···
+
package posts
+
+
import (
+
"time"
+
)
+
+
// Post represents a post in the AppView database
+
// Posts are indexed from the firehose after being written to community repositories
+
type Post struct {
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
+
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"`
+
Title *string `json:"title,omitempty" db:"title"`
+
Content *string `json:"content,omitempty" db:"content"`
+
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
+
CID string `json:"cid" db:"cid"`
+
CommunityDID string `json:"communityDid" db:"community_did"`
+
RKey string `json:"rkey" db:"rkey"`
+
URI string `json:"uri" db:"uri"`
+
AuthorDID string `json:"authorDid" db:"author_did"`
+
ID int64 `json:"id" db:"id"`
+
UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
+
DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
+
Score int `json:"score" db:"score"`
+
CommentCount int `json:"commentCount" db:"comment_count"`
+
}
+
+
// CreatePostRequest represents input for creating a new post
+
// Matches social.coves.post.create lexicon input schema
+
type CreatePostRequest struct {
+
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
+
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
+
Location interface{} `json:"location,omitempty"`
+
Title *string `json:"title,omitempty"`
+
Content *string `json:"content,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Community string `json:"community"`
+
AuthorDID string `json:"authorDid"`
+
Facets []interface{} `json:"facets,omitempty"`
+
ContentLabels []string `json:"contentLabels,omitempty"`
+
}
+
+
// CreatePostResponse represents the response from creating a post
+
// Matches social.coves.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
+
}
+
+
// PostRecord represents the actual atProto record structure written to PDS
+
// This is the data structure that gets stored in the community's repository
+
type PostRecord struct {
+
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
+
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
+
Location interface{} `json:"location,omitempty"`
+
Title *string `json:"title,omitempty"`
+
Content *string `json:"content,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Type string `json:"$type"`
+
Community string `json:"community"`
+
Author string `json:"author"`
+
CreatedAt string `json:"createdAt"`
+
Facets []interface{} `json:"facets,omitempty"`
+
ContentLabels []string `json:"contentLabels,omitempty"`
+
}
+261
internal/core/posts/service.go
···
···
+
package posts
+
+
import (
+
"Coves/internal/core/communities"
+
"bytes"
+
"context"
+
"encoding/json"
+
"fmt"
+
"io"
+
"log"
+
"net/http"
+
"time"
+
)
+
+
type postService struct {
+
repo Repository
+
communityService communities.Service
+
pdsURL string
+
}
+
+
// NewPostService creates a new post service
+
func NewPostService(
+
repo Repository,
+
communityService communities.Service,
+
pdsURL string,
+
) Service {
+
return &postService{
+
repo: repo,
+
communityService: communityService,
+
pdsURL: pdsURL,
+
}
+
}
+
+
// CreatePost creates a new post in a community
+
// Flow:
+
// 1. Validate input
+
// 2. Resolve community at-identifier (handle or DID) to DID
+
// 3. Fetch community from AppView
+
// 4. Ensure community has fresh PDS credentials
+
// 5. Build post record
+
// 6. Write to community's PDS repository
+
// 7. Return URI/CID (AppView indexes asynchronously via Jetstream)
+
func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {
+
// 1. Validate basic input
+
if err := s.validateCreateRequest(req); err != nil {
+
return nil, err
+
}
+
+
// 2. Resolve community at-identifier (handle or DID) to DID
+
// This accepts both formats per atProto best practices:
+
// - Handles: !gardening.communities.coves.social
+
// - DIDs: did:plc:abc123 or did:web:coves.social
+
communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community)
+
if err != nil {
+
// Handle specific error types appropriately
+
if communities.IsNotFound(err) {
+
return nil, ErrCommunityNotFound
+
}
+
if communities.IsValidationError(err) {
+
// Pass through validation errors (invalid format, etc.)
+
return nil, NewValidationError("community", err.Error())
+
}
+
// Infrastructure failures (DB errors, network issues) should be internal errors
+
// Don't leak internal details to client (e.g., "pq: connection refused")
+
return nil, fmt.Errorf("failed to resolve community identifier: %w", err)
+
}
+
+
// 3. Fetch community from AppView (includes all metadata)
+
community, err := s.communityService.GetByDID(ctx, communityDID)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
return nil, ErrCommunityNotFound
+
}
+
return nil, fmt.Errorf("failed to fetch community: %w", err)
+
}
+
+
// 4. Check community visibility (Alpha: public/unlisted only)
+
// Beta will add membership checks for private communities
+
if community.Visibility == "private" {
+
return nil, ErrNotAuthorized
+
}
+
+
// 5. Ensure community has fresh PDS credentials (token refresh if needed)
+
community, err = s.communityService.EnsureFreshToken(ctx, community)
+
if err != nil {
+
return nil, fmt.Errorf("failed to refresh community credentials: %w", err)
+
}
+
+
// 6. Build post record for PDS
+
postRecord := PostRecord{
+
Type: "social.coves.post.record",
+
Community: communityDID,
+
Author: req.AuthorDID,
+
Title: req.Title,
+
Content: req.Content,
+
Facets: req.Facets,
+
Embed: req.Embed,
+
ContentLabels: req.ContentLabels,
+
OriginalAuthor: req.OriginalAuthor,
+
FederatedFrom: req.FederatedFrom,
+
Location: req.Location,
+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
+
}
+
+
// 7. Write to community's PDS repository
+
uri, cid, err := s.createPostOnPDS(ctx, community, postRecord)
+
if err != nil {
+
return nil, fmt.Errorf("failed to write post to PDS: %w", err)
+
}
+
+
// 8. Return response (AppView will index via Jetstream consumer)
+
log.Printf("[POST-CREATE] Author: %s, Community: %s, URI: %s", req.AuthorDID, communityDID, uri)
+
+
return &CreatePostResponse{
+
URI: uri,
+
CID: cid,
+
}, nil
+
}
+
+
// validateCreateRequest validates basic input requirements
+
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)
+
)
+
+
// Validate community required
+
if req.Community == "" {
+
return NewValidationError("community", "community is required")
+
}
+
+
// Validate author DID set by handler
+
if req.AuthorDID == "" {
+
return NewValidationError("authorDid", "authorDid must be set from authenticated user")
+
}
+
+
// Validate content length
+
if req.Content != nil && len(*req.Content) > maxContentLength {
+
return NewValidationError("content",
+
fmt.Sprintf("content too long (max %d characters)", maxContentLength))
+
}
+
+
// Validate title length
+
if req.Title != nil {
+
if len(*req.Title) > maxTitleLength {
+
return NewValidationError("title",
+
fmt.Sprintf("title too long (max %d bytes)", maxTitleLength))
+
}
+
// Simplified grapheme check (actual implementation would need unicode library)
+
// For Alpha, byte length check is sufficient
+
}
+
+
// 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))
+
}
+
}
+
+
return nil
+
}
+
+
// createPostOnPDS writes a post record to the community's PDS repository
+
// Uses com.atproto.repo.createRecord endpoint
+
func (s *postService) createPostOnPDS(
+
ctx context.Context,
+
community *communities.Community,
+
record PostRecord,
+
) (uri, cid string, err error) {
+
// Use community's PDS URL (not service default) for federated communities
+
// Each community can be hosted on a different PDS instance
+
pdsURL := community.PDSURL
+
if pdsURL == "" {
+
// Fallback to service default if community doesn't have a PDS URL
+
// (shouldn't happen in practice, but safe default)
+
pdsURL = s.pdsURL
+
}
+
+
// Build PDS endpoint URL
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL)
+
+
// Build request payload
+
// 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
+
// "rkey" omitted - PDS will auto-generate TID
+
}
+
+
// Marshal payload
+
jsonData, err := json.Marshal(payload)
+
if err != nil {
+
return "", "", fmt.Errorf("failed to marshal post payload: %w", err)
+
}
+
+
// Create HTTP request
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
+
if err != nil {
+
return "", "", fmt.Errorf("failed to create PDS request: %w", err)
+
}
+
+
// Set headers (auth + content type)
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken)
+
+
// Extended timeout for write operations (30 seconds)
+
client := &http.Client{
+
Timeout: 30 * time.Second,
+
}
+
+
// Execute request
+
resp, err := client.Do(req)
+
if err != nil {
+
return "", "", fmt.Errorf("PDS request failed: %w", err)
+
}
+
defer func() {
+
if closeErr := resp.Body.Close(); closeErr != nil {
+
log.Printf("Warning: failed to close response body: %v", closeErr)
+
}
+
}()
+
+
// Read response body
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
return "", "", fmt.Errorf("failed to read PDS response: %w", err)
+
}
+
+
// Check for errors
+
if resp.StatusCode != http.StatusOK {
+
// Sanitize error body for logging (prevent sensitive data leakage)
+
bodyPreview := string(body)
+
if len(bodyPreview) > 200 {
+
bodyPreview = bodyPreview[:200] + "... (truncated)"
+
}
+
log.Printf("[POST-CREATE-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview)
+
+
// Return truncated error (defense in depth - handler will mask this further)
+
return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview)
+
}
+
+
// Parse response
+
var result struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
if err := json.Unmarshal(body, &result); err != nil {
+
return "", "", fmt.Errorf("failed to parse PDS response: %w", err)
+
}
+
+
return result.URI, result.CID, nil
+
}
+51
internal/db/migrations/011_create_posts_table.sql
···
···
+
-- +goose Up
+
-- Create posts table for AppView indexing
+
-- Posts are indexed from the firehose after being written to community repositories
+
CREATE TABLE posts (
+
id BIGSERIAL PRIMARY KEY,
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://community_did/social.coves.post.record/rkey)
+
cid TEXT NOT NULL, -- Content ID
+
rkey TEXT NOT NULL, -- Record key (TID)
+
author_did TEXT NOT NULL, -- Author's DID (from record metadata)
+
community_did TEXT NOT NULL, -- Community DID (from AT-URI repo field)
+
+
-- Content (all nullable per lexicon)
+
title TEXT, -- Post title
+
content TEXT, -- Post content/body
+
content_facets JSONB, -- Rich text facets (app.bsky.richtext.facet)
+
embed JSONB, -- Embedded content (images, video, external, record)
+
content_labels TEXT[], -- Self-applied labels (nsfw, spoiler, violence)
+
+
-- Timestamps
+
created_at TIMESTAMPTZ NOT NULL, -- Author's timestamp from record
+
edited_at TIMESTAMPTZ, -- Last edit timestamp (future)
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView
+
deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events)
+
+
-- Stats (denormalized for performance)
+
upvote_count INT NOT NULL DEFAULT 0,
+
downvote_count INT NOT NULL DEFAULT 0,
+
score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count (for sorting)
+
comment_count INT NOT NULL DEFAULT 0,
+
+
-- Foreign keys
+
CONSTRAINT fk_author FOREIGN KEY (author_did) REFERENCES users(did) ON DELETE CASCADE,
+
CONSTRAINT fk_community FOREIGN KEY (community_did) REFERENCES communities(did) ON DELETE CASCADE
+
);
+
+
-- Indexes for common query patterns
+
CREATE INDEX idx_posts_community_created ON posts(community_did, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_posts_community_score ON posts(community_did, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_posts_author ON posts(author_did, created_at DESC);
+
CREATE INDEX idx_posts_uri ON posts(uri);
+
+
-- Index for full-text search on content (future)
+
-- CREATE INDEX idx_posts_content_search ON posts USING gin(to_tsvector('english', content)) WHERE deleted_at IS NULL;
+
+
-- Comment on table
+
COMMENT ON TABLE posts IS 'Posts indexed from community repositories via Jetstream firehose consumer';
+
COMMENT ON COLUMN posts.uri IS 'AT-URI in format: at://community_did/social.coves.post.record/rkey';
+
COMMENT ON COLUMN posts.score IS 'Computed as upvote_count - downvote_count for ranking algorithms';
+
+
-- +goose Down
+
DROP TABLE IF EXISTS posts CASCADE;
+138
internal/db/postgres/post_repo.go
···
···
+
package postgres
+
+
import (
+
"Coves/internal/core/posts"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"strings"
+
+
"github.com/lib/pq"
+
)
+
+
type postgresPostRepo struct {
+
db *sql.DB
+
}
+
+
// NewPostRepository creates a new PostgreSQL post repository
+
func NewPostRepository(db *sql.DB) posts.Repository {
+
return &postgresPostRepo{db: db}
+
}
+
+
// Create inserts a new post into the posts table
+
// Called by Jetstream consumer after post is created on PDS
+
func (r *postgresPostRepo) Create(ctx context.Context, post *posts.Post) error {
+
// Serialize JSON fields for storage
+
var facetsJSON, embedJSON sql.NullString
+
+
if post.ContentFacets != nil {
+
facetsJSON.String = *post.ContentFacets
+
facetsJSON.Valid = true
+
}
+
+
if post.Embed != nil {
+
embedJSON.String = *post.Embed
+
embedJSON.Valid = true
+
}
+
+
// Convert content labels to PostgreSQL array
+
var labelsArray pq.StringArray
+
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
+
}
+
}
+
+
query := `
+
INSERT INTO posts (
+
uri, cid, rkey, author_did, community_did,
+
title, content, content_facets, embed, content_labels,
+
created_at, indexed_at
+
) VALUES (
+
$1, $2, $3, $4, $5,
+
$6, $7, $8, $9, $10,
+
$11, NOW()
+
)
+
RETURNING id, indexed_at
+
`
+
+
err := r.db.QueryRowContext(
+
ctx, query,
+
post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID,
+
post.Title, post.Content, facetsJSON, embedJSON, labelsArray,
+
post.CreatedAt,
+
).Scan(&post.ID, &post.IndexedAt)
+
if err != nil {
+
// Check for duplicate URI (post already indexed)
+
if strings.Contains(err.Error(), "duplicate key") && strings.Contains(err.Error(), "posts_uri_key") {
+
return fmt.Errorf("post already indexed: %s", post.URI)
+
}
+
+
// Check for foreign key violations
+
if strings.Contains(err.Error(), "violates foreign key constraint") {
+
if strings.Contains(err.Error(), "fk_author") {
+
return fmt.Errorf("author DID not found: %s", post.AuthorDID)
+
}
+
if strings.Contains(err.Error(), "fk_community") {
+
return fmt.Errorf("community DID not found: %s", post.CommunityDID)
+
}
+
}
+
+
return fmt.Errorf("failed to insert post: %w", err)
+
}
+
+
return nil
+
}
+
+
// GetByURI retrieves a post by its AT-URI
+
// Used for E2E test verification and future GET endpoint
+
func (r *postgresPostRepo) GetByURI(ctx context.Context, uri string) (*posts.Post, error) {
+
query := `
+
SELECT
+
id, uri, cid, rkey, author_did, community_did,
+
title, content, content_facets, embed, content_labels,
+
created_at, edited_at, indexed_at, deleted_at,
+
upvote_count, downvote_count, score, comment_count
+
FROM posts
+
WHERE uri = $1
+
`
+
+
var post posts.Post
+
var facetsJSON, embedJSON sql.NullString
+
var contentLabels pq.StringArray
+
+
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.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt,
+
&post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount,
+
)
+
+
if err == sql.ErrNoRows {
+
return nil, posts.ErrNotFound
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get post by URI: %w", err)
+
}
+
+
// Convert SQL types back to Go types
+
if facetsJSON.Valid {
+
post.ContentFacets = &facetsJSON.String
+
}
+
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
+
}
+
}
+
+
return &post, nil
+
}
+13 -19
internal/validation/lexicon_test.go
···
// Valid post
validPost := map[string]interface{}{
-
"$type": "social.coves.post.record",
-
"community": "did:plc:test123",
-
"postType": "text",
-
"title": "Test Post",
-
"text": "This is a test",
-
"tags": []string{"test"},
-
"language": "en",
-
"contentWarnings": []string{},
-
"createdAt": "2024-01-01T00:00:00Z",
}
if err := validator.ValidatePost(validPost); err != nil {
t.Errorf("Valid post failed validation: %v", err)
}
-
// Invalid post - invalid enum value
invalidPost := map[string]interface{}{
-
"$type": "social.coves.post.record",
-
"community": "did:plc:test123",
-
"postType": "invalid",
-
"title": "Test Post",
-
"text": "This is a test",
-
"tags": []string{"test"},
-
"language": "en",
-
"contentWarnings": []string{},
-
"createdAt": "2024-01-01T00:00:00Z",
}
if err := validator.ValidatePost(invalidPost); err == nil {
···
// Valid post
validPost := map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:test123",
+
"author": "did:plc:author123",
+
"title": "Test Post",
+
"content": "This is a test",
+
"createdAt": "2024-01-01T00:00:00Z",
}
if err := validator.ValidatePost(validPost); err != nil {
t.Errorf("Valid post failed validation: %v", err)
}
+
// Invalid post - missing required field (author)
invalidPost := map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:test123",
+
// Missing required "author" field
+
"title": "Test Post",
+
"content": "This is a test",
+
"createdAt": "2024-01-01T00:00:00Z",
}
if err := validator.ValidatePost(invalidPost); err == nil {
+2 -1
tests/integration/community_blocking_test.go
···
import (
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
-
postgresRepo "Coves/internal/db/postgres"
"context"
"database/sql"
"fmt"
"testing"
"time"
)
// TestCommunityBlocking_Indexing tests Jetstream indexing of block events
···
import (
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
+
+
postgresRepo "Coves/internal/db/postgres"
)
// TestCommunityBlocking_Indexing tests Jetstream indexing of block events
-42
tests/integration/community_e2e_test.go
···
return community
}
-
// authenticateWithPDS authenticates with the PDS and returns access token and DID
-
func authenticateWithPDS(pdsURL, handle, password string) (string, string, error) {
-
// Call com.atproto.server.createSession
-
sessionReq := map[string]string{
-
"identifier": handle,
-
"password": password,
-
}
-
-
reqBody, marshalErr := json.Marshal(sessionReq)
-
if marshalErr != nil {
-
return "", "", fmt.Errorf("failed to marshal session request: %w", marshalErr)
-
}
-
resp, err := http.Post(
-
pdsURL+"/xrpc/com.atproto.server.createSession",
-
"application/json",
-
bytes.NewBuffer(reqBody),
-
)
-
if err != nil {
-
return "", "", fmt.Errorf("failed to create session: %w", err)
-
}
-
defer func() { _ = resp.Body.Close() }()
-
-
if resp.StatusCode != http.StatusOK {
-
body, readErr := io.ReadAll(resp.Body)
-
if readErr != nil {
-
return "", "", fmt.Errorf("PDS auth failed (status %d, failed to read body: %w)", resp.StatusCode, readErr)
-
}
-
return "", "", fmt.Errorf("PDS auth failed (status %d): %s", resp.StatusCode, string(body))
-
}
-
-
var sessionResp struct {
-
AccessJwt string `json:"accessJwt"`
-
DID string `json:"did"`
-
}
-
-
if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
-
return "", "", fmt.Errorf("failed to decode session response: %w", err)
-
}
-
-
return sessionResp.AccessJwt, sessionResp.DID, nil
-
}
-
// queryPDSAccount queries the PDS to verify an account exists
// Returns the account's DID and handle if found
func queryPDSAccount(pdsURL, handle string) (string, string, error) {
···
return community
}
// queryPDSAccount queries the PDS to verify an account exists
// Returns the account's DID and handle if found
func queryPDSAccount(pdsURL, handle string) (string, string, error) {
+8 -4
tests/integration/community_hostedby_security_test.go
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
// Attempt to create community claiming to be hosted by nintendo.com
// but with a coves.social handle (ATTACK!)
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
-
"handle": "gaming.community.coves.social", // coves.social handle
"name": "gaming",
"displayName": "Nintendo Gaming",
"description": "Fake Nintendo community",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
// Create community with matching hostedBy and handle domains
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
-
"handle": "gaming.community.coves.social", // coves.social handle
"name": "gaming",
"displayName": "Gaming Community",
"description": "Legitimate coves.social community",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
// Attempt to use did:plc for hostedBy (not allowed)
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
-
"handle": "gaming.community.coves.social",
"name": "gaming",
"displayName": "Test Community",
"description": "Test",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
// Even with mismatched domain, this should succeed with skipVerification=true
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
-
"handle": "gaming.community.example.com",
"name": "gaming",
"displayName": "Test",
"description": "Test",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
uniqueHandle := fmt.Sprintf("gaming%s.community.coves.social", uniqueSuffix)
// Attempt to create community claiming to be hosted by nintendo.com
// but with a coves.social handle (ATTACK!)
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
+
"handle": uniqueHandle, // coves.social handle
"name": "gaming",
"displayName": "Nintendo Gaming",
"description": "Fake Nintendo community",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
uniqueHandle := fmt.Sprintf("gaming%s.community.coves.social", uniqueSuffix)
// Create community with matching hostedBy and handle domains
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
+
"handle": uniqueHandle, // coves.social handle
"name": "gaming",
"displayName": "Gaming Community",
"description": "Legitimate coves.social community",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
uniqueHandle := fmt.Sprintf("gaming%s.community.coves.social", uniqueSuffix)
// Attempt to use did:plc for hostedBy (not allowed)
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
+
"handle": uniqueHandle,
"name": "gaming",
"displayName": "Test Community",
"description": "Test",
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
uniqueHandle := fmt.Sprintf("gaming%s.community.example.com", uniqueSuffix)
// Even with mismatched domain, this should succeed with skipVerification=true
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
+
"handle": uniqueHandle,
"name": "gaming",
"displayName": "Test",
"description": "Test",
+1 -1
tests/integration/community_identifier_resolution_test.go
···
}{
{"nodots", "nodots"}, // No dots - should return as-is
{"single.dot", "single.dot"}, // Single dot - should return as-is
-
{"", ""}, // Empty - should return as-is
}
for _, tc := range testCases {
···
}{
{"nodots", "nodots"}, // No dots - should return as-is
{"single.dot", "single.dot"}, // Single dot - should return as-is
+
{"", ""}, // Empty - should return as-is
}
for _, tc := range testCases {
+12 -6
tests/integration/community_v2_validation_test.go
···
"Coves/internal/core/communities"
"Coves/internal/db/postgres"
"context"
"testing"
"time"
)
···
ctx := context.Background()
t.Run("accepts V2 community with rkey=self", func(t *testing.T) {
event := &jetstream.JetstreamEvent{
-
Did: "did:plc:community123",
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
···
CID: "bafyreigaming123",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "gaming.community.coves.social",
-
"name": "gaming",
"createdBy": "did:plc:user123",
"hostedBy": "did:web:coves.social",
"visibility": "public",
···
}
// Verify community was indexed
-
community, err := repo.GetByDID(ctx, "did:plc:community123")
if err != nil {
t.Fatalf("Community should have been indexed: %v", err)
}
···
}
// Verify record URI uses "self"
-
expectedURI := "at://did:plc:community123/social.coves.community.profile/self"
if community.RecordURI != expectedURI {
t.Errorf("Expected RecordURI %s, got %s", expectedURI, community.RecordURI)
}
···
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
"handle": "gamingtest.community.coves.social", // atProto handle (DNS-resolvable)
-
"name": "gamingtest", // Short name for !mentions
"createdBy": "did:plc:user123",
"hostedBy": "did:web:coves.social",
"visibility": "public",
···
"Coves/internal/core/communities"
"Coves/internal/db/postgres"
"context"
+
"fmt"
"testing"
"time"
)
···
ctx := context.Background()
t.Run("accepts V2 community with rkey=self", func(t *testing.T) {
+
// Use unique DID and handle to avoid conflicts with other test runs
+
timestamp := time.Now().UnixNano()
+
testDID := fmt.Sprintf("did:plc:testv2rkey%d", timestamp)
+
testHandle := fmt.Sprintf("testv2rkey%d.community.coves.social", timestamp)
+
event := &jetstream.JetstreamEvent{
+
Did: testDID,
Kind: "commit",
Commit: &jetstream.CommitEvent{
Operation: "create",
···
CID: "bafyreigaming123",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
+
"handle": testHandle,
+
"name": "testv2rkey",
"createdBy": "did:plc:user123",
"hostedBy": "did:web:coves.social",
"visibility": "public",
···
}
// Verify community was indexed
+
community, err := repo.GetByDID(ctx, testDID)
if err != nil {
t.Fatalf("Community should have been indexed: %v", err)
}
···
}
// Verify record URI uses "self"
+
expectedURI := fmt.Sprintf("at://%s/social.coves.community.profile/self", testDID)
if community.RecordURI != expectedURI {
t.Errorf("Expected RecordURI %s, got %s", expectedURI, community.RecordURI)
}
···
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
"handle": "gamingtest.community.coves.social", // atProto handle (DNS-resolvable)
+
"name": "gamingtest", // Short name for !mentions
"createdBy": "did:plc:user123",
"hostedBy": "did:web:coves.social",
"visibility": "public",
+92
tests/integration/helpers.go
···
···
+
package integration
+
+
import (
+
"Coves/internal/core/users"
+
"bytes"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net/http"
+
"strings"
+
"testing"
+
)
+
+
// createTestUser creates a test user in the database for use in integration tests
+
// Returns the created user or fails the test
+
func createTestUser(t *testing.T, db *sql.DB, handle, did string) *users.User {
+
t.Helper()
+
+
ctx := context.Background()
+
+
// Create user directly in DB for speed
+
query := `
+
INSERT INTO users (did, handle, pds_url, created_at, updated_at)
+
VALUES ($1, $2, $3, NOW(), NOW())
+
RETURNING did, handle, pds_url, created_at, updated_at
+
`
+
+
user := &users.User{}
+
err := db.QueryRowContext(ctx, query, did, handle, "http://localhost:3001").Scan(
+
&user.DID,
+
&user.Handle,
+
&user.PDSURL,
+
&user.CreatedAt,
+
&user.UpdatedAt,
+
)
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
+
+
return user
+
}
+
+
// contains checks if string s contains substring substr
+
// Helper for error message assertions
+
func contains(s, substr string) bool {
+
return strings.Contains(s, substr)
+
}
+
+
// authenticateWithPDS authenticates with PDS to get access token and DID
+
// Used for setting up test environments that need PDS credentials
+
func authenticateWithPDS(pdsURL, handle, password string) (string, string, error) {
+
// Call com.atproto.server.createSession
+
sessionReq := map[string]string{
+
"identifier": handle,
+
"password": password,
+
}
+
+
reqBody, marshalErr := json.Marshal(sessionReq)
+
if marshalErr != nil {
+
return "", "", fmt.Errorf("failed to marshal session request: %w", marshalErr)
+
}
+
resp, err := http.Post(
+
pdsURL+"/xrpc/com.atproto.server.createSession",
+
"application/json",
+
bytes.NewBuffer(reqBody),
+
)
+
if err != nil {
+
return "", "", fmt.Errorf("failed to create session: %w", err)
+
}
+
defer func() { _ = resp.Body.Close() }()
+
+
if resp.StatusCode != http.StatusOK {
+
body, readErr := io.ReadAll(resp.Body)
+
if readErr != nil {
+
return "", "", fmt.Errorf("PDS auth failed (status %d, failed to read body: %w)", resp.StatusCode, readErr)
+
}
+
return "", "", fmt.Errorf("PDS auth failed (status %d): %s", resp.StatusCode, string(body))
+
}
+
+
var sessionResp struct {
+
AccessJwt string `json:"accessJwt"`
+
DID string `json:"did"`
+
}
+
+
if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
+
return "", "", fmt.Errorf("failed to decode session response: %w", err)
+
}
+
+
return sessionResp.AccessJwt, sessionResp.DID, nil
+
}
+1 -13
tests/integration/jetstream_consumer_test.go
···
})
}
-
// Helper function
-
func contains(s, substr string) bool {
-
return len(s) >= len(substr) && anySubstring(s, substr)
-
}
-
-
func anySubstring(s, substr string) bool {
-
for i := 0; i <= len(s)-len(substr); i++ {
-
if s[i:i+len(substr)] == substr {
-
return true
-
}
-
}
-
return false
-
}
···
})
}
+
// Helper functions moved to helpers.go
+363
tests/integration/post_creation_test.go
···
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"strings"
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
func TestPostCreation_Basic(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Setup: Initialize services
+
userRepo := postgres.NewUserRepository(db)
+
resolver := identity.NewResolver(db, identity.DefaultConfig())
+
userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
+
+
communityRepo := postgres.NewCommunityRepository(db)
+
// Note: Provisioner not needed for this test (we're not actually creating communities)
+
communityService := communities.NewCommunityService(
+
communityRepo,
+
"http://localhost:3001",
+
"did:web:test.coves.social",
+
"test.coves.social",
+
nil, // provisioner
+
)
+
+
postRepo := postgres.NewPostRepository(db)
+
postService := posts.NewPostService(postRepo, communityService, "http://localhost:3001")
+
+
ctx := context.Background()
+
+
// Cleanup: Remove any existing test data
+
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'")
+
_, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'")
+
_, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'")
+
+
// Setup: Create test user
+
testUserDID := generateTestDID("postauthor")
+
testUserHandle := "postauthor.test"
+
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testUserDID,
+
Handle: testUserHandle,
+
PDSURL: "http://localhost:3001",
+
})
+
require.NoError(t, err, "Failed to create test user")
+
+
// Setup: Create test community (insert directly to DB for speed)
+
testCommunity := &communities.Community{
+
DID: generateTestDID("testcommunity"),
+
Handle: "testcommunity.community.test.coves.social", // Canonical atProto handle (no ! prefix, .community. format)
+
Name: "testcommunity",
+
DisplayName: "Test Community",
+
Description: "A community for testing posts",
+
Visibility: "public",
+
CreatedByDID: testUserDID,
+
HostedByDID: "did:web:test.coves.social",
+
PDSURL: "http://localhost:3001",
+
PDSAccessToken: "fake_token_for_test", // Won't actually call PDS in unit test
+
}
+
+
_, err = communityRepo.Create(ctx, testCommunity)
+
require.NoError(t, err, "Failed to create test community")
+
+
t.Run("Create text post successfully (with DID)", func(t *testing.T) {
+
// NOTE: This test validates the service layer logic only
+
// It will fail when trying to write to PDS because we're using a fake token
+
// For full E2E testing, you'd need a real PDS instance
+
+
content := "This is a test post"
+
title := "Test Post Title"
+
+
req := posts.CreatePostRequest{
+
Community: testCommunity.DID, // Using DID directly
+
Title: &title,
+
Content: &content,
+
AuthorDID: testUserDID,
+
}
+
+
// 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)
+
+
// For now, we expect an error because token is fake
+
// In a full E2E test with real PDS, this would succeed
+
require.Error(t, err)
+
t.Logf("Expected error (fake token): %v", err)
+
// Verify the error is from token refresh or PDS, not validation
+
assert.Contains(t, err.Error(), "failed to refresh community credentials")
+
})
+
+
t.Run("Create text post with community handle", func(t *testing.T) {
+
// Test that we can use community handle instead of DID
+
// This validates at-identifier resolution per atProto best practices
+
+
content := "Post using handle instead of DID"
+
title := "Handle Test"
+
+
req := posts.CreatePostRequest{
+
Community: testCommunity.Handle, // Using canonical atProto handle
+
Title: &title,
+
Content: &content,
+
AuthorDID: testUserDID,
+
}
+
+
// Should resolve handle to DID and proceed
+
// Will still fail at token refresh (expected with fake token)
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
// Should fail at token refresh, not community resolution
+
assert.Contains(t, err.Error(), "failed to refresh community credentials")
+
})
+
+
t.Run("Create text post with ! prefix handle", func(t *testing.T) {
+
// Test that we can also use ! prefix with scoped format: !name@instance
+
// This is Coves-specific UX shorthand for name.community.instance
+
+
content := "Post using !-prefixed handle"
+
title := "Prefixed Handle Test"
+
+
// Extract name from handle: "gardening.community.coves.social" -> "gardening"
+
// Scoped format: !gardening@coves.social
+
handleParts := strings.Split(testCommunity.Handle, ".")
+
communityName := handleParts[0]
+
instanceDomain := strings.Join(handleParts[2:], ".") // Skip ".community."
+
scopedHandle := fmt.Sprintf("!%s@%s", communityName, instanceDomain)
+
+
req := posts.CreatePostRequest{
+
Community: scopedHandle, // !gardening@coves.social
+
Title: &title,
+
Content: &content,
+
AuthorDID: testUserDID,
+
}
+
+
// Should resolve handle to DID and proceed
+
// Will still fail at token refresh (expected with fake token)
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
// Should fail at token refresh, not community resolution
+
assert.Contains(t, err.Error(), "failed to refresh community credentials")
+
})
+
+
t.Run("Reject post with missing community", func(t *testing.T) {
+
content := "Post without community"
+
+
req := posts.CreatePostRequest{
+
Community: "", // Missing!
+
Content: &content,
+
AuthorDID: testUserDID,
+
}
+
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
assert.True(t, posts.IsValidationError(err))
+
})
+
+
t.Run("Reject post with non-existent community handle", func(t *testing.T) {
+
content := "Post with non-existent handle"
+
+
req := posts.CreatePostRequest{
+
Community: "nonexistent.community.test.coves.social", // Valid canonical handle format, but doesn't exist
+
Content: &content,
+
AuthorDID: testUserDID,
+
}
+
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
// Should fail with community not found (wrapped in error)
+
assert.Contains(t, err.Error(), "community not found")
+
})
+
+
t.Run("Reject post with missing author DID", func(t *testing.T) {
+
content := "Post without author"
+
+
req := posts.CreatePostRequest{
+
Community: testCommunity.DID,
+
Content: &content,
+
AuthorDID: "", // Missing!
+
}
+
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
assert.True(t, posts.IsValidationError(err))
+
assert.Contains(t, err.Error(), "authorDid")
+
})
+
+
t.Run("Reject post in non-existent community", func(t *testing.T) {
+
content := "Post in fake community"
+
+
req := posts.CreatePostRequest{
+
Community: "did:plc:nonexistent",
+
Content: &content,
+
AuthorDID: testUserDID,
+
}
+
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
assert.Equal(t, posts.ErrCommunityNotFound, err)
+
})
+
+
t.Run("Reject post with too-long content", func(t *testing.T) {
+
// Create content longer than 50k characters
+
longContent := string(make([]byte, 50001))
+
+
req := posts.CreatePostRequest{
+
Community: testCommunity.DID,
+
Content: &longContent,
+
AuthorDID: testUserDID,
+
}
+
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
assert.True(t, posts.IsValidationError(err))
+
assert.Contains(t, err.Error(), "too long")
+
})
+
+
t.Run("Reject post with invalid content label", func(t *testing.T) {
+
content := "Post with invalid label"
+
+
req := posts.CreatePostRequest{
+
Community: testCommunity.DID,
+
Content: &content,
+
ContentLabels: []string{"invalid_label"}, // Not in known values!
+
AuthorDID: testUserDID,
+
}
+
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
assert.True(t, posts.IsValidationError(err))
+
assert.Contains(t, err.Error(), "unknown content label")
+
})
+
+
t.Run("Accept post with valid content labels", func(t *testing.T) {
+
content := "Post with valid labels"
+
+
req := posts.CreatePostRequest{
+
Community: testCommunity.DID,
+
Content: &content,
+
ContentLabels: []string{"nsfw", "spoiler"},
+
AuthorDID: testUserDID,
+
}
+
+
// Will fail at token refresh (expected with fake token)
+
_, err := postService.CreatePost(ctx, req)
+
require.Error(t, err)
+
// Should fail at token refresh, not validation
+
assert.Contains(t, err.Error(), "failed to refresh community credentials")
+
})
+
}
+
+
// TestPostRepository_Create tests the repository layer
+
func TestPostRepository_Create(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Cleanup first
+
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'")
+
_, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'")
+
_, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'")
+
+
// Setup: Create test user and community
+
ctx := context.Background()
+
userRepo := postgres.NewUserRepository(db)
+
communityRepo := postgres.NewCommunityRepository(db)
+
+
testUserDID := generateTestDID("postauthor2")
+
_, err := userRepo.Create(ctx, &users.User{
+
DID: testUserDID,
+
Handle: "postauthor2.test",
+
PDSURL: "http://localhost:3001",
+
})
+
require.NoError(t, err)
+
+
testCommunityDID := generateTestDID("testcommunity2")
+
_, err = communityRepo.Create(ctx, &communities.Community{
+
DID: testCommunityDID,
+
Handle: "testcommunity2.community.test.coves.social", // Canonical format (no ! prefix)
+
Name: "testcommunity2",
+
Visibility: "public",
+
CreatedByDID: testUserDID,
+
HostedByDID: "did:web:test.coves.social",
+
PDSURL: "http://localhost:3001",
+
})
+
require.NoError(t, err)
+
+
postRepo := postgres.NewPostRepository(db)
+
+
t.Run("Insert post successfully", func(t *testing.T) {
+
content := "Test post content"
+
title := "Test Title"
+
+
post := &posts.Post{
+
URI: "at://" + testCommunityDID + "/social.coves.post.record/test123",
+
CID: "bafy2test123",
+
RKey: "test123",
+
AuthorDID: testUserDID,
+
CommunityDID: testCommunityDID,
+
Title: &title,
+
Content: &content,
+
}
+
+
err := postRepo.Create(ctx, post)
+
require.NoError(t, err)
+
assert.NotZero(t, post.ID, "Post should have ID after insert")
+
assert.NotZero(t, post.IndexedAt, "Post should have IndexedAt timestamp")
+
})
+
+
t.Run("Reject duplicate post URI", func(t *testing.T) {
+
content := "Duplicate post"
+
+
post1 := &posts.Post{
+
URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate",
+
CID: "bafy2duplicate1",
+
RKey: "duplicate",
+
AuthorDID: testUserDID,
+
CommunityDID: testCommunityDID,
+
Content: &content,
+
}
+
+
err := postRepo.Create(ctx, post1)
+
require.NoError(t, err)
+
+
// Try to insert again with same URI
+
post2 := &posts.Post{
+
URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate",
+
CID: "bafy2duplicate2",
+
RKey: "duplicate",
+
AuthorDID: testUserDID,
+
CommunityDID: testCommunityDID,
+
Content: &content,
+
}
+
+
err = postRepo.Create(ctx, post2)
+
require.Error(t, err)
+
assert.Contains(t, err.Error(), "already indexed")
+
})
+
}
+715
tests/integration/post_e2e_test.go
···
···
+
package integration
+
+
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/auth"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
+
"bytes"
+
"context"
+
"database/sql"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"net"
+
"net/http"
+
"net/http/httptest"
+
"os"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/golang-jwt/jwt/v5"
+
"github.com/gorilla/websocket"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// TestPostCreation_E2E_WithJetstream tests the full post creation flow:
+
// 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
+
// 2. Handler validates and calls PostService.CreatePost()
+
// 3. Service writes post to community's PDS repository
+
// 4. PDS broadcasts event to firehose/Jetstream
+
// 5. Jetstream consumer receives event and indexes post in AppView DB
+
// 6. Post is now queryable from AppView
+
//
+
// NOTE: This test simulates the Jetstream event (step 4-5) since we don't have
+
// a live PDS/Jetstream in test environment. For true live testing, use TestPostCreation_E2E_LivePDS.
+
func TestPostCreation_E2E_WithJetstream(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Cleanup old test data first
+
_, _ = db.Exec("DELETE FROM posts WHERE community_did = 'did:plc:gaming123'")
+
_, _ = db.Exec("DELETE FROM communities WHERE did = 'did:plc:gaming123'")
+
_, _ = db.Exec("DELETE FROM users WHERE did = 'did:plc:alice123'")
+
+
// Setup repositories
+
userRepo := postgres.NewUserRepository(db)
+
communityRepo := postgres.NewCommunityRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Setup user service for post consumer
+
identityConfig := identity.DefaultConfig()
+
identityResolver := identity.NewResolver(db, identityConfig)
+
userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
+
+
// Create test user (author)
+
author := createTestUser(t, db, "alice.test", "did:plc:alice123")
+
+
// Create test community with fake PDS credentials
+
// In real E2E, this would be a real community provisioned on PDS
+
community := &communities.Community{
+
DID: "did:plc:gaming123",
+
Handle: "gaming.community.test.coves.social",
+
Name: "gaming",
+
DisplayName: "Gaming Community",
+
OwnerDID: "did:plc:gaming123",
+
CreatedByDID: author.DID,
+
HostedByDID: "did:web:coves.test",
+
Visibility: "public",
+
ModerationType: "moderator",
+
RecordURI: "at://did:plc:gaming123/social.coves.community.profile/self",
+
RecordCID: "fakecid123",
+
PDSAccessToken: "fake_token_for_testing",
+
PDSRefreshToken: "fake_refresh_token",
+
}
+
_, err := communityRepo.Create(context.Background(), community)
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
t.Run("Full E2E flow - XRPC to DB via Jetstream", func(t *testing.T) {
+
ctx := context.Background()
+
+
// STEP 1: Simulate what the XRPC handler would receive
+
// In real flow, this comes from client with OAuth bearer token
+
title := "My First Post"
+
content := "This is a test post!"
+
postReq := posts.CreatePostRequest{
+
Title: &title,
+
Content: &content,
+
// Community and AuthorDID set by handler from request context
+
}
+
+
// STEP 2: Simulate Jetstream consumer receiving the post CREATE event
+
// In real production, this event comes from PDS via Jetstream WebSocket
+
// For this test, we simulate the event that would be broadcast after PDS write
+
+
// Generate a realistic rkey (TID - timestamp identifier)
+
rkey := generateTID()
+
+
// Build the post record as it would appear in Jetstream
+
jetstreamEvent := jetstream.JetstreamEvent{
+
Did: community.DID, // Repo owner (community)
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "create",
+
Collection: "social.coves.post.record",
+
RKey: rkey,
+
CID: "bafy2bzaceabc123def456", // Fake CID
+
Record: map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": community.DID,
+
"author": author.DID,
+
"title": *postReq.Title,
+
"content": *postReq.Content,
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
// STEP 3: Process event through Jetstream consumer
+
consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
+
err := consumer.HandleEvent(ctx, &jetstreamEvent)
+
if err != nil {
+
t.Fatalf("Jetstream consumer failed to process event: %v", err)
+
}
+
+
// STEP 4: Verify post was indexed in AppView database
+
expectedURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey)
+
indexedPost, err := postRepo.GetByURI(ctx, expectedURI)
+
if err != nil {
+
t.Fatalf("Post not indexed in AppView: %v", err)
+
}
+
+
// STEP 5: Verify all fields are correct
+
if indexedPost.URI != expectedURI {
+
t.Errorf("Expected URI %s, got %s", expectedURI, indexedPost.URI)
+
}
+
if indexedPost.AuthorDID != author.DID {
+
t.Errorf("Expected author %s, got %s", author.DID, indexedPost.AuthorDID)
+
}
+
if indexedPost.CommunityDID != community.DID {
+
t.Errorf("Expected community %s, got %s", community.DID, indexedPost.CommunityDID)
+
}
+
if indexedPost.Title == nil || *indexedPost.Title != title {
+
t.Errorf("Expected title '%s', got %v", title, indexedPost.Title)
+
}
+
if indexedPost.Content == nil || *indexedPost.Content != content {
+
t.Errorf("Expected content '%s', got %v", content, indexedPost.Content)
+
}
+
+
// Verify stats initialized correctly
+
if indexedPost.UpvoteCount != 0 {
+
t.Errorf("Expected upvote_count 0, got %d", indexedPost.UpvoteCount)
+
}
+
if indexedPost.DownvoteCount != 0 {
+
t.Errorf("Expected downvote_count 0, got %d", indexedPost.DownvoteCount)
+
}
+
if indexedPost.Score != 0 {
+
t.Errorf("Expected score 0, got %d", indexedPost.Score)
+
}
+
+
t.Logf("✓ E2E test passed! Post indexed with URI: %s", indexedPost.URI)
+
})
+
+
t.Run("Consumer validates repository ownership (security)", func(t *testing.T) {
+
ctx := context.Background()
+
+
// SECURITY TEST: Try to create a post that claims to be from the community
+
// but actually comes from a user's repository
+
// This should be REJECTED by the consumer
+
+
maliciousEvent := jetstream.JetstreamEvent{
+
Did: author.DID, // Event from user's repo (NOT community repo)
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "create",
+
Collection: "social.coves.post.record",
+
RKey: generateTID(),
+
CID: "bafy2bzacefake",
+
Record: map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": community.DID, // Claims to be for this community
+
"author": author.DID,
+
"title": "Fake Post",
+
"content": "This is a malicious post attempt",
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
+
err := consumer.HandleEvent(ctx, &maliciousEvent)
+
+
// Should get security error
+
if err == nil {
+
t.Fatal("Expected security error for post from wrong repository, got nil")
+
}
+
+
if !contains(err.Error(), "repository DID") || !contains(err.Error(), "doesn't match") {
+
t.Errorf("Expected repository mismatch error, got: %v", err)
+
}
+
+
t.Logf("✓ Security validation passed: %v", err)
+
})
+
+
t.Run("Idempotent indexing - duplicate events", func(t *testing.T) {
+
ctx := context.Background()
+
+
// Simulate the same Jetstream event arriving twice
+
// This can happen during Jetstream replays or network retries
+
rkey := generateTID()
+
event := jetstream.JetstreamEvent{
+
Did: community.DID,
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "create",
+
Collection: "social.coves.post.record",
+
RKey: rkey,
+
CID: "bafy2bzaceidempotent",
+
Record: map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": community.DID,
+
"author": author.DID,
+
"title": "Duplicate Test",
+
"content": "Testing idempotency",
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
+
+
// First event - should succeed
+
err := consumer.HandleEvent(ctx, &event)
+
if err != nil {
+
t.Fatalf("First event failed: %v", err)
+
}
+
+
// Second event (duplicate) - should be handled gracefully
+
err = consumer.HandleEvent(ctx, &event)
+
if err != nil {
+
t.Fatalf("Duplicate event should be handled gracefully, got error: %v", err)
+
}
+
+
// Verify only one post in database
+
uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, rkey)
+
post, err := postRepo.GetByURI(ctx, uri)
+
if err != nil {
+
t.Fatalf("Post not found: %v", err)
+
}
+
+
if post.URI != uri {
+
t.Error("Post URI mismatch - possible duplicate indexing")
+
}
+
+
t.Logf("✓ Idempotency test passed")
+
})
+
+
t.Run("Handles orphaned posts (unknown community)", func(t *testing.T) {
+
ctx := context.Background()
+
+
// Post references a community that doesn't exist in AppView yet
+
// This can happen if Jetstream delivers post event before community profile event
+
unknownCommunityDID := "did:plc:unknown999"
+
+
event := jetstream.JetstreamEvent{
+
Did: unknownCommunityDID,
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "create",
+
Collection: "social.coves.post.record",
+
RKey: generateTID(),
+
CID: "bafy2bzaceorphaned",
+
Record: map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": unknownCommunityDID,
+
"author": author.DID,
+
"title": "Orphaned Post",
+
"content": "Community not indexed yet",
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
+
+
// Should log warning but NOT fail (eventual consistency)
+
// Note: This will fail due to foreign key constraint in current schema
+
// In production, you might want to handle this differently (defer indexing, etc.)
+
err := consumer.HandleEvent(ctx, &event)
+
+
// For now, we expect this to fail due to FK constraint
+
// In future, we might make FK constraint DEFERRABLE or handle orphaned posts differently
+
if err == nil {
+
t.Log("⚠️ Orphaned post was indexed (FK constraint not enforced)")
+
} else {
+
t.Logf("✓ Orphaned post rejected by FK constraint (expected): %v", err)
+
}
+
})
+
}
+
+
// 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)
+
// 2. Handler → Service → Write to community's PDS repository
+
// 3. PDS → Jetstream firehose event
+
// 4. Jetstream consumer → Index in AppView database
+
// 5. Verify post appears in database with correct data
+
//
+
// This is a TRUE E2E test that requires:
+
// - Live PDS running at PDS_URL (default: http://localhost:3001)
+
// - Live Jetstream running at JETSTREAM_URL (default: ws://localhost:6008/subscribe)
+
// - Test database running
+
func TestPostCreation_E2E_LivePDS(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping live PDS E2E test in short mode")
+
}
+
+
// Setup test database
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
require.NoError(t, err, "Failed to connect to test database")
+
defer func() {
+
if closeErr := db.Close(); closeErr != nil {
+
t.Logf("Failed to close database: %v", closeErr)
+
}
+
}()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
// Check if PDS is running
+
pdsURL := os.Getenv("PDS_URL")
+
if pdsURL == "" {
+
pdsURL = "http://localhost:3001"
+
}
+
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
// Get instance credentials for authentication
+
instanceHandle := os.Getenv("PDS_INSTANCE_HANDLE")
+
instancePassword := os.Getenv("PDS_INSTANCE_PASSWORD")
+
if instanceHandle == "" {
+
instanceHandle = "testuser123.local.coves.dev"
+
}
+
if instancePassword == "" {
+
instancePassword = "test-password-123"
+
}
+
+
t.Logf("🔐 Authenticating with PDS as: %s", instanceHandle)
+
+
// Authenticate to get instance DID (needed for provisioner domain)
+
_, instanceDID, err := authenticateWithPDS(pdsURL, instanceHandle, instancePassword)
+
if err != nil {
+
t.Skipf("Failed to authenticate with PDS (may not be configured): %v", err)
+
}
+
+
t.Logf("✅ Authenticated - Instance DID: %s", instanceDID)
+
+
// Extract instance domain from DID for community provisioning
+
var instanceDomain string
+
if strings.HasPrefix(instanceDID, "did:web:") {
+
instanceDomain = strings.TrimPrefix(instanceDID, "did:web:")
+
} else {
+
// Fallback for did:plc
+
instanceDomain = "coves.social"
+
}
+
+
// Setup repositories and services
+
communityRepo := postgres.NewCommunityRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Setup PDS account provisioner for community creation
+
provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
+
+
// Setup community service with real PDS provisioner
+
communityService := communities.NewCommunityService(
+
communityRepo,
+
pdsURL,
+
instanceDID,
+
instanceDomain,
+
provisioner, // ✅ Real provisioner for creating communities on PDS
+
)
+
+
postService := posts.NewPostService(postRepo, communityService, pdsURL)
+
+
// Setup auth middleware (skip JWT verification for testing)
+
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
+
+
// Setup HTTP handler
+
createHandler := post.NewCreateHandler(postService)
+
+
ctx := context.Background()
+
+
// Cleanup old test data
+
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:e2etest%'")
+
_, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:e2etest%'")
+
_, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:e2etest%'")
+
+
// Create test user (author)
+
author := createTestUser(t, db, "e2etestauthor.bsky.social", "did:plc:e2etestauthor123")
+
+
// ====================================================================================
+
// Part 1: Write-Forward to PDS
+
// ====================================================================================
+
t.Run("1. Write-Forward to PDS", func(t *testing.T) {
+
// TRUE E2E: Actually provision a real community on PDS
+
// This tests the full flow:
+
// 1. Call com.atproto.server.createAccount on PDS
+
// 2. PDS generates DID, keys, tokens
+
// 3. Write community profile to PDS repository
+
// 4. Store credentials in AppView DB
+
// 5. Use those credentials to create a post
+
+
// Use timestamp to ensure unique community name for each test run
+
communityName := fmt.Sprintf("e2epost%d", time.Now().Unix())
+
+
t.Logf("\n📝 Provisioning test community on live PDS (name: %s)...", communityName)
+
community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{
+
Name: communityName,
+
DisplayName: "E2E Test Community",
+
Description: "Test community for E2E post creation testing",
+
CreatedByDID: author.DID,
+
Visibility: "public",
+
AllowExternalDiscovery: true,
+
})
+
require.NoError(t, err, "Failed to provision community on PDS")
+
require.NotEmpty(t, community.DID, "Community should have DID from PDS")
+
require.NotEmpty(t, community.PDSAccessToken, "Community should have access token")
+
require.NotEmpty(t, community.PDSRefreshToken, "Community should have refresh token")
+
+
t.Logf("✓ Community provisioned: DID=%s, Handle=%s", community.DID, community.Handle)
+
+
// NOTE: Cleanup disabled to allow post-test inspection of indexed data
+
// Uncomment to enable cleanup after test
+
// defer func() {
+
// if err := communityRepo.Delete(ctx, community.DID); err != nil {
+
// t.Logf("Warning: Failed to cleanup test community: %v", err)
+
// }
+
// }()
+
+
// Build HTTP request for post creation
+
title := "E2E Test Post"
+
content := "This post was created via full E2E test with live PDS!"
+
reqBody := map[string]interface{}{
+
"community": community.DID,
+
"title": title,
+
"content": content,
+
}
+
reqJSON, err := json.Marshal(reqBody)
+
require.NoError(t, err)
+
+
// Create HTTP request
+
req := httptest.NewRequest("POST", "/xrpc/social.coves.post.create", bytes.NewReader(reqJSON))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Create a simple JWT for testing (Phase 1: no signature verification)
+
// In production, this would be a real OAuth token from PDS
+
testJWT := createSimpleTestJWT(author.DID)
+
req.Header.Set("Authorization", "Bearer "+testJWT)
+
+
// Execute request through auth middleware + handler
+
rr := httptest.NewRecorder()
+
handler := authMiddleware.RequireAuth(http.HandlerFunc(createHandler.HandleCreate))
+
handler.ServeHTTP(rr, req)
+
+
// Check response
+
require.Equal(t, http.StatusOK, rr.Code, "Handler should return 200 OK, body: %s", rr.Body.String())
+
+
// Parse response
+
var response posts.CreatePostResponse
+
err = json.NewDecoder(rr.Body).Decode(&response)
+
require.NoError(t, err, "Failed to parse response")
+
+
t.Logf("✅ Post created on PDS:")
+
t.Logf(" URI: %s", response.URI)
+
t.Logf(" CID: %s", response.CID)
+
+
// ====================================================================================
+
// Part 2: TRUE E2E - Real Jetstream Firehose Consumer
+
// ====================================================================================
+
// This part tests the ACTUAL production code path in main.go
+
// including the WebSocket connection and consumer logic
+
t.Run("2. Real Jetstream Firehose Consumption", func(t *testing.T) {
+
t.Logf("\n🔄 TRUE E2E: Subscribing to real Jetstream firehose...")
+
+
// Get PDS hostname for Jetstream filtering
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
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",
+
pdsHostname)
+
+
t.Logf(" Jetstream URL: %s", jetstreamURL)
+
t.Logf(" Looking for post URI: %s", response.URI)
+
t.Logf(" Community DID: %s", community.DID)
+
+
// Setup user service (required by post consumer)
+
userRepo := postgres.NewUserRepository(db)
+
identityConfig := identity.DefaultConfig()
+
plcURL := os.Getenv("PLC_DIRECTORY_URL")
+
if plcURL == "" {
+
plcURL = "http://localhost:3002"
+
}
+
identityConfig.PLCURL = plcURL
+
identityResolver := identity.NewResolver(db, identityConfig)
+
userService := users.NewUserService(userRepo, identityResolver, pdsURL)
+
+
// Create post consumer (same as main.go)
+
postConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService)
+
+
// Channels to receive the event
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
// Start Jetstream WebSocket subscriber in background
+
// This creates its own WebSocket connection to Jetstream
+
go func() {
+
err := subscribeToJetstreamForPost(ctx, jetstreamURL, community.DID, postConsumer, eventChan, errorChan, done)
+
if err != nil {
+
errorChan <- err
+
}
+
}()
+
+
// Wait for event or timeout
+
t.Logf("⏳ Waiting for Jetstream event (max 30 seconds)...")
+
+
select {
+
case event := <-eventChan:
+
t.Logf("✅ Received real Jetstream event!")
+
t.Logf(" Event DID: %s", event.Did)
+
t.Logf(" Collection: %s", event.Commit.Collection)
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
t.Logf(" RKey: %s", event.Commit.RKey)
+
+
// Verify it's for our community
+
assert.Equal(t, community.DID, event.Did, "Event should be from community repo")
+
+
// Verify post was indexed in AppView database
+
t.Logf("\n🔍 Querying AppView database for indexed post...")
+
+
indexedPost, err := postRepo.GetByURI(ctx, response.URI)
+
require.NoError(t, err, "Post should be indexed in AppView")
+
+
t.Logf("✅ Post indexed in AppView:")
+
t.Logf(" URI: %s", indexedPost.URI)
+
t.Logf(" CID: %s", indexedPost.CID)
+
t.Logf(" Author DID: %s", indexedPost.AuthorDID)
+
t.Logf(" Community: %s", indexedPost.CommunityDID)
+
t.Logf(" Title: %v", indexedPost.Title)
+
t.Logf(" Content: %v", indexedPost.Content)
+
+
// Verify all fields match what we sent
+
assert.Equal(t, response.URI, indexedPost.URI, "URI should match")
+
assert.Equal(t, response.CID, indexedPost.CID, "CID should match")
+
assert.Equal(t, author.DID, indexedPost.AuthorDID, "Author DID should match")
+
assert.Equal(t, community.DID, indexedPost.CommunityDID, "Community DID should match")
+
assert.Equal(t, title, *indexedPost.Title, "Title should match")
+
assert.Equal(t, content, *indexedPost.Content, "Content should match")
+
+
// Verify stats initialized correctly
+
assert.Equal(t, 0, indexedPost.UpvoteCount, "Upvote count should be 0")
+
assert.Equal(t, 0, indexedPost.DownvoteCount, "Downvote count should be 0")
+
assert.Equal(t, 0, indexedPost.Score, "Score should be 0")
+
assert.Equal(t, 0, indexedPost.CommentCount, "Comment count should be 0")
+
+
// Verify timestamps
+
assert.False(t, indexedPost.CreatedAt.IsZero(), "CreatedAt should be set")
+
assert.False(t, indexedPost.IndexedAt.IsZero(), "IndexedAt should be set")
+
+
// Signal to stop Jetstream consumer
+
close(done)
+
+
t.Log("\n✅ Part 2 Complete: TRUE E2E - PDS → Jetstream → Consumer → AppView ✓")
+
+
case err := <-errorChan:
+
t.Fatalf("❌ Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("❌ Timeout: No Jetstream event received within 30 seconds")
+
}
+
})
+
})
+
}
+
+
// createSimpleTestJWT creates a minimal JWT for testing (Phase 1 - no signature)
+
// In production, this would be a real OAuth token from PDS with proper signatures
+
func createSimpleTestJWT(userDID string) string {
+
// Create minimal JWT claims using RegisteredClaims
+
// Use userDID as issuer since we don't have a proper PDS DID for testing
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: userDID,
+
Issuer: userDID, // Use DID as issuer for testing (valid per atProto)
+
Audience: jwt.ClaimStrings{"did:web:test.coves.social"},
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
},
+
Scope: "com.atproto.access",
+
}
+
+
// For Phase 1 testing, we create an unsigned JWT
+
// The middleware is configured with skipVerify=true for testing
+
header := map[string]interface{}{
+
"alg": "none",
+
"typ": "JWT",
+
}
+
+
headerJSON, _ := json.Marshal(header)
+
claimsJSON, _ := json.Marshal(claims)
+
+
// Base64url encode (without padding)
+
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
+
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
+
+
// For "alg: none", signature is empty
+
return headerB64 + "." + claimsB64 + "."
+
}
+
+
// generateTID generates a simple timestamp-based identifier for testing
+
// In production, PDS generates proper TIDs
+
func generateTID() string {
+
return fmt.Sprintf("3k%d", time.Now().UnixNano()/1000)
+
}
+
+
// subscribeToJetstreamForPost subscribes to real Jetstream firehose and processes post events
+
// This helper creates a WebSocket connection to Jetstream and waits for post events
+
func subscribeToJetstreamForPost(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.PostEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
errorChan chan<- error,
+
done <-chan bool,
+
) error {
+
conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() { _ = conn.Close() }()
+
+
// Read messages until we find our event or receive done signal
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
// Set read deadline to avoid blocking forever
+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+
return fmt.Errorf("failed to set read deadline: %w", err)
+
}
+
+
var event jetstream.JetstreamEvent
+
err := conn.ReadJSON(&event)
+
if err != nil {
+
// Check if it's a timeout (expected)
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue // Timeout is expected, keep listening
+
}
+
// For other errors, don't retry reading from a broken connection
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
// 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" {
+
// Process the event through the consumer
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
// Send to channel so test can verify
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+470
tests/integration/post_handler_test.go
···
···
+
package integration
+
+
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/db/postgres"
+
"bytes"
+
"encoding/json"
+
"net/http"
+
"net/http/httptest"
+
"strings"
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// TestPostHandler_SecurityValidation tests HTTP handler-level security checks
+
func TestPostHandler_SecurityValidation(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Setup services
+
communityRepo := postgres.NewCommunityRepository(db)
+
communityService := communities.NewCommunityService(
+
communityRepo,
+
"http://localhost:3001",
+
"did:web:test.coves.social",
+
"test.coves.social",
+
nil,
+
)
+
+
postRepo := postgres.NewPostRepository(db)
+
postService := posts.NewPostService(postRepo, communityService, "http://localhost:3001")
+
+
// Create handler
+
handler := post.NewCreateHandler(postService)
+
+
t.Run("Reject client-provided authorDid", func(t *testing.T) {
+
// Client tries to impersonate another user
+
payload := map[string]interface{}{
+
"community": "did:plc:test123",
+
"authorDid": "did:plc:attacker", // ❌ Client trying to set author
+
"content": "Malicious post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should return 400 Bad Request
+
assert.Equal(t, http.StatusBadRequest, rec.Code)
+
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
assert.Equal(t, "InvalidRequest", errResp["error"])
+
assert.Contains(t, errResp["message"], "authorDid must not be provided")
+
})
+
+
t.Run("Reject missing authentication", func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": "did:plc:test123",
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// No auth context set
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should return 401 Unauthorized
+
assert.Equal(t, http.StatusUnauthorized, rec.Code)
+
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
assert.Equal(t, "AuthRequired", errResp["error"])
+
})
+
+
t.Run("Reject request body > 1MB", func(t *testing.T) {
+
// Create a payload larger than 1MB
+
largeContent := strings.Repeat("A", 1*1024*1024+1000) // 1MB + 1KB
+
+
payload := map[string]interface{}{
+
"community": "did:plc:test123",
+
"content": largeContent,
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should return 413 Request Entity Too Large
+
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
+
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
assert.Equal(t, "RequestTooLarge", errResp["error"])
+
})
+
+
t.Run("Reject malformed JSON", func(t *testing.T) {
+
// Invalid JSON
+
invalidJSON := []byte(`{"community": "did:plc:test123", "content": `)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(invalidJSON))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should return 400 Bad Request
+
assert.Equal(t, http.StatusBadRequest, rec.Code)
+
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
assert.Equal(t, "InvalidRequest", errResp["error"])
+
})
+
+
t.Run("Reject empty community field", func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": "", // Empty community
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should return 400 Bad Request
+
assert.Equal(t, http.StatusBadRequest, rec.Code)
+
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
assert.Equal(t, "InvalidRequest", errResp["error"])
+
assert.Contains(t, errResp["message"], "community is required")
+
})
+
+
t.Run("Reject invalid at-identifier format", func(t *testing.T) {
+
invalidIdentifiers := []string{
+
"not-a-did-or-handle",
+
"just-plain-text",
+
"http://example.com",
+
}
+
+
for _, invalidID := range invalidIdentifiers {
+
t.Run(invalidID, func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": invalidID,
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should reject (either 400 InvalidRequest or 404 NotFound depending on how service resolves it)
+
// Both are valid - the important thing is that it rejects invalid identifiers
+
assert.True(t, rec.Code == http.StatusBadRequest || rec.Code == http.StatusNotFound,
+
"Should reject invalid identifier with 400 or 404, got %d", rec.Code)
+
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
// Should have an error type and message
+
assert.NotEmpty(t, errResp["error"], "should have error type")
+
assert.NotEmpty(t, errResp["message"], "should have error message")
+
})
+
}
+
})
+
+
t.Run("Accept valid DID format", func(t *testing.T) {
+
validDIDs := []string{
+
"did:plc:test123",
+
"did:web:example.com",
+
}
+
+
for _, validDID := range validDIDs {
+
t.Run(validDID, func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": validDID,
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// May fail at service layer (community not found), but should NOT fail at validation
+
// Looking for anything OTHER than "community must be a DID" error
+
if rec.Code == http.StatusBadRequest {
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
// Should NOT be the format validation error
+
assert.NotContains(t, errResp["message"], "community must be a DID")
+
}
+
})
+
}
+
})
+
+
t.Run("Accept valid scoped handle format", func(t *testing.T) {
+
// Scoped format: !name@instance (gets converted to name.community.instance internally)
+
validScopedHandles := []string{
+
"!mycommunity@bsky.social", // Scoped format
+
"!gaming@test.coves.social", // Scoped format
+
}
+
+
for _, validHandle := range validScopedHandles {
+
t.Run(validHandle, func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": validHandle,
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// May fail at service layer (community not found), but should NOT fail at format validation
+
if rec.Code == http.StatusBadRequest {
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
// Should NOT be the format validation error
+
assert.NotContains(t, errResp["message"], "community must be a DID")
+
assert.NotContains(t, errResp["message"], "scoped handle must include")
+
}
+
})
+
}
+
})
+
+
t.Run("Accept valid canonical handle format", func(t *testing.T) {
+
// Canonical format: name.community.instance (DNS-resolvable atProto handle)
+
validCanonicalHandles := []string{
+
"gaming.community.test.coves.social",
+
"books.community.bsky.social",
+
}
+
+
for _, validHandle := range validCanonicalHandles {
+
t.Run(validHandle, func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": validHandle,
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// May fail at service layer (community not found), but should NOT fail at format validation
+
// Canonical handles don't have strict validation at handler level - they're validated by the service
+
if rec.Code == http.StatusBadRequest {
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
// Should NOT be the format validation error (canonical handles pass basic validation)
+
assert.NotContains(t, errResp["message"], "community must be a DID")
+
}
+
})
+
}
+
})
+
+
t.Run("Accept valid @-prefixed handle format", func(t *testing.T) {
+
// @-prefixed format: @name.community.instance (atProto standard, @ gets stripped)
+
validAtHandles := []string{
+
"@gaming.community.test.coves.social",
+
"@books.community.bsky.social",
+
}
+
+
for _, validHandle := range validAtHandles {
+
t.Run(validHandle, func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": validHandle,
+
"content": "Test post",
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
// Mock authenticated user context
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// May fail at service layer (community not found), but should NOT fail at format validation
+
// @ prefix is valid and gets stripped by the resolver
+
if rec.Code == http.StatusBadRequest {
+
var errResp map[string]interface{}
+
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
+
require.NoError(t, err)
+
+
// Should NOT be the format validation error
+
assert.NotContains(t, errResp["message"], "community must be a DID")
+
}
+
})
+
}
+
})
+
+
t.Run("Reject non-POST methods", func(t *testing.T) {
+
methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
+
+
for _, method := range methods {
+
t.Run(method, func(t *testing.T) {
+
req := httptest.NewRequest(method, "/xrpc/social.coves.post.create", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleCreate(rec, req)
+
+
// Should return 405 Method Not Allowed
+
assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
+
})
+
}
+
})
+
}
+
+
// TestPostHandler_SpecialCharacters tests content with special characters
+
func TestPostHandler_SpecialCharacters(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping integration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Setup services
+
communityRepo := postgres.NewCommunityRepository(db)
+
communityService := communities.NewCommunityService(
+
communityRepo,
+
"http://localhost:3001",
+
"did:web:test.coves.social",
+
"test.coves.social",
+
nil,
+
)
+
+
postRepo := postgres.NewPostRepository(db)
+
postService := posts.NewPostService(postRepo, communityService, "http://localhost:3001")
+
+
handler := post.NewCreateHandler(postService)
+
+
t.Run("Accept Unicode and emoji", func(t *testing.T) {
+
content := "Hello 世界! 🌍 Testing unicode: café, naïve, Ω"
+
+
payload := map[string]interface{}{
+
"community": "did:plc:test123",
+
"content": content,
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Should NOT reject due to unicode/special characters
+
// May fail at service layer for other reasons, but should pass handler validation
+
assert.NotEqual(t, http.StatusBadRequest, rec.Code, "Handler should not reject valid unicode")
+
})
+
+
t.Run("SQL injection attempt is safely handled", func(t *testing.T) {
+
// Common SQL injection patterns
+
sqlInjections := []string{
+
"'; DROP TABLE posts; --",
+
"1' OR '1'='1",
+
"<script>alert('xss')</script>",
+
"../../../etc/passwd",
+
}
+
+
for _, injection := range sqlInjections {
+
t.Run(injection, func(t *testing.T) {
+
payload := map[string]interface{}{
+
"community": "did:plc:test123",
+
"content": injection,
+
}
+
+
body, _ := json.Marshal(payload)
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.post.create", bytes.NewReader(body))
+
+
ctx := middleware.SetTestUserDID(req.Context(), "did:plc:alice")
+
req = req.WithContext(ctx)
+
+
rec := httptest.NewRecorder()
+
handler.HandleCreate(rec, req)
+
+
// Handler should NOT crash or return 500
+
// These are just strings, should be handled safely
+
assert.NotEqual(t, http.StatusInternalServerError, rec.Code,
+
"Handler should not crash on injection attempt")
+
})
+
}
+
})
+
}
+2 -1
tests/integration/subscription_indexing_test.go
···
import (
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
-
postgresRepo "Coves/internal/db/postgres"
"context"
"database/sql"
"fmt"
"testing"
"time"
)
// TestSubscriptionIndexing_ContentVisibility tests that contentVisibility is properly indexed
···
import (
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
+
+
postgresRepo "Coves/internal/db/postgres"
)
// TestSubscriptionIndexing_ContentVisibility tests that contentVisibility is properly indexed
+4 -4
tests/lexicon_validation_test.go
···
recordData: map[string]interface{}{
"$type": "social.coves.post.record",
"community": "did:plc:programming123",
-
"postType": "text",
"title": "Test Post",
"content": "This is a test post",
"createdAt": "2025-01-09T14:30:00Z",
···
shouldFail: false,
},
{
-
name: "Invalid post record - invalid enum value",
recordType: "social.coves.post.record",
recordData: map[string]interface{}{
"$type": "social.coves.post.record",
"community": "did:plc:programming123",
-
"postType": "invalid-type",
"title": "Test Post",
"content": "This is a test post",
"createdAt": "2025-01-09T14:30:00Z",
},
shouldFail: true,
-
errorContains: "string val not in required enum",
},
}
···
recordData: map[string]interface{}{
"$type": "social.coves.post.record",
"community": "did:plc:programming123",
+
"author": "did:plc:testauthor123",
"title": "Test Post",
"content": "This is a test post",
"createdAt": "2025-01-09T14:30:00Z",
···
shouldFail: false,
},
{
+
name: "Invalid post record - missing required field",
recordType: "social.coves.post.record",
recordData: map[string]interface{}{
"$type": "social.coves.post.record",
"community": "did:plc:programming123",
+
// Missing required "author" field
"title": "Test Post",
"content": "This is a test post",
"createdAt": "2025-01-09T14:30:00Z",
},
shouldFail: true,
+
errorContains: "required field missing",
},
}