A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+10410 -393
.beads
aggregators
cmd
reindex-votes
server
internal
api
atproto
core
db
scripts
tests
+41
internal/atproto/lexicon/social/coves/feed/vote/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.feed.vote.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a vote on a post or comment",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["subject"],
+
"properties": {
+
"subject": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the post or comment to remove the vote from"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {}
+
}
+
},
+
"errors": [
+
{
+
"name": "VoteNotFound",
+
"description": "No vote found for this subject"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to delete this vote"
+
}
+
]
+
}
+
}
+
}
+115
internal/api/handlers/vote/create_vote.go
···
+
package vote
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// CreateVoteHandler handles vote creation
+
type CreateVoteHandler struct {
+
service votes.Service
+
}
+
+
// NewCreateVoteHandler creates a new create vote handler
+
func NewCreateVoteHandler(service votes.Service) *CreateVoteHandler {
+
return &CreateVoteHandler{
+
service: service,
+
}
+
}
+
+
// CreateVoteInput represents the request body for creating a vote
+
type CreateVoteInput struct {
+
Subject struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"subject"`
+
Direction string `json:"direction"`
+
}
+
+
// CreateVoteOutput represents the response body for creating a vote
+
type CreateVoteOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleCreateVote creates a vote on a post or comment
+
// POST /xrpc/social.coves.vote.create
+
//
+
// Request body: { "subject": { "uri": "at://...", "cid": "..." }, "direction": "up" }
+
// Response: { "uri": "at://...", "cid": "..." }
+
//
+
// Behavior:
+
// - If no vote exists: creates new vote with given direction
+
// - If vote exists with same direction: deletes vote (toggle off)
+
// - If vote exists with different direction: updates to new direction
+
func (h *CreateVoteHandler) HandleCreateVote(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var input CreateVoteInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// Validate required fields
+
if input.Subject.URI == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.uri is required")
+
return
+
}
+
if input.Subject.CID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.cid is required")
+
return
+
}
+
if input.Direction == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "direction is required")
+
return
+
}
+
+
// Validate direction
+
if input.Direction != "up" && input.Direction != "down" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "direction must be 'up' or 'down'")
+
return
+
}
+
+
// Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Create vote request
+
req := votes.CreateVoteRequest{
+
Subject: votes.StrongRef{
+
URI: input.Subject.URI,
+
CID: input.Subject.CID,
+
},
+
Direction: input.Direction,
+
}
+
+
// Call service to create vote
+
response, err := h.service.CreateVote(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response
+
output := CreateVoteOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+93
internal/api/handlers/vote/delete_vote.go
···
+
package vote
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// DeleteVoteHandler handles vote deletion
+
type DeleteVoteHandler struct {
+
service votes.Service
+
}
+
+
// NewDeleteVoteHandler creates a new delete vote handler
+
func NewDeleteVoteHandler(service votes.Service) *DeleteVoteHandler {
+
return &DeleteVoteHandler{
+
service: service,
+
}
+
}
+
+
// DeleteVoteInput represents the request body for deleting a vote
+
type DeleteVoteInput struct {
+
Subject struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"subject"`
+
}
+
+
// DeleteVoteOutput represents the response body for deleting a vote
+
// Per lexicon: output is an empty object
+
type DeleteVoteOutput struct{}
+
+
// HandleDeleteVote removes a vote from a post or comment
+
// POST /xrpc/social.coves.vote.delete
+
//
+
// Request body: { "subject": { "uri": "at://...", "cid": "..." } }
+
// Response: { "success": true }
+
func (h *DeleteVoteHandler) HandleDeleteVote(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var input DeleteVoteInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// Validate required fields
+
if input.Subject.URI == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.uri is required")
+
return
+
}
+
if input.Subject.CID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.cid is required")
+
return
+
}
+
+
// Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Create delete vote request
+
req := votes.DeleteVoteRequest{
+
Subject: votes.StrongRef{
+
URI: input.Subject.URI,
+
CID: input.Subject.CID,
+
},
+
}
+
+
// Call service to delete vote
+
err := h.service.DeleteVote(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response (empty object per lexicon)
+
output := DeleteVoteOutput{}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+24
internal/api/routes/vote.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/vote"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterVoteRoutes registers vote-related XRPC endpoints on the router
+
// Implements social.coves.feed.vote.* lexicon endpoints
+
func RegisterVoteRoutes(r chi.Router, voteService votes.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+
// Initialize handlers
+
createHandler := vote.NewCreateVoteHandler(voteService)
+
deleteHandler := vote.NewDeleteVoteHandler(voteService)
+
+
// Procedure endpoints (POST) - require authentication
+
// social.coves.feed.vote.create - create or update a vote on a post/comment
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.create", createHandler.HandleCreateVote)
+
+
// social.coves.feed.vote.delete - delete a vote from a post/comment
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.delete", deleteHandler.HandleDeleteVote)
+
}
-3
internal/api/handlers/vote/errors.go
···
case errors.Is(err, votes.ErrVoteNotFound):
// Matches: social.coves.feed.vote.delete#VoteNotFound
writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject")
-
case errors.Is(err, votes.ErrSubjectNotFound):
-
// Matches: social.coves.feed.vote.create#SubjectNotFound
-
writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")
case errors.Is(err, votes.ErrInvalidDirection):
writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
case errors.Is(err, votes.ErrInvalidSubject):
+4 -4
internal/atproto/oauth/handlers_security.go
···
// - Android: Verified via /.well-known/assetlinks.json
var allowedMobileRedirectURIs = map[string]bool{
// Custom scheme per atproto spec (reverse-domain of coves.social)
-
"social.coves:/callback": true,
-
"social.coves://callback": true, // Some platforms add double slash
-
"social.coves:/oauth/callback": true, // Alternative path
-
"social.coves://oauth/callback": true,
+
"social.coves:/callback": true,
+
"social.coves://callback": true, // Some platforms add double slash
+
"social.coves:/oauth/callback": true, // Alternative path
+
"social.coves://oauth/callback": true,
// Universal Links - cryptographically bound to app (preferred for security)
"https://coves.social/app/oauth/callback": true,
}
-3
internal/core/votes/errors.go
···
// ErrVoteNotFound indicates the requested vote doesn't exist
ErrVoteNotFound = errors.New("vote not found")
-
// ErrSubjectNotFound indicates the post/comment being voted on doesn't exist
-
ErrSubjectNotFound = errors.New("subject not found")
-
// ErrInvalidDirection indicates the vote direction is not "up" or "down"
ErrInvalidDirection = errors.New("invalid vote direction: must be 'up' or 'down'")
+3 -2
internal/db/postgres/vote_repo.go
···
return nil
}
-
// GetByURI retrieves a vote by its AT-URI
+
// GetByURI retrieves an active vote by its AT-URI
// Used by Jetstream consumer for DELETE operations
+
// Returns ErrVoteNotFound for soft-deleted votes
func (r *postgresVoteRepo) GetByURI(ctx context.Context, uri string) (*votes.Vote, error) {
query := `
SELECT
···
subject_uri, subject_cid, direction,
created_at, indexed_at, deleted_at
FROM votes
-
WHERE uri = $1
+
WHERE uri = $1 AND deleted_at IS NULL
`
var vote votes.Vote
+285
internal/atproto/oauth/dev_auth_resolver.go
···
+
//go:build dev
+
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// DevAuthResolver is a custom OAuth resolver that allows HTTP localhost URLs for development.
+
// The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing.
+
type DevAuthResolver struct {
+
Client *http.Client
+
UserAgent string
+
PDSURL string // For resolving handles via local PDS
+
handleResolver *DevHandleResolver
+
}
+
+
// ProtectedResourceMetadata matches the OAuth protected resource metadata document format
+
type ProtectedResourceMetadata struct {
+
Resource string `json:"resource"`
+
AuthorizationServers []string `json:"authorization_servers"`
+
}
+
+
// NewDevAuthResolver creates a resolver that accepts localhost HTTP URLs
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
+
resolver := &DevAuthResolver{
+
Client: NewSSRFSafeHTTPClient(allowPrivateIPs),
+
UserAgent: "Coves/1.0",
+
PDSURL: pdsURL,
+
}
+
// Create handle resolver for resolving handles via local PDS
+
if pdsURL != "" {
+
resolver.handleResolver = NewDevHandleResolver(pdsURL, allowPrivateIPs)
+
}
+
return resolver
+
}
+
+
// ResolveAuthServerURL resolves a PDS URL to an auth server URL.
+
// Unlike indigo's standard resolver, this allows HTTP and ports for localhost.
+
func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {
+
u, err := url.Parse(hostURL)
+
if err != nil {
+
return "", err
+
}
+
+
// For localhost, allow HTTP and port numbers
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
+
if !isLocalhost {
+
// For non-localhost, enforce HTTPS and no port (standard rules)
+
if u.Scheme != "https" || u.Port() != "" {
+
return "", fmt.Errorf("not a valid public host URL: %s", hostURL)
+
}
+
}
+
+
// Build the protected resource document URL
+
var docURL string
+
if isLocalhost {
+
// For localhost, preserve the port and use HTTP
+
port := u.Port()
+
if port == "" {
+
port = "3001" // Default PDS port
+
}
+
docURL = fmt.Sprintf("http://%s:%s/.well-known/oauth-protected-resource", u.Hostname(), port)
+
} else {
+
docURL = fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname())
+
}
+
+
// Fetch the protected resource document
+
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
+
if err != nil {
+
return "", err
+
}
+
if r.UserAgent != "" {
+
req.Header.Set("User-Agent", r.UserAgent)
+
}
+
+
resp, err := r.Client.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("fetching protected resource document: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode)
+
}
+
+
var body ProtectedResourceMetadata
+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+
return "", fmt.Errorf("invalid protected resource document: %w", err)
+
}
+
+
if len(body.AuthorizationServers) < 1 {
+
return "", fmt.Errorf("no auth server URL in protected resource document")
+
}
+
+
authURL := body.AuthorizationServers[0]
+
+
// Validate the auth server URL (with localhost exception)
+
au, err := url.Parse(authURL)
+
if err != nil {
+
return "", fmt.Errorf("invalid auth server URL: %w", err)
+
}
+
+
authIsLocalhost := au.Hostname() == "localhost" || au.Hostname() == "127.0.0.1"
+
if !authIsLocalhost {
+
if au.Scheme != "https" || au.Port() != "" {
+
return "", fmt.Errorf("invalid auth server URL: %s", authURL)
+
}
+
}
+
+
return authURL, nil
+
}
+
+
// ResolveAuthServerMetadataDev fetches OAuth server metadata from a given auth server URL.
+
// Unlike indigo's resolver, this allows HTTP and ports for localhost.
+
func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) {
+
u, err := url.Parse(serverURL)
+
if err != nil {
+
return nil, err
+
}
+
+
// Build metadata URL - preserve port for localhost
+
var metaURL string
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
+
if isLocalhost && u.Port() != "" {
+
metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port())
+
} else if isLocalhost {
+
metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname())
+
} else {
+
metaURL = fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname())
+
}
+
+
slog.Debug("dev mode: fetching auth server metadata", "url", metaURL)
+
+
req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil)
+
if err != nil {
+
return nil, err
+
}
+
if r.UserAgent != "" {
+
req.Header.Set("User-Agent", r.UserAgent)
+
}
+
+
resp, err := r.Client.Do(req)
+
if err != nil {
+
return nil, fmt.Errorf("fetching auth server metadata: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode)
+
}
+
+
var metadata oauthlib.AuthServerMetadata
+
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
+
}
+
+
// Skip validation for localhost (indigo's Validate checks HTTPS)
+
if !isLocalhost {
+
if err := metadata.Validate(serverURL); err != nil {
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
+
}
+
}
+
+
return &metadata, nil
+
}
+
+
// StartDevAuthFlow performs OAuth flow for localhost development.
+
// This bypasses indigo's HTTPS validation for the auth server URL.
+
// It resolves the identity, gets the PDS endpoint, fetches auth server metadata,
+
// and returns a redirect URL for the user to approve.
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+
var accountDID syntax.DID
+
var pdsEndpoint string
+
+
// Check if identifier is a handle or DID
+
if strings.HasPrefix(identifier, "did:") {
+
// It's a DID - look up via directory (PLC)
+
atid, err := syntax.ParseAtIdentifier(identifier)
+
if err != nil {
+
return "", fmt.Errorf("not a valid DID (%s): %w", identifier, err)
+
}
+
ident, err := dir.Lookup(ctx, *atid)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve DID (%s): %w", identifier, err)
+
}
+
accountDID = ident.DID
+
pdsEndpoint = ident.PDSEndpoint()
+
} else {
+
// It's a handle - resolve via local PDS first
+
if r.handleResolver == nil {
+
return "", fmt.Errorf("handle resolution not configured (PDS URL not set)")
+
}
+
+
// Resolve handle to DID via local PDS
+
did, err := r.handleResolver.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle via PDS (%s): %w", identifier, err)
+
}
+
if did == "" {
+
return "", fmt.Errorf("handle not found: %s", identifier)
+
}
+
+
slog.Info("dev mode: resolved handle via local PDS", "handle", identifier, "did", did)
+
+
// Parse the DID
+
parsedDID, err := syntax.ParseDID(did)
+
if err != nil {
+
return "", fmt.Errorf("invalid DID from PDS (%s): %w", did, err)
+
}
+
accountDID = parsedDID
+
+
// Now look up the DID document via PLC to get PDS endpoint
+
atid, err := syntax.ParseAtIdentifier(did)
+
if err != nil {
+
return "", fmt.Errorf("not a valid DID (%s): %w", did, err)
+
}
+
ident, err := dir.Lookup(ctx, *atid)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve DID document (%s): %w", did, err)
+
}
+
pdsEndpoint = ident.PDSEndpoint()
+
}
+
+
if pdsEndpoint == "" {
+
return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
+
}
+
+
slog.Debug("dev mode: resolving auth server",
+
"did", accountDID,
+
"pds", pdsEndpoint)
+
+
// Resolve auth server URL (allowing HTTP for localhost)
+
authServerURL, err := r.ResolveAuthServerURL(ctx, pdsEndpoint)
+
if err != nil {
+
return "", fmt.Errorf("resolving auth server: %w", err)
+
}
+
+
slog.Info("dev mode: resolved auth server", "url", authServerURL)
+
+
// Fetch auth server metadata using our dev-friendly resolver
+
authMeta, err := r.ResolveAuthServerMetadataDev(ctx, authServerURL)
+
if err != nil {
+
return "", fmt.Errorf("fetching auth server metadata: %w", err)
+
}
+
+
slog.Debug("dev mode: got auth server metadata",
+
"issuer", authMeta.Issuer,
+
"authorization_endpoint", authMeta.AuthorizationEndpoint,
+
"token_endpoint", authMeta.TokenEndpoint)
+
+
// Send auth request (PAR) using indigo's method
+
info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier)
+
if err != nil {
+
return "", fmt.Errorf("auth request failed: %w", err)
+
}
+
+
// Set the account DID
+
info.AccountDID = &accountDID
+
+
// Persist auth request info
+
client.ClientApp.Store.SaveAuthRequestInfo(ctx, *info)
+
+
// Build redirect URL
+
params := url.Values{}
+
params.Set("client_id", client.ClientApp.Config.ClientID)
+
params.Set("request_uri", info.RequestURI)
+
+
authEndpoint := authMeta.AuthorizationEndpoint
+
redirectURL := fmt.Sprintf("%s?%s", authEndpoint, params.Encode())
+
+
slog.Info("dev mode: OAuth redirect URL built", "url_prefix", authEndpoint)
+
+
return redirectURL, nil
+
}
+106
internal/atproto/oauth/dev_resolver.go
···
+
//go:build dev
+
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
)
+
+
// DevHandleResolver resolves handles via local PDS for development
+
// This is needed because local handles (e.g., user.local.coves.dev) can't be
+
// resolved via standard DNS/HTTP well-known methods - they only exist on the local PDS.
+
type DevHandleResolver struct {
+
pdsURL string
+
httpClient *http.Client
+
}
+
+
// NewDevHandleResolver creates a resolver that queries local PDS for handle resolution
+
func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver {
+
return &DevHandleResolver{
+
pdsURL: strings.TrimSuffix(pdsURL, "/"),
+
httpClient: NewSSRFSafeHTTPClient(allowPrivateIPs),
+
}
+
}
+
+
// ResolveHandle queries the local PDS to resolve a handle to a DID
+
// Returns the DID if successful, or empty string if not found
+
func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
+
if r.pdsURL == "" {
+
return "", fmt.Errorf("PDS URL not configured")
+
}
+
+
// Build the resolve handle URL
+
resolveURL := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s",
+
r.pdsURL, url.QueryEscape(handle))
+
+
// Create request with context and timeout
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+
defer cancel()
+
+
req, err := http.NewRequestWithContext(ctx, "GET", resolveURL, nil)
+
if err != nil {
+
return "", fmt.Errorf("failed to create request: %w", err)
+
}
+
req.Header.Set("User-Agent", "Coves/1.0")
+
+
// Execute request
+
resp, err := r.httpClient.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("failed to query PDS: %w", err)
+
}
+
defer resp.Body.Close()
+
+
// Check response status
+
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
+
return "", nil // Handle not found
+
}
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("PDS returned status %d", resp.StatusCode)
+
}
+
+
// Parse response
+
var result struct {
+
DID string `json:"did"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return "", fmt.Errorf("failed to parse PDS response: %w", err)
+
}
+
+
if result.DID == "" {
+
return "", nil // No DID in response
+
}
+
+
slog.Debug("resolved handle via local PDS",
+
"handle", handle,
+
"did", result.DID,
+
"pds_url", r.pdsURL)
+
+
return result.DID, nil
+
}
+
+
// ResolveIdentifier attempts to resolve a handle to DID, or returns the DID if already provided
+
// This is the main entry point for the handlers
+
func (r *DevHandleResolver) ResolveIdentifier(ctx context.Context, identifier string) (string, error) {
+
// If it's already a DID, return as-is
+
if strings.HasPrefix(identifier, "did:") {
+
return identifier, nil
+
}
+
+
// Try to resolve the handle via local PDS
+
did, err := r.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle via PDS: %w", err)
+
}
+
if did == "" {
+
return "", fmt.Errorf("handle not found on local PDS: %s", identifier)
+
}
+
+
return did, nil
+
}
+41
internal/atproto/oauth/dev_stubs.go
···
+
//go:build !dev
+
+
package oauth
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/atproto/identity"
+
)
+
+
// DevHandleResolver is a stub for production builds.
+
// The actual implementation is in dev_resolver.go (only compiled with -tags dev).
+
type DevHandleResolver struct{}
+
+
// NewDevHandleResolver returns nil in production builds.
+
// Dev mode features are only available when built with -tags dev.
+
func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver {
+
return nil
+
}
+
+
// ResolveHandle is a stub that should never be called in production.
+
// The nil check in handlers.go prevents this from being reached.
+
func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
+
panic("dev mode: ResolveHandle called in production build - this should never happen")
+
}
+
+
// DevAuthResolver is a stub for production builds.
+
// The actual implementation is in dev_auth_resolver.go (only compiled with -tags dev).
+
type DevAuthResolver struct{}
+
+
// NewDevAuthResolver returns nil in production builds.
+
// Dev mode features are only available when built with -tags dev.
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
+
return nil
+
}
+
+
// StartDevAuthFlow is a stub that should never be called in production.
+
// The nil check in handlers.go prevents this from being reached.
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+
panic("dev mode: StartDevAuthFlow called in production build - this should never happen")
+
}
+5 -1
scripts/dev-run.sh
···
#!/bin/bash
# Development server runner - loads .env.dev before starting
+
# Uses -tags dev to include dev-only code (localhost OAuth resolvers, etc.)
set -a # automatically export all variables
source .env.dev
···
echo " IS_DEV_ENV: $IS_DEV_ENV"
echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL"
echo " JETSTREAM_URL: $JETSTREAM_URL"
+
echo " APPVIEW_PUBLIC_URL: $APPVIEW_PUBLIC_URL"
+
echo " PDS_URL: $PDS_URL"
+
echo " Build tags: dev"
echo ""
-
go run ./cmd/server
+
go run -tags dev ./cmd/server
+267
cmd/reindex-votes/main.go
···
+
// cmd/reindex-votes/main.go
+
// Quick tool to reindex votes from PDS to AppView database
+
package main
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"log"
+
"net/http"
+
"net/url"
+
"os"
+
"strings"
+
"time"
+
+
_ "github.com/lib/pq"
+
)
+
+
type ListRecordsResponse struct {
+
Records []Record `json:"records"`
+
Cursor string `json:"cursor"`
+
}
+
+
type Record struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
Value map[string]interface{} `json:"value"`
+
}
+
+
func main() {
+
// Get config from env
+
dbURL := os.Getenv("DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable"
+
}
+
pdsURL := os.Getenv("PDS_URL")
+
if pdsURL == "" {
+
pdsURL = "http://localhost:3001"
+
}
+
+
log.Printf("Connecting to database...")
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
log.Fatalf("Failed to connect to database: %v", err)
+
}
+
defer db.Close()
+
+
ctx := context.Background()
+
+
// Get all accounts directly from the PDS
+
log.Printf("Fetching accounts from PDS (%s)...", pdsURL)
+
dids, err := fetchAllAccountsFromPDS(pdsURL)
+
if err != nil {
+
log.Fatalf("Failed to fetch accounts from PDS: %v", err)
+
}
+
log.Printf("Found %d accounts on PDS to check for votes", len(dids))
+
+
// Reset vote counts first
+
log.Printf("Resetting all vote counts...")
+
if _, err := db.ExecContext(ctx, "DELETE FROM votes"); err != nil {
+
log.Fatalf("Failed to clear votes table: %v", err)
+
}
+
if _, err := db.ExecContext(ctx, "UPDATE posts SET upvote_count = 0, downvote_count = 0, score = 0"); err != nil {
+
log.Fatalf("Failed to reset post vote counts: %v", err)
+
}
+
if _, err := db.ExecContext(ctx, "UPDATE comments SET upvote_count = 0, downvote_count = 0, score = 0"); err != nil {
+
log.Fatalf("Failed to reset comment vote counts: %v", err)
+
}
+
+
// For each user, fetch their votes from PDS
+
totalVotes := 0
+
for _, did := range dids {
+
votes, err := fetchVotesFromPDS(pdsURL, did)
+
if err != nil {
+
log.Printf("Warning: failed to fetch votes for %s: %v", did, err)
+
continue
+
}
+
+
if len(votes) == 0 {
+
continue
+
}
+
+
log.Printf("Found %d votes for %s", len(votes), did)
+
+
// Index each vote
+
for _, vote := range votes {
+
if err := indexVote(ctx, db, did, vote); err != nil {
+
log.Printf("Warning: failed to index vote %s: %v", vote.URI, err)
+
continue
+
}
+
totalVotes++
+
}
+
}
+
+
log.Printf("โœ“ Reindexed %d votes from PDS", totalVotes)
+
}
+
+
// fetchAllAccountsFromPDS queries the PDS sync API to get all repo DIDs
+
func fetchAllAccountsFromPDS(pdsURL string) ([]string, error) {
+
// Use com.atproto.sync.listRepos to get all repos on this PDS
+
var allDIDs []string
+
cursor := ""
+
+
for {
+
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.listRepos?limit=100", pdsURL)
+
if cursor != "" {
+
reqURL += "&cursor=" + url.QueryEscape(cursor)
+
}
+
+
resp, err := http.Get(reqURL)
+
if err != nil {
+
return nil, fmt.Errorf("HTTP request failed: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != 200 {
+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+
}
+
+
var result struct {
+
Repos []struct {
+
DID string `json:"did"`
+
} `json:"repos"`
+
Cursor string `json:"cursor"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return nil, fmt.Errorf("failed to decode response: %w", err)
+
}
+
+
for _, repo := range result.Repos {
+
allDIDs = append(allDIDs, repo.DID)
+
}
+
+
if result.Cursor == "" {
+
break
+
}
+
cursor = result.Cursor
+
}
+
+
return allDIDs, nil
+
}
+
+
func fetchVotesFromPDS(pdsURL, did string) ([]Record, error) {
+
var allRecords []Record
+
cursor := ""
+
collection := "social.coves.feed.vote"
+
+
for {
+
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100",
+
pdsURL, url.QueryEscape(did), url.QueryEscape(collection))
+
if cursor != "" {
+
reqURL += "&cursor=" + url.QueryEscape(cursor)
+
}
+
+
resp, err := http.Get(reqURL)
+
if err != nil {
+
return nil, fmt.Errorf("HTTP request failed: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode == 400 {
+
// User doesn't exist on this PDS or has no records - that's OK
+
return nil, nil
+
}
+
if resp.StatusCode != 200 {
+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+
}
+
+
var result ListRecordsResponse
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return nil, fmt.Errorf("failed to decode response: %w", err)
+
}
+
+
allRecords = append(allRecords, result.Records...)
+
+
if result.Cursor == "" {
+
break
+
}
+
cursor = result.Cursor
+
}
+
+
return allRecords, nil
+
}
+
+
func indexVote(ctx context.Context, db *sql.DB, voterDID string, record Record) error {
+
// Extract vote data from record
+
subject, ok := record.Value["subject"].(map[string]interface{})
+
if !ok {
+
return fmt.Errorf("missing subject")
+
}
+
subjectURI, _ := subject["uri"].(string)
+
subjectCID, _ := subject["cid"].(string)
+
direction, _ := record.Value["direction"].(string)
+
createdAtStr, _ := record.Value["createdAt"].(string)
+
+
if subjectURI == "" || direction == "" {
+
return fmt.Errorf("invalid vote record: missing required fields")
+
}
+
+
// Parse created_at
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
+
if err != nil {
+
createdAt = time.Now()
+
}
+
+
// Extract rkey from URI (at://did/collection/rkey)
+
parts := strings.Split(record.URI, "/")
+
if len(parts) < 5 {
+
return fmt.Errorf("invalid URI format: %s", record.URI)
+
}
+
rkey := parts[len(parts)-1]
+
+
// Start transaction
+
tx, err := db.BeginTx(ctx, nil)
+
if err != nil {
+
return fmt.Errorf("failed to begin transaction: %w", err)
+
}
+
defer tx.Rollback()
+
+
// Insert vote
+
_, err = tx.ExecContext(ctx, `
+
INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at)
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
+
ON CONFLICT (uri) DO NOTHING
+
`, record.URI, record.CID, rkey, voterDID, subjectURI, subjectCID, direction, createdAt)
+
if err != nil {
+
return fmt.Errorf("failed to insert vote: %w", err)
+
}
+
+
// Update post/comment counts
+
collection := extractCollectionFromURI(subjectURI)
+
var updateQuery string
+
+
switch collection {
+
case "social.coves.community.post":
+
if direction == "up" {
+
updateQuery = `UPDATE posts SET upvote_count = upvote_count + 1, score = upvote_count + 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
} else {
+
updateQuery = `UPDATE posts SET downvote_count = downvote_count + 1, score = upvote_count - (downvote_count + 1) WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
case "social.coves.community.comment":
+
if direction == "up" {
+
updateQuery = `UPDATE comments SET upvote_count = upvote_count + 1, score = upvote_count + 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
} else {
+
updateQuery = `UPDATE comments SET downvote_count = downvote_count + 1, score = upvote_count - (downvote_count + 1) WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
default:
+
// Unknown collection, just index the vote
+
return tx.Commit()
+
}
+
+
if _, err := tx.ExecContext(ctx, updateQuery, subjectURI); err != nil {
+
return fmt.Errorf("failed to update vote counts: %w", err)
+
}
+
+
return tx.Commit()
+
}
+
+
func extractCollectionFromURI(uri string) string {
+
// at://did:plc:xxx/social.coves.community.post/rkey
+
parts := strings.Split(uri, "/")
+
if len(parts) >= 4 {
+
return parts[3]
+
}
+
return ""
+
}
+221
internal/core/votes/cache.go
···
+
package votes
+
+
import (
+
"context"
+
"fmt"
+
"log/slog"
+
"strings"
+
"sync"
+
"time"
+
+
"Coves/internal/atproto/pds"
+
)
+
+
// CachedVote represents a vote stored in the cache
+
type CachedVote struct {
+
Direction string // "up" or "down"
+
URI string // vote record URI (at://did/collection/rkey)
+
RKey string // record key
+
}
+
+
// VoteCache provides an in-memory cache of user votes fetched from their PDS.
+
// This avoids eventual consistency issues with the AppView database.
+
type VoteCache struct {
+
mu sync.RWMutex
+
votes map[string]map[string]*CachedVote // userDID -> subjectURI -> vote
+
expiry map[string]time.Time // userDID -> expiry time
+
ttl time.Duration
+
logger *slog.Logger
+
}
+
+
// NewVoteCache creates a new vote cache with the specified TTL
+
func NewVoteCache(ttl time.Duration, logger *slog.Logger) *VoteCache {
+
if logger == nil {
+
logger = slog.Default()
+
}
+
return &VoteCache{
+
votes: make(map[string]map[string]*CachedVote),
+
expiry: make(map[string]time.Time),
+
ttl: ttl,
+
logger: logger,
+
}
+
}
+
+
// GetVotesForUser returns all cached votes for a user.
+
// Returns nil if cache is empty or expired for this user.
+
func (c *VoteCache) GetVotesForUser(userDID string) map[string]*CachedVote {
+
c.mu.RLock()
+
defer c.mu.RUnlock()
+
+
// Check if cache exists and is not expired
+
expiry, exists := c.expiry[userDID]
+
if !exists || time.Now().After(expiry) {
+
return nil
+
}
+
+
return c.votes[userDID]
+
}
+
+
// GetVote returns the cached vote for a specific subject, or nil if not found/expired
+
func (c *VoteCache) GetVote(userDID, subjectURI string) *CachedVote {
+
votes := c.GetVotesForUser(userDID)
+
if votes == nil {
+
return nil
+
}
+
return votes[subjectURI]
+
}
+
+
// IsCached returns true if the user's votes are cached and not expired
+
func (c *VoteCache) IsCached(userDID string) bool {
+
c.mu.RLock()
+
defer c.mu.RUnlock()
+
+
expiry, exists := c.expiry[userDID]
+
return exists && time.Now().Before(expiry)
+
}
+
+
// SetVotesForUser replaces all cached votes for a user
+
func (c *VoteCache) SetVotesForUser(userDID string, votes map[string]*CachedVote) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
c.votes[userDID] = votes
+
c.expiry[userDID] = time.Now().Add(c.ttl)
+
+
c.logger.Debug("vote cache updated",
+
"user", userDID,
+
"vote_count", len(votes),
+
"expires_at", c.expiry[userDID])
+
}
+
+
// SetVote adds or updates a single vote in the cache
+
func (c *VoteCache) SetVote(userDID, subjectURI string, vote *CachedVote) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
if c.votes[userDID] == nil {
+
c.votes[userDID] = make(map[string]*CachedVote)
+
}
+
+
c.votes[userDID][subjectURI] = vote
+
+
// Always extend expiry on vote action - active users keep their cache fresh
+
c.expiry[userDID] = time.Now().Add(c.ttl)
+
+
c.logger.Debug("vote cached",
+
"user", userDID,
+
"subject", subjectURI,
+
"direction", vote.Direction)
+
}
+
+
// RemoveVote removes a vote from the cache (for toggle-off)
+
func (c *VoteCache) RemoveVote(userDID, subjectURI string) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
if c.votes[userDID] != nil {
+
delete(c.votes[userDID], subjectURI)
+
+
// Extend expiry on vote action - active users keep their cache fresh
+
c.expiry[userDID] = time.Now().Add(c.ttl)
+
+
c.logger.Debug("vote removed from cache",
+
"user", userDID,
+
"subject", subjectURI)
+
}
+
}
+
+
// Invalidate removes all cached votes for a user
+
func (c *VoteCache) Invalidate(userDID string) {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
+
delete(c.votes, userDID)
+
delete(c.expiry, userDID)
+
+
c.logger.Debug("vote cache invalidated", "user", userDID)
+
}
+
+
// FetchAndCacheFromPDS fetches all votes from the user's PDS and caches them.
+
// This should be called on first authenticated request or when cache is expired.
+
func (c *VoteCache) FetchAndCacheFromPDS(ctx context.Context, pdsClient pds.Client) error {
+
userDID := pdsClient.DID()
+
+
c.logger.Debug("fetching votes from PDS",
+
"user", userDID,
+
"pds", pdsClient.HostURL())
+
+
votes, err := c.fetchAllVotesFromPDS(ctx, pdsClient)
+
if err != nil {
+
return fmt.Errorf("failed to fetch votes from PDS: %w", err)
+
}
+
+
c.SetVotesForUser(userDID, votes)
+
+
c.logger.Info("vote cache populated from PDS",
+
"user", userDID,
+
"vote_count", len(votes))
+
+
return nil
+
}
+
+
// fetchAllVotesFromPDS paginates through all vote records on the user's PDS
+
func (c *VoteCache) fetchAllVotesFromPDS(ctx context.Context, pdsClient pds.Client) (map[string]*CachedVote, error) {
+
votes := make(map[string]*CachedVote)
+
cursor := ""
+
const pageSize = 100
+
const collection = "social.coves.feed.vote"
+
+
for {
+
result, err := pdsClient.ListRecords(ctx, collection, pageSize, cursor)
+
if err != nil {
+
if pds.IsAuthError(err) {
+
return nil, ErrNotAuthorized
+
}
+
return nil, fmt.Errorf("listRecords failed: %w", err)
+
}
+
+
for _, rec := range result.Records {
+
// Extract subject from record value
+
subject, ok := rec.Value["subject"].(map[string]any)
+
if !ok {
+
continue
+
}
+
+
subjectURI, ok := subject["uri"].(string)
+
if !ok || subjectURI == "" {
+
continue
+
}
+
+
direction, _ := rec.Value["direction"].(string)
+
if direction == "" {
+
continue
+
}
+
+
// Extract rkey from URI
+
rkey := extractRKeyFromURI(rec.URI)
+
+
votes[subjectURI] = &CachedVote{
+
Direction: direction,
+
URI: rec.URI,
+
RKey: rkey,
+
}
+
}
+
+
if result.Cursor == "" {
+
break
+
}
+
cursor = result.Cursor
+
}
+
+
return votes, nil
+
}
+
+
// extractRKeyFromURI extracts the rkey from an AT-URI (at://did/collection/rkey)
+
func extractRKeyFromURI(uri string) string {
+
parts := strings.Split(uri, "/")
+
if len(parts) >= 5 {
+
return parts[len(parts)-1]
+
}
+
return ""
+
}
+14
internal/core/votes/service.go
···
// - Deletes the user's vote record from their PDS
// - AppView will soft-delete via Jetstream consumer
DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req DeleteVoteRequest) error
+
+
// EnsureCachePopulated fetches the user's votes from their PDS if not already cached.
+
// This should be called before rendering feeds to ensure vote state is available.
+
// If cache is already populated and not expired, this is a no-op.
+
EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error
+
+
// GetViewerVote returns the viewer's vote for a specific subject, or nil if not voted.
+
// Returns from cache if available, otherwise returns nil (caller should ensure cache is populated).
+
GetViewerVote(userDID, subjectURI string) *CachedVote
+
+
// GetViewerVotesForSubjects returns the viewer's votes for multiple subjects.
+
// Returns a map of subjectURI -> CachedVote for subjects the user has voted on.
+
// This is efficient for batch lookups when rendering feeds.
+
GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*CachedVote
}
// CreateVoteRequest contains the parameters for creating a vote
+84 -2
internal/core/votes/service_impl.go
···
oauthStore oauth.ClientAuthStore
logger *slog.Logger
pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth.
+
cache *VoteCache // In-memory cache of user votes from PDS
}
// NewService creates a new vote service instance
-
func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {
+
func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, cache *VoteCache, logger *slog.Logger) Service {
if logger == nil {
logger = slog.Default()
}
···
repo: repo,
oauthClient: oauthClient,
oauthStore: oauthStore,
+
cache: cache,
logger: logger,
}
}
// NewServiceWithPDSFactory creates a vote service with a custom PDS client factory.
// This is primarily for testing with password-based authentication.
-
func NewServiceWithPDSFactory(repo Repository, logger *slog.Logger, factory PDSClientFactory) Service {
+
func NewServiceWithPDSFactory(repo Repository, cache *VoteCache, logger *slog.Logger, factory PDSClientFactory) Service {
if logger == nil {
logger = slog.Default()
}
return &voteService{
repo: repo,
+
cache: cache,
logger: logger,
pdsClientFactory: factory,
}
···
"subject", req.Subject.URI,
"direction", req.Direction)
+
// Update cache - remove the vote
+
if s.cache != nil {
+
s.cache.RemoveVote(session.AccountDID.String(), req.Subject.URI)
+
}
+
// Return empty response to indicate deletion
return &CreateVoteResponse{
URI: "",
···
"uri", uri,
"cid", cid)
+
// Update cache - add the new vote
+
if s.cache != nil {
+
s.cache.SetVote(session.AccountDID.String(), req.Subject.URI, &CachedVote{
+
Direction: req.Direction,
+
URI: uri,
+
RKey: extractRKeyFromURI(uri),
+
})
+
}
+
return &CreateVoteResponse{
URI: uri,
CID: cid,
···
"subject", req.Subject.URI,
"uri", existing.URI)
+
// Update cache - remove the vote
+
if s.cache != nil {
+
s.cache.RemoveVote(session.AccountDID.String(), req.Subject.URI)
+
}
+
return nil
}
···
// No vote found for this subject after checking all pages
return nil, nil
}
+
+
// EnsureCachePopulated fetches the user's votes from their PDS if not already cached.
+
func (s *voteService) EnsureCachePopulated(ctx context.Context, session *oauth.ClientSessionData) error {
+
if s.cache == nil {
+
return nil // No cache configured
+
}
+
+
// Check if already cached
+
if s.cache.IsCached(session.AccountDID.String()) {
+
return nil
+
}
+
+
// Create PDS client for this session
+
pdsClient, err := s.getPDSClient(ctx, session)
+
if err != nil {
+
s.logger.Error("failed to create PDS client for cache population",
+
"error", err,
+
"user", session.AccountDID)
+
return fmt.Errorf("failed to create PDS client: %w", err)
+
}
+
+
// Fetch and cache votes from PDS
+
if err := s.cache.FetchAndCacheFromPDS(ctx, pdsClient); err != nil {
+
s.logger.Error("failed to populate vote cache from PDS",
+
"error", err,
+
"user", session.AccountDID)
+
return fmt.Errorf("failed to populate vote cache: %w", err)
+
}
+
+
return nil
+
}
+
+
// GetViewerVote returns the viewer's vote for a specific subject, or nil if not voted.
+
func (s *voteService) GetViewerVote(userDID, subjectURI string) *CachedVote {
+
if s.cache == nil {
+
return nil
+
}
+
return s.cache.GetVote(userDID, subjectURI)
+
}
+
+
// GetViewerVotesForSubjects returns the viewer's votes for multiple subjects.
+
func (s *voteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*CachedVote {
+
result := make(map[string]*CachedVote)
+
if s.cache == nil {
+
return result
+
}
+
+
allVotes := s.cache.GetVotesForUser(userDID)
+
if allVotes == nil {
+
return result
+
}
+
+
for _, uri := range subjectURIs {
+
if vote, exists := allVotes[uri]; exists {
+
result[uri] = vote
+
}
+
}
+
+
return result
+
}
+76 -16
internal/atproto/jetstream/vote_consumer.go
···
}
// Atomically: Index vote + Update post counts
-
if err := c.indexVoteAndUpdateCounts(ctx, vote); err != nil {
+
wasNew, err := c.indexVoteAndUpdateCounts(ctx, vote)
+
if err != nil {
return fmt.Errorf("failed to index vote and update counts: %w", err)
}
-
log.Printf("โœ“ Indexed vote: %s (%s on %s)", uri, vote.Direction, vote.SubjectURI)
+
if wasNew {
+
log.Printf("โœ“ Indexed vote: %s (%s on %s)", uri, vote.Direction, vote.SubjectURI)
+
}
return nil
}
···
}
// indexVoteAndUpdateCounts atomically indexes a vote and updates post vote counts
-
func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error {
+
// Returns (true, nil) if vote was newly inserted, (false, nil) if already existed (idempotent)
+
func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) (bool, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
-
return fmt.Errorf("failed to begin transaction: %w", err)
+
return false, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
···
}
}()
-
// 1. Index the vote (idempotent with ON CONFLICT DO NOTHING)
+
// 1. Check for existing active vote with different URI (stale record)
+
// This handles cases where:
+
// - User voted on another client and we missed the delete event
+
// - Vote was reindexed but user created a new vote with different rkey
+
// - Any other state mismatch between PDS and AppView
+
var existingDirection sql.NullString
+
checkQuery := `
+
SELECT direction FROM votes
+
WHERE voter_did = $1
+
AND subject_uri = $2
+
AND deleted_at IS NULL
+
AND uri != $3
+
LIMIT 1
+
`
+
if err := tx.QueryRowContext(ctx, checkQuery, vote.VoterDID, vote.SubjectURI, vote.URI).Scan(&existingDirection); err != nil && err != sql.ErrNoRows {
+
return false, fmt.Errorf("failed to check existing vote: %w", err)
+
}
+
+
// If there's a stale vote, soft-delete it and adjust counts
+
if existingDirection.Valid {
+
softDeleteQuery := `
+
UPDATE votes
+
SET deleted_at = NOW()
+
WHERE voter_did = $1
+
AND subject_uri = $2
+
AND deleted_at IS NULL
+
AND uri != $3
+
`
+
if _, err := tx.ExecContext(ctx, softDeleteQuery, vote.VoterDID, vote.SubjectURI, vote.URI); err != nil {
+
return false, fmt.Errorf("failed to soft-delete existing votes: %w", err)
+
}
+
+
// Decrement the old vote's count (will be re-incremented below if same direction)
+
collection := utils.ExtractCollectionFromURI(vote.SubjectURI)
+
var decrementQuery string
+
if existingDirection.String == "up" {
+
if collection == "social.coves.community.post" {
+
decrementQuery = `UPDATE posts SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
} else if collection == "social.coves.community.comment" {
+
decrementQuery = `UPDATE comments SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
} else {
+
if collection == "social.coves.community.post" {
+
decrementQuery = `UPDATE posts SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`
+
} else if collection == "social.coves.community.comment" {
+
decrementQuery = `UPDATE comments SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`
+
}
+
}
+
if decrementQuery != "" {
+
if _, err := tx.ExecContext(ctx, decrementQuery, vote.SubjectURI); err != nil {
+
return false, fmt.Errorf("failed to decrement old vote count: %w", err)
+
}
+
}
+
log.Printf("Cleaned up stale vote for %s on %s (was %s)", vote.VoterDID, vote.SubjectURI, existingDirection.String)
+
}
+
+
// 2. Index the vote (idempotent with ON CONFLICT DO NOTHING)
query := `
INSERT INTO votes (
uri, cid, rkey, voter_did,
···
// If no rows returned, vote already exists (idempotent - OK for Jetstream replays)
if err == sql.ErrNoRows {
-
log.Printf("Vote already indexed: %s (idempotent)", vote.URI)
+
// Silently handle idempotent case - no log needed for replayed events
if commitErr := tx.Commit(); commitErr != nil {
-
return fmt.Errorf("failed to commit transaction: %w", commitErr)
+
return false, fmt.Errorf("failed to commit transaction: %w", commitErr)
}
-
return nil
+
return false, nil // Vote already existed
}
if err != nil {
-
return fmt.Errorf("failed to insert vote: %w", err)
+
return false, fmt.Errorf("failed to insert vote: %w", err)
}
-
// 2. Update vote counts on the subject (post or comment)
+
// 3. Update vote counts on the subject (post or comment)
// Parse collection from subject URI to determine target table
collection := utils.ExtractCollectionFromURI(vote.SubjectURI)
···
// Vote is still indexed in votes table, we just don't update denormalized counts
log.Printf("Vote subject has unsupported collection: %s (vote indexed, counts not updated)", collection)
if commitErr := tx.Commit(); commitErr != nil {
-
return fmt.Errorf("failed to commit transaction: %w", commitErr)
+
return false, fmt.Errorf("failed to commit transaction: %w", commitErr)
}
-
return nil
+
return true, nil // Vote was newly indexed
}
result, err := tx.ExecContext(ctx, updateQuery, vote.SubjectURI)
if err != nil {
-
return fmt.Errorf("failed to update vote counts: %w", err)
+
return false, fmt.Errorf("failed to update vote counts: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
-
return fmt.Errorf("failed to check update result: %w", err)
+
return false, fmt.Errorf("failed to check update result: %w", err)
}
// If subject doesn't exist or is deleted, that's OK (vote still indexed)
···
// Commit transaction
if err := tx.Commit(); err != nil {
-
return fmt.Errorf("failed to commit transaction: %w", err)
+
return false, fmt.Errorf("failed to commit transaction: %w", err)
}
-
return nil
+
return true, nil // Vote was newly indexed
}
// deleteVoteAndUpdateCounts atomically soft-deletes a vote and updates post vote counts
+109
internal/atproto/lexicon/social/coves/community/comment/create.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.create",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a comment on a post or another comment. Comments support nested threading, rich text, embeds, and self-labeling.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["reply", "content"],
+
"properties": {
+
"reply": {
+
"type": "object",
+
"description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).",
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the original post that started the thread"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the immediate parent (post or comment) being replied to"
+
}
+
}
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the created comment"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the created comment record"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InvalidReply",
+
"description": "The reply reference is invalid, malformed, or refers to non-existent content"
+
},
+
{
+
"name": "ContentTooLong",
+
"description": "Comment content exceeds maximum length constraints"
+
},
+
{
+
"name": "ContentEmpty",
+
"description": "Comment content is empty or contains only whitespace"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to create comments on this content"
+
}
+
]
+
}
+
}
+
}
+41
internal/atproto/lexicon/social/coves/community/comment/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a comment. Only the comment author can delete their own comments.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment to delete"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommentNotFound",
+
"description": "Comment with the specified URI does not exist"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to delete this comment (not the author)"
+
}
+
]
+
}
+
}
+
}
+97
internal/atproto/lexicon/social/coves/community/comment/update.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.update",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Update an existing comment's content, facets, embed, languages, or labels. Threading references (reply.root and reply.parent) are immutable and cannot be changed.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "content"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment to update"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Updated comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Updated annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Updated embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Updated languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Updated self-applied content labels"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the updated comment (unchanged from input)"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "New CID of the updated comment record"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommentNotFound",
+
"description": "Comment with the specified URI does not exist"
+
},
+
{
+
"name": "ContentTooLong",
+
"description": "Updated comment content exceeds maximum length constraints"
+
},
+
{
+
"name": "ContentEmpty",
+
"description": "Updated comment content is empty or contains only whitespace"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to update this comment (not the author)"
+
}
+
]
+
}
+
}
+
}
+38
internal/core/comments/types.go
···
+
package comments
+
+
// CreateCommentRequest contains parameters for creating a comment
+
type CreateCommentRequest struct {
+
Reply ReplyRef `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
}
+
+
// CreateCommentResponse contains the result of creating a comment
+
type CreateCommentResponse struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// UpdateCommentRequest contains parameters for updating a comment
+
type UpdateCommentRequest struct {
+
URI string `json:"uri"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
}
+
+
// UpdateCommentResponse contains the result of updating a comment
+
type UpdateCommentResponse struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// DeleteCommentRequest contains parameters for deleting a comment
+
type DeleteCommentRequest struct {
+
URI string `json:"uri"`
+
}
+130
internal/api/handlers/comments/create_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// CreateCommentHandler handles comment creation requests
+
type CreateCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewCreateCommentHandler creates a new handler for creating comments
+
func NewCreateCommentHandler(service comments.Service) *CreateCommentHandler {
+
return &CreateCommentHandler{
+
service: service,
+
}
+
}
+
+
// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create
+
type CreateCommentInput struct {
+
Reply struct {
+
Root struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"root"`
+
Parent struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"parent"`
+
} `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels interface{} `json:"labels,omitempty"`
+
}
+
+
// CreateCommentOutput matches the lexicon output schema
+
type CreateCommentOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleCreate handles comment creation requests
+
// POST /xrpc/social.coves.community.comment.create
+
//
+
// Request body: { "reply": { "root": {...}, "parent": {...} }, "content": "..." }
+
// Response: { "uri": "at://...", "cid": "..." }
+
func (h *CreateCommentHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into CreateCommentInput
+
var input CreateCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert labels interface{} to *comments.SelfLabels if provided
+
var labels *comments.SelfLabels
+
if input.Labels != nil {
+
labelsJSON, err := json.Marshal(input.Labels)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format")
+
return
+
}
+
var selfLabels comments.SelfLabels
+
if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure")
+
return
+
}
+
labels = &selfLabels
+
}
+
+
// 6. Convert input to CreateCommentRequest
+
req := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: input.Reply.Root.URI,
+
CID: input.Reply.Root.CID,
+
},
+
Parent: comments.StrongRef{
+
URI: input.Reply.Parent.URI,
+
CID: input.Reply.Parent.CID,
+
},
+
},
+
Content: input.Content,
+
Facets: input.Facets,
+
Embed: input.Embed,
+
Langs: input.Langs,
+
Labels: labels,
+
}
+
+
// 7. Call service to create comment
+
response, err := h.service.CreateComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 8. Return JSON response with URI and CID
+
output := CreateCommentOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+80
internal/api/handlers/comments/delete_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// DeleteCommentHandler handles comment deletion requests
+
type DeleteCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewDeleteCommentHandler creates a new handler for deleting comments
+
func NewDeleteCommentHandler(service comments.Service) *DeleteCommentHandler {
+
return &DeleteCommentHandler{
+
service: service,
+
}
+
}
+
+
// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete
+
type DeleteCommentInput struct {
+
URI string `json:"uri"`
+
}
+
+
// DeleteCommentOutput is empty per lexicon specification
+
type DeleteCommentOutput struct{}
+
+
// HandleDelete handles comment deletion requests
+
// POST /xrpc/social.coves.community.comment.delete
+
//
+
// Request body: { "uri": "at://..." }
+
// Response: {}
+
func (h *DeleteCommentHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into DeleteCommentInput
+
var input DeleteCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert input to DeleteCommentRequest
+
req := comments.DeleteCommentRequest{
+
URI: input.URI,
+
}
+
+
// 6. Call service to delete comment
+
err := h.service.DeleteComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 7. Return empty JSON object per lexicon specification
+
output := DeleteCommentOutput{}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+34 -2
internal/api/handlers/comments/errors.go
···
import (
"Coves/internal/core/comments"
"encoding/json"
+
"errors"
"log"
"net/http"
)
···
func handleServiceError(w http.ResponseWriter, err error) {
switch {
case comments.IsNotFound(err):
-
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
// Map specific not found errors to appropriate messages
+
switch {
+
case errors.Is(err, comments.ErrCommentNotFound):
+
writeError(w, http.StatusNotFound, "CommentNotFound", "Comment not found")
+
case errors.Is(err, comments.ErrParentNotFound):
+
writeError(w, http.StatusNotFound, "ParentNotFound", "Parent post or comment not found")
+
case errors.Is(err, comments.ErrRootNotFound):
+
writeError(w, http.StatusNotFound, "RootNotFound", "Root post not found")
+
default:
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
}
case comments.IsValidationError(err):
-
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
// Map specific validation errors to appropriate messages
+
switch {
+
case errors.Is(err, comments.ErrInvalidReply):
+
writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed")
+
case errors.Is(err, comments.ErrContentTooLong):
+
writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes")
+
case errors.Is(err, comments.ErrContentEmpty):
+
writeError(w, http.StatusBadRequest, "ContentEmpty", "Comment content is required")
+
default:
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
}
+
+
case errors.Is(err, comments.ErrNotAuthorized):
+
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action")
+
+
case errors.Is(err, comments.ErrBanned):
+
writeError(w, http.StatusForbidden, "Banned", "User is banned from this community")
+
+
// NOTE: IsConflict case removed - the PDS handles duplicate detection via CreateRecord,
+
// so ErrCommentAlreadyExists is never returned from the service layer. If the PDS rejects
+
// a duplicate record, it returns an auth/validation error which is handled by other cases.
+
// Keeping this code would be dead code that never executes.
default:
// Don't leak internal error details to clients
+112
internal/api/handlers/comments/update_comment.go
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// UpdateCommentHandler handles comment update requests
+
type UpdateCommentHandler struct {
+
service comments.Service
+
}
+
+
// NewUpdateCommentHandler creates a new handler for updating comments
+
func NewUpdateCommentHandler(service comments.Service) *UpdateCommentHandler {
+
return &UpdateCommentHandler{
+
service: service,
+
}
+
}
+
+
// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update
+
type UpdateCommentInput struct {
+
URI string `json:"uri"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels interface{} `json:"labels,omitempty"`
+
}
+
+
// UpdateCommentOutput matches the lexicon output schema
+
type UpdateCommentOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleUpdate handles comment update requests
+
// POST /xrpc/social.coves.community.comment.update
+
//
+
// Request body: { "uri": "at://...", "content": "..." }
+
// Response: { "uri": "at://...", "cid": "..." }
+
func (h *UpdateCommentHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check method is POST
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments)
+
r.Body = http.MaxBytesReader(w, r.Body, 100*1024)
+
+
// 3. Parse JSON body into UpdateCommentInput
+
var input UpdateCommentInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Convert labels interface{} to *comments.SelfLabels if provided
+
var labels *comments.SelfLabels
+
if input.Labels != nil {
+
labelsJSON, err := json.Marshal(input.Labels)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format")
+
return
+
}
+
var selfLabels comments.SelfLabels
+
if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure")
+
return
+
}
+
labels = &selfLabels
+
}
+
+
// 6. Convert input to UpdateCommentRequest
+
req := comments.UpdateCommentRequest{
+
URI: input.URI,
+
Content: input.Content,
+
Facets: input.Facets,
+
Embed: input.Embed,
+
Langs: input.Langs,
+
Labels: labels,
+
}
+
+
// 7. Call service to update comment
+
response, err := h.service.UpdateComment(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 8. Return JSON response with URI and CID
+
output := UpdateCommentOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+35
internal/api/routes/comment.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/comments"
+
"Coves/internal/api/middleware"
+
commentsCore "Coves/internal/core/comments"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterCommentRoutes registers comment-related XRPC endpoints on the router
+
// Implements social.coves.community.comment.* lexicon endpoints
+
// All write operations (create, update, delete) require authentication
+
func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+
// Initialize handlers
+
createHandler := comments.NewCreateCommentHandler(service)
+
updateHandler := comments.NewUpdateCommentHandler(service)
+
deleteHandler := comments.NewDeleteCommentHandler(service)
+
+
// Procedure endpoints (POST) - require authentication
+
// social.coves.community.comment.create - create a new comment on a post or another comment
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.create",
+
createHandler.HandleCreate)
+
+
// social.coves.community.comment.update - update an existing comment's content
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.update",
+
updateHandler.HandleUpdate)
+
+
// social.coves.community.comment.delete - soft delete a comment
+
r.With(authMiddleware.RequireAuth).Post(
+
"/xrpc/social.coves.community.comment.delete",
+
deleteHandler.HandleDelete)
+
}
+4 -2
tests/integration/comment_query_test.go
···
postRepo := postgres.NewPostRepository(db)
userRepo := postgres.NewUserRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
-
return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - these tests only use the read path (GetComments)
+
return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
}
// Helper: createTestCommentWithScore creates a comment with specific vote counts
···
postRepo := postgres.NewPostRepository(db)
userRepo := postgres.NewUserRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
-
service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - these tests only use the read path (GetComments)
+
service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
return &testCommentServiceAdapter{service: service}
}
+6 -3
tests/integration/comment_vote_test.go
···
}
// Query comments with viewer authentication
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
···
}
// Query with authentication but no vote
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
···
t.Run("Unauthenticated request has no viewer state", func(t *testing.T) {
// Query without authentication
-
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
// Use factory constructor with nil factory - this test only uses the read path (GetComments)
+
commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{
PostURI: testPostURI,
Sort: "new",
+1 -1
go.mod
···
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
-
github.com/rivo/uniseg v0.1.0 // indirect
+
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
+2
go.sum
···
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
···
+
-- +goose Up
+
-- Add deletion reason tracking to preserve thread structure while respecting privacy
+
-- When comments are deleted, we blank content but keep the record for threading
+
+
-- Create enum type for deletion reasons
+
CREATE TYPE deletion_reason AS ENUM ('author', 'moderator');
+
+
-- Add new columns to comments table
+
ALTER TABLE comments ADD COLUMN deletion_reason deletion_reason;
+
ALTER TABLE comments ADD COLUMN deleted_by TEXT;
+
+
-- Add comments for new columns
+
COMMENT ON COLUMN comments.deletion_reason IS 'Reason for deletion: author (user deleted), moderator (community mod removed)';
+
COMMENT ON COLUMN comments.deleted_by IS 'DID of the actor who performed the deletion';
+
+
-- Backfill existing deleted comments as author-deleted
+
-- This handles existing soft-deleted comments gracefully
+
UPDATE comments
+
SET deletion_reason = 'author',
+
deleted_by = commenter_did
+
WHERE deleted_at IS NOT NULL AND deletion_reason IS NULL;
+
+
-- Modify existing indexes to NOT filter deleted_at IS NULL
+
-- This allows deleted comments to appear in thread queries for structure preservation
+
-- Note: We drop and recreate to change the partial index condition
+
+
-- Drop old partial indexes that exclude deleted comments
+
DROP INDEX IF EXISTS idx_comments_root;
+
DROP INDEX IF EXISTS idx_comments_parent;
+
DROP INDEX IF EXISTS idx_comments_parent_score;
+
DROP INDEX IF EXISTS idx_comments_uri_active;
+
+
-- Recreate indexes without the deleted_at filter (include all comments for threading)
+
CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC);
+
CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC);
+
CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC);
+
CREATE INDEX idx_comments_uri_lookup ON comments(uri);
+
+
-- Add index for querying by deletion_reason (for moderation dashboard)
+
CREATE INDEX idx_comments_deleted_reason ON comments(deletion_reason, deleted_at DESC)
+
WHERE deleted_at IS NOT NULL;
+
+
-- Add index for querying by deleted_by (for moderation audit/filtering)
+
CREATE INDEX idx_comments_deleted_by ON comments(deleted_by, deleted_at DESC)
+
WHERE deleted_at IS NOT NULL;
+
+
-- +goose Down
+
-- Remove deletion metadata columns and restore original indexes
+
+
DROP INDEX IF EXISTS idx_comments_deleted_by;
+
DROP INDEX IF EXISTS idx_comments_deleted_reason;
+
DROP INDEX IF EXISTS idx_comments_uri_lookup;
+
DROP INDEX IF EXISTS idx_comments_parent_score;
+
DROP INDEX IF EXISTS idx_comments_parent;
+
DROP INDEX IF EXISTS idx_comments_root;
+
+
-- Restore original partial indexes (excluding deleted comments)
+
CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_uri_active ON comments(uri) WHERE deleted_at IS NULL;
+
+
ALTER TABLE comments DROP COLUMN IF EXISTS deleted_by;
+
ALTER TABLE comments DROP COLUMN IF EXISTS deletion_reason;
+
+
DROP TYPE IF EXISTS deletion_reason;
+5
internal/core/communityFeeds/types.go
···
Reply *ReplyRef `json:"reply,omitempty"` // Reply context
}
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *posts.PostView {
+
return f.Post
+
}
+
// FeedReason is a union type for feed context
// Can be reasonRepost or reasonPin
type FeedReason struct {
+5
internal/core/discover/types.go
···
Reply *ReplyRef `json:"reply,omitempty"`
}
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *posts.PostView {
+
return f.Post
+
}
+
// FeedReason is a union type for feed context
type FeedReason struct {
Repost *ReasonRepost `json:"-"`
+5
internal/core/timeline/types.go
···
Reply *ReplyRef `json:"reply,omitempty"` // Reply context
}
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *posts.PostView {
+
return f.Post
+
}
+
// FeedReason is a union type for feed context
// Future: Can be reasonRepost or reasonCommunity
type FeedReason struct {
+12
internal/api/handlers/vote/create_vote_test.go
···
return nil
}
+
func (m *mockVoteService) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error {
+
return nil
+
}
+
+
func (m *mockVoteService) GetViewerVote(userDID, subjectURI string) *votes.CachedVote {
+
return nil
+
}
+
+
func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {
+
return nil
+
}
+
func TestCreateVoteHandler_Success(t *testing.T) {
mockService := &mockVoteService{}
handler := NewCreateVoteHandler(mockService)
+2
scripts/generate_deep_thread.go
···
+
//go:build ignore
+
package main
import (
+2
scripts/generate_nba_comments.go
···
+
//go:build ignore
+
package main
import (
+2
scripts/generate_test_comments.go
···
+
//go:build ignore
+
package main
import (
+29
internal/atproto/oauth/store.go
···
return rows, nil
}
+
// UpdateHandleByDID updates the handle for all OAuth sessions belonging to a DID.
+
// This is called when identity events indicate a handle change, keeping active
+
// sessions in sync with the user's current handle.
+
// Returns the number of sessions updated.
+
func (s *PostgresOAuthStore) UpdateHandleByDID(ctx context.Context, did, newHandle string) (int64, error) {
+
query := `
+
UPDATE oauth_sessions
+
SET handle = $2, updated_at = NOW()
+
WHERE did = $1 AND expires_at > NOW()
+
`
+
+
result, err := s.db.ExecContext(ctx, query, did, newHandle)
+
if err != nil {
+
return 0, fmt.Errorf("failed to update session handle: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return 0, fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rows > 0 {
+
slog.Info("updated OAuth session handles for identity change",
+
"did", did, "new_handle", newHandle, "sessions_updated", rows)
+
}
+
+
return rows, nil
+
}
+
// MobileOAuthData holds mobile-specific OAuth flow data
type MobileOAuthData struct {
CSRFToken string
+375
tests/integration/oauth_session_handle_sync_test.go
···
+
package integration
+
+
import (
+
"context"
+
"fmt"
+
"net/http"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/stretchr/testify/require"
+
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/oauth"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
+
)
+
+
// TestOAuthSessionHandleSync tests that OAuth session handles are updated
+
// when identity events indicate a handle change.
+
//
+
// This ensures mobile/web apps display the correct handle after a user
+
// changes their handle on their PDS.
+
//
+
// Prerequisites:
+
// - Test database on localhost:5434
+
//
+
// Run with:
+
//
+
// docker-compose --profile test up -d postgres-test
+
// TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
// go test -v ./tests/integration/ -run "TestOAuthSessionHandleSync"
+
func TestOAuthSessionHandleSync(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
+
// Set up real infrastructure components
+
userRepo := postgres.NewUserRepository(db)
+
resolver := identity.NewResolver(db, identity.DefaultConfig())
+
userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
+
+
// Create real OAuth store (with session handle updater capability)
+
baseOAuthStore := oauth.NewPostgresOAuthStore(db, 24*time.Hour)
+
+
t.Run("Handle change syncs to active OAuth sessions", func(t *testing.T) {
+
testDID := "did:plc:oauthsync123"
+
oldHandle := "oldhandle.oauth.sync.test"
+
newHandle := "newhandle.oauth.sync.test"
+
sessionID := "test-session-oauth-sync-001"
+
+
// 1. Create user with old handle
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err, "Failed to create test user")
+
t.Logf("โœ… Created user: %s (%s)", oldHandle, testDID)
+
+
// 2. Create OAuth session with old handle
+
parsedDID, err := syntax.ParseDID(testDID)
+
require.NoError(t, err, "Failed to parse DID")
+
+
session := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: sessionID,
+
HostURL: "https://bsky.social",
+
AccessToken: "test-access-token",
+
RefreshToken: "test-refresh-token",
+
Scopes: []string{"atproto"},
+
}
+
err = baseOAuthStore.SaveSession(ctx, session)
+
require.NoError(t, err, "Failed to save OAuth session")
+
t.Logf("โœ… Created OAuth session: %s", sessionID)
+
+
// 3. Verify session was created with correct data
+
savedSession, err := baseOAuthStore.GetSession(ctx, parsedDID, sessionID)
+
require.NoError(t, err, "Failed to retrieve saved session")
+
require.NotNil(t, savedSession, "Session should exist")
+
t.Logf("โœ… Verified session exists for DID: %s", testDID)
+
+
// 4. Cast store to SessionHandleUpdater (what the consumer uses)
+
sessionUpdater, ok := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
require.True(t, ok, "OAuth store should implement SessionHandleUpdater")
+
+
// 5. Create consumer with session handle updater
+
consumer := jetstream.NewUserEventConsumer(
+
userService,
+
resolver,
+
"", // No WebSocket URL - we'll call HandleIdentityEventPublic directly
+
"",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
// 6. Simulate identity event with NEW handle (as if PDS sent handle change)
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 999999,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
t.Logf("๐Ÿ“ก Simulating identity event: %s โ†’ %s", oldHandle, newHandle)
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err, "Failed to handle identity event")
+
t.Logf("โœ… Identity event processed")
+
+
// 7. Verify users table was updated
+
user, err := userService.GetUserByDID(ctx, testDID)
+
require.NoError(t, err, "Failed to get user after handle change")
+
require.Equal(t, newHandle, user.Handle, "User handle should be updated in database")
+
t.Logf("โœ… Users table updated: handle=%s", user.Handle)
+
+
// 8. Verify OAuth session handle was updated
+
var sessionHandle string
+
err = db.QueryRowContext(ctx,
+
"SELECT handle FROM oauth_sessions WHERE did = $1 AND session_id = $2",
+
testDID, sessionID,
+
).Scan(&sessionHandle)
+
require.NoError(t, err, "Failed to query session handle")
+
require.Equal(t, newHandle, sessionHandle, "OAuth session handle should be updated")
+
t.Logf("โœ… OAuth session handle updated: %s", sessionHandle)
+
})
+
+
t.Run("Multiple sessions updated on handle change", func(t *testing.T) {
+
testDID := "did:plc:multisession456"
+
oldHandle := "multi.old.handle.test"
+
newHandle := "multi.new.handle.test"
+
+
// 1. Create user
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err)
+
+
// 2. Create multiple OAuth sessions (simulating login from multiple devices)
+
parsedDID, _ := syntax.ParseDID(testDID)
+
for i := 1; i <= 3; i++ {
+
session := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: fmt.Sprintf("multi-session-%d", i),
+
HostURL: "https://bsky.social",
+
AccessToken: fmt.Sprintf("access-token-%d", i),
+
RefreshToken: fmt.Sprintf("refresh-token-%d", i),
+
Scopes: []string{"atproto"},
+
}
+
err = baseOAuthStore.SaveSession(ctx, session)
+
require.NoError(t, err)
+
}
+
t.Logf("โœ… Created 3 OAuth sessions for user")
+
+
// 3. Process identity event with new handle
+
sessionUpdater := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
consumer := jetstream.NewUserEventConsumer(
+
userService, resolver, "", "",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 888888,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err)
+
+
// 4. Verify ALL sessions were updated
+
var count int
+
err = db.QueryRowContext(ctx,
+
"SELECT COUNT(*) FROM oauth_sessions WHERE did = $1 AND handle = $2",
+
testDID, newHandle,
+
).Scan(&count)
+
require.NoError(t, err)
+
require.Equal(t, 3, count, "All 3 sessions should have updated handles")
+
t.Logf("โœ… All %d sessions updated with new handle", count)
+
})
+
+
t.Run("No sessions updated when user has no active sessions", func(t *testing.T) {
+
testDID := "did:plc:nosessions789"
+
oldHandle := "nosession.old.test"
+
newHandle := "nosession.new.test"
+
+
// 1. Create user with no OAuth sessions
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err)
+
+
// 2. Process identity event
+
sessionUpdater := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
consumer := jetstream.NewUserEventConsumer(
+
userService, resolver, "", "",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 777777,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
// Should not error even when no sessions exist
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err, "Should handle event gracefully with no sessions")
+
+
// 3. Verify user was still updated
+
user, err := userService.GetUserByDID(ctx, testDID)
+
require.NoError(t, err)
+
require.Equal(t, newHandle, user.Handle)
+
t.Logf("โœ… User updated correctly even with no active sessions")
+
})
+
+
t.Run("Consumer works without session updater (backward compat)", func(t *testing.T) {
+
testDID := "did:plc:nosyncer000"
+
oldHandle := "nosyncer.old.test"
+
newHandle := "nosyncer.new.test"
+
+
// 1. Create user
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: testDID,
+
Handle: oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
require.NoError(t, err)
+
+
// 2. Create consumer WITHOUT session handle updater
+
consumer := jetstream.NewUserEventConsumer(
+
userService, resolver, "", "",
+
// No WithSessionHandleUpdater - testing backward compatibility
+
)
+
+
// 3. Process identity event - should work without error
+
identityEvent := &jetstream.JetstreamEvent{
+
Did: testDID,
+
Kind: "identity",
+
Identity: &jetstream.IdentityEvent{
+
Did: testDID,
+
Handle: newHandle,
+
Seq: 666666,
+
Time: time.Now().Format(time.RFC3339),
+
},
+
}
+
+
err = consumer.HandleIdentityEventPublic(ctx, identityEvent)
+
require.NoError(t, err, "Consumer should work without session updater")
+
+
// 4. Verify user was updated
+
user, err := userService.GetUserByDID(ctx, testDID)
+
require.NoError(t, err)
+
require.Equal(t, newHandle, user.Handle)
+
t.Logf("โœ… Consumer works correctly without session handle updater")
+
})
+
}
+
+
// TestOAuthSessionHandleSync_LiveJetstream tests the full flow with real Jetstream
+
// This requires the dev infrastructure to be running.
+
//
+
// Prerequisites:
+
// - PDS running on localhost:3001
+
// - Jetstream running on localhost:6008
+
// - Test database on localhost:5434
+
//
+
// Run with:
+
//
+
// docker-compose --profile test --profile jetstream up -d
+
// TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
// go test -v ./tests/integration/ -run "TestOAuthSessionHandleSync_LiveJetstream"
+
func TestOAuthSessionHandleSync_LiveJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping live Jetstream test in short mode")
+
}
+
+
// Check if Jetstream is available
+
if !isServiceAvailable("http://localhost:6008") {
+
t.Skip("Jetstream not available at localhost:6008 - run 'docker-compose --profile jetstream up -d' first")
+
}
+
+
// Check if PDS is available
+
if !isServiceAvailable("http://localhost:3001/xrpc/_health") {
+
t.Skip("PDS not available at localhost:3001 - run 'docker-compose up -d pds' first")
+
}
+
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+
defer cancel()
+
+
// Set up real infrastructure
+
userRepo := postgres.NewUserRepository(db)
+
resolver := identity.NewResolver(db, identity.DefaultConfig())
+
userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
+
baseOAuthStore := oauth.NewPostgresOAuthStore(db, 24*time.Hour)
+
sessionUpdater := baseOAuthStore.(jetstream.SessionHandleUpdater)
+
+
// Start consumer connected to real Jetstream
+
consumer := jetstream.NewUserEventConsumer(
+
userService,
+
resolver,
+
"ws://localhost:6008/subscribe",
+
"",
+
jetstream.WithSessionHandleUpdater(sessionUpdater),
+
)
+
+
// Start consumer in background
+
consumerCtx, consumerCancel := context.WithCancel(ctx)
+
defer consumerCancel()
+
+
go func() {
+
if err := consumer.Start(consumerCtx); err != nil && err != context.Canceled {
+
t.Logf("Consumer stopped: %v", err)
+
}
+
}()
+
+
// Give consumer time to connect
+
time.Sleep(500 * time.Millisecond)
+
+
t.Run("Real Jetstream integration", func(t *testing.T) {
+
t.Log("๐Ÿ”Œ Connected to live Jetstream - waiting for identity events...")
+
t.Log("Note: This test verifies the consumer is properly configured with session sync.")
+
t.Log("To fully test handle sync, create a user on the PDS and change their handle.")
+
+
// For now, just verify the consumer is running with the session updater
+
// A full E2E test would require:
+
// 1. Create user on PDS
+
// 2. Create OAuth session
+
// 3. Update handle on PDS (via user credentials)
+
// 4. Wait for Jetstream to deliver identity event
+
// 5. Verify session handle updated
+
+
t.Log("โœ… Consumer running with OAuth session sync enabled")
+
})
+
}
+
+
// isServiceAvailable checks if an HTTP service is responding
+
func isServiceAvailable(url string) bool {
+
client := &http.Client{Timeout: 2 * time.Second}
+
resp, err := client.Get(url)
+
if err != nil {
+
return false
+
}
+
defer resp.Body.Close()
+
return resp.StatusCode < 500
+
}
+76 -1
aggregators/kagi-news/tests/test_e2e.py
···
assert embed["external"]["description"] == "Test description"
# Thumbnail is not included - server's unfurl service handles it
assert "thumb" not in embed["external"]
-
print("\nโœ… External embed format correct")
+
# Sources should not be present when not provided
+
assert "sources" not in embed["external"]
+
print("\nโœ… External embed format correct (no sources)")
+
+
+
def test_coves_client_external_embed_with_sources(aggregator_credentials):
+
"""
+
Test external embed formatting with sources.
+
+
Verifies:
+
- Embed structure matches social.coves.embed.external
+
- Sources array is included when provided
+
- Each source has uri, title, domain
+
"""
+
handle, password = aggregator_credentials
+
+
client = CovesClient(
+
api_url="http://localhost:8081",
+
handle=handle,
+
password=password
+
)
+
+
# Create external embed with sources
+
sources = [
+
{"uri": "https://source1.com/article", "title": "Source 1 Article", "domain": "source1.com"},
+
{"uri": "https://source2.com/story", "title": "Source 2 Story", "domain": "source2.com"},
+
]
+
+
embed = client.create_external_embed(
+
uri="https://example.com/story",
+
title="Test Story With Sources",
+
description="Test description with sources",
+
sources=sources
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
assert embed["external"]["uri"] == "https://example.com/story"
+
assert embed["external"]["title"] == "Test Story With Sources"
+
assert embed["external"]["description"] == "Test description with sources"
+
# Sources should be present
+
assert "sources" in embed["external"]
+
assert len(embed["external"]["sources"]) == 2
+
assert embed["external"]["sources"][0]["uri"] == "https://source1.com/article"
+
assert embed["external"]["sources"][0]["title"] == "Source 1 Article"
+
assert embed["external"]["sources"][0]["domain"] == "source1.com"
+
assert embed["external"]["sources"][1]["uri"] == "https://source2.com/story"
+
print("\nโœ… External embed format correct (with sources)")
+
+
+
def test_coves_client_external_embed_with_empty_sources(aggregator_credentials):
+
"""
+
Test external embed formatting with empty sources list.
+
+
Verifies:
+
- Empty sources list is not included in embed (regression test)
+
"""
+
handle, password = aggregator_credentials
+
+
client = CovesClient(
+
api_url="http://localhost:8081",
+
handle=handle,
+
password=password
+
)
+
+
# Create external embed with empty sources list
+
embed = client.create_external_embed(
+
uri="https://example.com/story",
+
title="Test Story",
+
description="Test description",
+
sources=[]
+
)
+
+
assert embed["$type"] == "social.coves.embed.external"
+
# Empty sources list should not be included
+
assert "sources" not in embed["external"]
+
print("\nโœ… External embed format correct (empty sources excluded)")
+137
aggregators/kagi-news/tests/test_main.py
···
assert call_kwargs["embed"]["external"]["title"] == sample_story.title
# Thumbnail is not included - server's unfurl service handles it
assert "thumb" not in call_kwargs["embed"]["external"]
+
+
def test_create_post_with_sources_in_embed(self, mock_config, mock_rss_feed, sample_story, tmp_path):
+
"""Test that posts include sources in external embeds when available."""
+
state_file = tmp_path / "state.json"
+
mock_client = Mock()
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
+
+
# Mock create_external_embed to return proper embed structure with sources
+
mock_client.create_external_embed.return_value = {
+
"$type": "social.coves.embed.external",
+
"external": {
+
"uri": sample_story.link,
+
"title": sample_story.title,
+
"description": sample_story.summary,
+
"sources": [
+
{"uri": "https://example.com/1", "title": "Source 1", "domain": "example.com"}
+
]
+
}
+
}
+
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
+
patch('src.main.RichTextFormatter') as MockFormatter:
+
+
# Setup mocks
+
mock_loader = Mock()
+
mock_loader.load.return_value = mock_config
+
MockConfigLoader.return_value = mock_loader
+
+
mock_fetcher = Mock()
+
single_entry_feed = MagicMock(bozo=0, entries=[mock_rss_feed.entries[0]])
+
mock_fetcher.fetch_feed.return_value = single_entry_feed
+
MockRSSFetcher.return_value = mock_fetcher
+
+
mock_parser = Mock()
+
mock_parser.parse_to_story.return_value = sample_story
+
MockHTMLParser.return_value = mock_parser
+
+
mock_formatter = Mock()
+
mock_formatter.format_full.return_value = {
+
"content": "Test content",
+
"facets": []
+
}
+
MockFormatter.return_value = mock_formatter
+
+
# Run aggregator
+
aggregator = Aggregator(
+
config_path=Path("config.yaml"),
+
state_file=state_file,
+
coves_client=mock_client
+
)
+
aggregator.run()
+
+
# Verify create_external_embed was called with sources
+
mock_client.create_external_embed.assert_called()
+
call_kwargs = mock_client.create_external_embed.call_args.kwargs
+
+
# Verify sources were passed
+
assert "sources" in call_kwargs
+
assert len(call_kwargs["sources"]) == 1
+
assert call_kwargs["sources"][0]["uri"] == "https://example.com/1"
+
assert call_kwargs["sources"][0]["title"] == "Source 1"
+
assert call_kwargs["sources"][0]["domain"] == "example.com"
+
+
def test_create_post_without_sources(self, mock_config, mock_rss_feed, tmp_path):
+
"""Test that posts without sources don't include sources in embed."""
+
state_file = tmp_path / "state.json"
+
mock_client = Mock()
+
mock_client.create_post.return_value = "at://did:plc:test/social.coves.post/abc123"
+
+
# Create a story without sources
+
story_without_sources = KagiStory(
+
title="Test Story No Sources",
+
link="https://kite.kagi.com/test/world/1",
+
guid="https://kite.kagi.com/test/world/1",
+
pub_date=datetime(2024, 1, 15, 12, 0, 0),
+
categories=["World"],
+
summary="Test summary",
+
highlights=[],
+
perspectives=[],
+
quote=None,
+
sources=[], # No sources
+
image_url=None,
+
image_alt=None
+
)
+
+
# Mock create_external_embed to return proper embed structure without sources
+
mock_client.create_external_embed.return_value = {
+
"$type": "social.coves.embed.external",
+
"external": {
+
"uri": story_without_sources.link,
+
"title": story_without_sources.title,
+
"description": story_without_sources.summary
+
}
+
}
+
+
with patch('src.main.ConfigLoader') as MockConfigLoader, \
+
patch('src.main.RSSFetcher') as MockRSSFetcher, \
+
patch('src.main.KagiHTMLParser') as MockHTMLParser, \
+
patch('src.main.RichTextFormatter') as MockFormatter:
+
+
# Setup mocks
+
mock_loader = Mock()
+
mock_loader.load.return_value = mock_config
+
MockConfigLoader.return_value = mock_loader
+
+
mock_fetcher = Mock()
+
single_entry_feed = MagicMock(bozo=0, entries=[mock_rss_feed.entries[0]])
+
mock_fetcher.fetch_feed.return_value = single_entry_feed
+
MockRSSFetcher.return_value = mock_fetcher
+
+
mock_parser = Mock()
+
mock_parser.parse_to_story.return_value = story_without_sources
+
MockHTMLParser.return_value = mock_parser
+
+
mock_formatter = Mock()
+
mock_formatter.format_full.return_value = {
+
"content": "Test content",
+
"facets": []
+
}
+
MockFormatter.return_value = mock_formatter
+
+
# Run aggregator
+
aggregator = Aggregator(
+
config_path=Path("config.yaml"),
+
state_file=state_file,
+
coves_client=mock_client
+
)
+
aggregator.run()
+
+
# Verify create_external_embed was called
+
mock_client.create_external_embed.assert_called()
+
call_kwargs = mock_client.create_external_embed.call_args.kwargs
+
+
# Verify sources is None (empty list becomes None)
+
assert call_kwargs.get("sources") is None
+1188
tests/integration/comment_e2e_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/pds"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net"
+
"net/http"
+
"os"
+
"strings"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/gorilla/websocket"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
)
+
+
// TestCommentE2E_CreateWithJetstream tests the full comment creation flow with real Jetstream
+
// Flow: Client โ†’ Service โ†’ PDS Write โ†’ Jetstream Firehose โ†’ Consumer โ†’ AppView
+
func TestCommentE2E_CreateWithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping 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)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
// Check if PDS is running
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
// Check if Jetstream is running
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v. Run 'docker-compose --profile jetstream up' to start.", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
+
// Setup repositories
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create test user on PDS
+
// Use shorter handle to avoid PDS length limits (max 20 chars for label)
+
testUserHandle := fmt.Sprintf("cmt%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("cmt%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
t.Logf("Creating test user on PDS: %s", testUserHandle)
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
t.Logf("Test user created: DID=%s", userDID)
+
+
// Index user in AppView
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
// Create test community and post to comment on
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-e2e-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Comments", 0, time.Now())
+
postCID := "bafyposte2etest"
+
+
// Setup comment service with PDS factory
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create mock OAuth session
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
t.Run("create comment with real Jetstream indexing", func(t *testing.T) {
+
// Setup Jetstream consumer
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
// Channels for event communication
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
// Start Jetstream consumer in background BEFORE writing to PDS
+
t.Logf("\n๐Ÿ”„ Starting Jetstream consumer for comments...")
+
go func() {
+
subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)
+
if subscribeErr != nil {
+
errorChan <- subscribeErr
+
}
+
}()
+
+
// Give Jetstream a moment to connect
+
time.Sleep(500 * time.Millisecond)
+
+
// Create comment via service (writes to PDS)
+
t.Logf("\n๐Ÿ“ Creating comment via service (writes to PDS)...")
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
Parent: comments.StrongRef{
+
URI: postURI,
+
CID: postCID,
+
},
+
},
+
Content: "This is a TRUE E2E test comment via Jetstream!",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
t.Logf("โœ… Comment written to PDS:")
+
t.Logf(" URI: %s", commentResp.URI)
+
t.Logf(" CID: %s", commentResp.CID)
+
+
// Wait for Jetstream event
+
t.Logf("\nโณ 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 our comment
+
if event.Did != userDID {
+
t.Errorf("Expected DID %s, got %s", userDID, event.Did)
+
}
+
if event.Commit.Collection != "social.coves.community.comment" {
+
t.Errorf("Expected collection social.coves.community.comment, got %s", event.Commit.Collection)
+
}
+
if event.Commit.Operation != "create" {
+
t.Errorf("Expected operation create, got %s", event.Commit.Operation)
+
}
+
+
// Verify indexed in AppView database
+
t.Logf("\n๐Ÿ” Querying AppView database...")
+
indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Comment not indexed in AppView: %v", err)
+
}
+
+
t.Logf("โœ… Comment indexed in AppView:")
+
t.Logf(" CommenterDID: %s", indexedComment.CommenterDID)
+
t.Logf(" Content: %s", indexedComment.Content)
+
t.Logf(" RootURI: %s", indexedComment.RootURI)
+
t.Logf(" ParentURI: %s", indexedComment.ParentURI)
+
+
// Verify comment details
+
if indexedComment.CommenterDID != userDID {
+
t.Errorf("Expected commenter_did %s, got %s", userDID, indexedComment.CommenterDID)
+
}
+
if indexedComment.Content != "This is a TRUE E2E test comment via Jetstream!" {
+
t.Errorf("Expected content mismatch, got %s", indexedComment.Content)
+
}
+
+
close(done)
+
+
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")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMENT CREATE FLOW COMPLETE:")
+
t.Logf(" Client โ†’ Service โ†’ PDS โ†’ Jetstream โ†’ Consumer โ†’ AppView โœ“")
+
})
+
}
+
+
// TestCommentE2E_UpdateWithJetstream tests comment update with real Jetstream indexing
+
func TestCommentE2E_UpdateWithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping 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)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
// Check if PDS is running
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
// Check if Jetstream is running
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
+
// Setup repositories
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create test user on PDS
+
testUserHandle := fmt.Sprintf("cmtup%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("cmtup%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-upd-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Update", 0, time.Now())
+
postCID := "bafypostupdate"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
t.Run("update comment with real Jetstream indexing", func(t *testing.T) {
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
// First, create a comment and wait for it to be indexed
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)
+
if subscribeErr != nil {
+
errorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
// Create initial comment
+
t.Logf("\n๐Ÿ“ Creating initial comment...")
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "Original comment content",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
// Wait for create event
+
select {
+
case <-eventChan:
+
t.Logf("โœ… Create event received and indexed")
+
case err := <-errorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout waiting for create event")
+
}
+
close(done)
+
+
// Now update the comment
+
t.Logf("\n๐Ÿ“ Updating comment via service...")
+
+
// Start new Jetstream subscription for update event
+
updateEventChan := make(chan *jetstream.JetstreamEvent, 10)
+
updateErrorChan := make(chan error, 1)
+
updateDone := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForCommentUpdate(ctx, jetstreamURL, userDID, commentConsumer, updateEventChan, updateDone)
+
if subscribeErr != nil {
+
updateErrorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
// Get existing comment CID from PDS for optimistic locking
+
rkey := utils.ExtractRKeyFromURI(commentResp.URI)
+
pdsResp, httpErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",
+
pdsURL, userDID, rkey))
+
if httpErr != nil {
+
t.Fatalf("Failed to get record from PDS: %v", httpErr)
+
}
+
defer func() { _ = pdsResp.Body.Close() }()
+
if pdsResp.StatusCode != http.StatusOK {
+
body, _ := io.ReadAll(pdsResp.Body)
+
t.Fatalf("Failed to get record from PDS: status=%d body=%s", pdsResp.StatusCode, string(body))
+
}
+
var pdsRecord struct {
+
CID string `json:"cid"`
+
}
+
if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil {
+
t.Fatalf("Failed to decode PDS response: %v", decodeErr)
+
}
+
+
updateReq := comments.UpdateCommentRequest{
+
URI: commentResp.URI,
+
Content: "Updated comment content via E2E test!",
+
Langs: []string{"en"},
+
}
+
+
updatedComment, err := commentService.UpdateComment(ctx, session, updateReq)
+
if err != nil {
+
t.Fatalf("Failed to update comment: %v", err)
+
}
+
+
t.Logf("โœ… Comment updated on PDS:")
+
t.Logf(" URI: %s", updatedComment.URI)
+
t.Logf(" CID: %s", updatedComment.CID)
+
+
// Wait for update event from Jetstream
+
t.Logf("\nโณ Waiting for update event from Jetstream...")
+
+
select {
+
case event := <-updateEventChan:
+
t.Logf("โœ… Received update event from Jetstream!")
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
+
if event.Commit.Operation != "update" {
+
t.Errorf("Expected operation 'update', got '%s'", event.Commit.Operation)
+
}
+
+
// Verify updated content in AppView
+
indexedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Failed to get updated comment: %v", err)
+
}
+
+
if indexedComment.Content != "Updated comment content via E2E test!" {
+
t.Errorf("Expected updated content, got: %s", indexedComment.Content)
+
}
+
+
t.Logf("โœ… Comment updated in AppView:")
+
t.Logf(" Content: %s", indexedComment.Content)
+
+
close(updateDone)
+
+
case err := <-updateErrorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout: No update event received within 30 seconds")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMENT UPDATE FLOW COMPLETE:")
+
t.Logf(" Client โ†’ Service โ†’ PDS PutRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView โœ“")
+
})
+
}
+
+
// TestCommentE2E_DeleteWithJetstream tests comment deletion with real Jetstream indexing
+
func TestCommentE2E_DeleteWithJetstream(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping 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)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
+
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
+
pdsHostname = strings.Split(pdsHostname, ":")[0]
+
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)
+
+
testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
+
if err != nil {
+
t.Skipf("Jetstream not running at %s: %v", jetstreamURL, err)
+
}
+
_ = testConn.Close()
+
+
ctx := context.Background()
+
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
testUserHandle := fmt.Sprintf("cmtdl%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("cmtdl%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-del-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Delete", 0, time.Now())
+
postCID := "bafypostdelete"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
t.Run("delete comment with real Jetstream indexing", func(t *testing.T) {
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
// First, create a comment
+
eventChan := make(chan *jetstream.JetstreamEvent, 10)
+
errorChan := make(chan error, 1)
+
done := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)
+
if subscribeErr != nil {
+
errorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
t.Logf("\n๐Ÿ“ Creating comment to delete...")
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "This comment will be deleted",
+
Langs: []string{"en"},
+
}
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
commentResp, err := commentService.CreateComment(ctx, session, commentReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
+
// Wait for create event
+
select {
+
case <-eventChan:
+
t.Logf("โœ… Create event received")
+
case err := <-errorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout waiting for create event")
+
}
+
close(done)
+
+
// Verify comment exists
+
_, err = commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Comment should exist before delete: %v", err)
+
}
+
+
// Now delete the comment
+
t.Logf("\n๐Ÿ—‘๏ธ Deleting comment via service...")
+
+
deleteEventChan := make(chan *jetstream.JetstreamEvent, 10)
+
deleteErrorChan := make(chan error, 1)
+
deleteDone := make(chan bool)
+
+
go func() {
+
subscribeErr := subscribeToJetstreamForCommentDelete(ctx, jetstreamURL, userDID, commentConsumer, deleteEventChan, deleteDone)
+
if subscribeErr != nil {
+
deleteErrorChan <- subscribeErr
+
}
+
}()
+
+
time.Sleep(500 * time.Millisecond)
+
+
err = commentService.DeleteComment(ctx, session, comments.DeleteCommentRequest{URI: commentResp.URI})
+
if err != nil {
+
t.Fatalf("Failed to delete comment: %v", err)
+
}
+
+
t.Logf("โœ… Comment delete request sent to PDS")
+
+
// Wait for delete event from Jetstream
+
t.Logf("\nโณ Waiting for delete event from Jetstream...")
+
+
select {
+
case event := <-deleteEventChan:
+
t.Logf("โœ… Received delete event from Jetstream!")
+
t.Logf(" Operation: %s", event.Commit.Operation)
+
+
if event.Commit.Operation != "delete" {
+
t.Errorf("Expected operation 'delete', got '%s'", event.Commit.Operation)
+
}
+
+
// Verify comment is soft-deleted in AppView
+
deletedComment, err := commentRepo.GetByURI(ctx, commentResp.URI)
+
if err != nil {
+
t.Fatalf("Failed to get deleted comment: %v", err)
+
}
+
+
if deletedComment.DeletedAt == nil {
+
t.Errorf("Expected comment to be soft-deleted (deleted_at should be set)")
+
} else {
+
t.Logf("โœ… Comment soft-deleted in AppView at: %v", *deletedComment.DeletedAt)
+
}
+
+
close(deleteDone)
+
+
case err := <-deleteErrorChan:
+
t.Fatalf("Jetstream error: %v", err)
+
+
case <-time.After(30 * time.Second):
+
t.Fatalf("Timeout: No delete event received within 30 seconds")
+
}
+
+
t.Logf("\nโœ… TRUE E2E COMMENT DELETE FLOW COMPLETE:")
+
t.Logf(" Client โ†’ Service โ†’ PDS DeleteRecord โ†’ Jetstream โ†’ Consumer โ†’ AppView โœ“")
+
})
+
}
+
+
// subscribeToJetstreamForComment subscribes to real Jetstream firehose for comment create events
+
func subscribeToJetstreamForComment(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.CommentEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
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() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
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 {
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
// Check if this is a comment create event for the target DID
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.comment" &&
+
event.Commit.Operation == "create" {
+
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+
+
// subscribeToJetstreamForCommentUpdate subscribes for comment update events
+
func subscribeToJetstreamForCommentUpdate(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.CommentEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
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() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
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 {
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.comment" &&
+
event.Commit.Operation == "update" {
+
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+
+
// subscribeToJetstreamForCommentDelete subscribes for comment delete events
+
func subscribeToJetstreamForCommentDelete(
+
ctx context.Context,
+
jetstreamURL string,
+
targetDID string,
+
consumer *jetstream.CommentEventConsumer,
+
eventChan chan<- *jetstream.JetstreamEvent,
+
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() }()
+
+
for {
+
select {
+
case <-done:
+
return nil
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
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 {
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
+
return nil
+
}
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+
continue
+
}
+
return fmt.Errorf("failed to read Jetstream message: %w", err)
+
}
+
+
if event.Did == targetDID && event.Kind == "commit" &&
+
event.Commit != nil && event.Commit.Collection == "social.coves.community.comment" &&
+
event.Commit.Operation == "delete" {
+
+
if err := consumer.HandleEvent(ctx, &event); err != nil {
+
return fmt.Errorf("failed to process event: %w", err)
+
}
+
+
select {
+
case eventChan <- &event:
+
return nil
+
case <-time.After(1 * time.Second):
+
return fmt.Errorf("timeout sending event to channel")
+
}
+
}
+
}
+
}
+
}
+
+
// TestCommentE2E_Authorization tests that users cannot modify other users' comments
+
func TestCommentE2E_Authorization(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping 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)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
ctx := context.Background()
+
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create two test users on PDS
+
userAHandle := fmt.Sprintf("usera%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
userAEmail := fmt.Sprintf("usera%d@test.local", time.Now().UnixNano()%1000000)
+
userAPassword := "test-password-123"
+
+
userBHandle := fmt.Sprintf("userb%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
userBEmail := fmt.Sprintf("userb%d@test.local", time.Now().UnixNano()%1000000)
+
userBPassword := "test-password-123"
+
+
pdsAccessTokenA, userADID, err := createPDSAccount(pdsURL, userAHandle, userAEmail, userAPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user A on PDS: %v", err)
+
}
+
+
pdsAccessTokenB, userBDID, err := createPDSAccount(pdsURL, userBHandle, userBEmail, userBPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user B on PDS: %v", err)
+
}
+
+
testUserA := createTestUser(t, db, userAHandle, userADID)
+
_ = createTestUser(t, db, userBHandle, userBDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "auth-test-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUserA.DID, "Auth Test Post", 0, time.Now())
+
postCID := "bafypostauthtest"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userADID, "session-"+userADID, pdsAccessTokenA, pdsURL)
+
mockStore.AddSessionWithPDS(userBDID, "session-"+userBDID, pdsAccessTokenB, pdsURL)
+
+
t.Run("user cannot update another user's comment", func(t *testing.T) {
+
// User A creates a comment
+
parsedDIDA, parseErr := syntax.ParseDID(userADID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID A: %v", parseErr)
+
}
+
sessionA, sessionErr := mockStore.GetSession(ctx, parsedDIDA, "session-"+userADID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session A: %v", sessionErr)
+
}
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "User A's comment",
+
Langs: []string{"en"},
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, sessionA, commentReq)
+
if err != nil {
+
t.Fatalf("User A failed to create comment: %v", err)
+
}
+
t.Logf("User A created comment: %s", commentResp.URI)
+
+
// User B tries to update User A's comment
+
parsedDIDB, parseErr := syntax.ParseDID(userBDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID B: %v", parseErr)
+
}
+
sessionB, sessionErr := mockStore.GetSession(ctx, parsedDIDB, "session-"+userBDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session B: %v", sessionErr)
+
}
+
+
updateReq := comments.UpdateCommentRequest{
+
URI: commentResp.URI,
+
Content: "User B trying to update User A's comment",
+
Langs: []string{"en"},
+
}
+
+
_, err = commentService.UpdateComment(ctx, sessionB, updateReq)
+
if err == nil {
+
t.Errorf("Expected error when User B tries to update User A's comment, got nil")
+
} else if err != comments.ErrNotAuthorized {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: User B cannot update User A's comment")
+
}
+
})
+
+
t.Run("user cannot delete another user's comment", func(t *testing.T) {
+
// User A creates a comment
+
parsedDIDA, parseErr := syntax.ParseDID(userADID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID A: %v", parseErr)
+
}
+
sessionA, sessionErr := mockStore.GetSession(ctx, parsedDIDA, "session-"+userADID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session A: %v", sessionErr)
+
}
+
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "User A's comment for delete test",
+
Langs: []string{"en"},
+
}
+
+
commentResp, err := commentService.CreateComment(ctx, sessionA, commentReq)
+
if err != nil {
+
t.Fatalf("User A failed to create comment: %v", err)
+
}
+
t.Logf("User A created comment: %s", commentResp.URI)
+
+
// User B tries to delete User A's comment
+
parsedDIDB, parseErr := syntax.ParseDID(userBDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID B: %v", parseErr)
+
}
+
sessionB, sessionErr := mockStore.GetSession(ctx, parsedDIDB, "session-"+userBDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session B: %v", sessionErr)
+
}
+
+
deleteReq := comments.DeleteCommentRequest{
+
URI: commentResp.URI,
+
}
+
+
err = commentService.DeleteComment(ctx, sessionB, deleteReq)
+
if err == nil {
+
t.Errorf("Expected error when User B tries to delete User A's comment, got nil")
+
} else if err != comments.ErrNotAuthorized {
+
t.Errorf("Expected ErrNotAuthorized, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: User B cannot delete User A's comment")
+
}
+
})
+
}
+
+
// TestCommentE2E_ValidationErrors tests that validation errors are properly returned
+
func TestCommentE2E_ValidationErrors(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping 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)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
defer func() { _ = db.Close() }()
+
+
if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
+
t.Fatalf("Failed to set goose dialect: %v", dialectErr)
+
}
+
if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
+
t.Fatalf("Failed to run migrations: %v", migrateErr)
+
}
+
+
pdsURL := getTestPDSURL()
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
ctx := context.Background()
+
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
+
// Create test user on PDS
+
testUserHandle := fmt.Sprintf("valtest%d.local.coves.dev", time.Now().UnixNano()%1000000)
+
testUserEmail := fmt.Sprintf("valtest%d@test.local", time.Now().UnixNano()%1000000)
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Fatalf("Failed to create test user on PDS: %v", err)
+
}
+
+
testUser := createTestUser(t, db, testUserHandle, userDID)
+
+
testCommunityDID, err := createFeedTestCommunity(db, ctx, "val-test-community", "owner.test")
+
if err != nil {
+
t.Fatalf("Failed to create test community: %v", err)
+
}
+
+
postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Validation Test Post", 0, time.Now())
+
postCID := "bafypostvaltest"
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
postRepo,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
parsedDID, parseErr := syntax.ParseDID(userDID)
+
if parseErr != nil {
+
t.Fatalf("Failed to parse DID: %v", parseErr)
+
}
+
session, sessionErr := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
if sessionErr != nil {
+
t.Fatalf("Failed to get session: %v", sessionErr)
+
}
+
+
t.Run("empty content returns ErrContentEmpty", func(t *testing.T) {
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: "",
+
Langs: []string{"en"},
+
}
+
+
_, err := commentService.CreateComment(ctx, session, commentReq)
+
if err == nil {
+
t.Errorf("Expected error for empty content, got nil")
+
} else if err != comments.ErrContentEmpty {
+
t.Errorf("Expected ErrContentEmpty, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: empty content returns ErrContentEmpty")
+
}
+
})
+
+
t.Run("whitespace-only content returns ErrContentEmpty", func(t *testing.T) {
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: postURI, CID: postCID},
+
Parent: comments.StrongRef{URI: postURI, CID: postCID},
+
},
+
Content: " \t\n ",
+
Langs: []string{"en"},
+
}
+
+
_, err := commentService.CreateComment(ctx, session, commentReq)
+
if err == nil {
+
t.Errorf("Expected error for whitespace-only content, got nil")
+
} else if err != comments.ErrContentEmpty {
+
t.Errorf("Expected ErrContentEmpty, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: whitespace-only content returns ErrContentEmpty")
+
}
+
})
+
+
t.Run("invalid reply reference returns ErrInvalidReply", func(t *testing.T) {
+
commentReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{URI: "", CID: ""},
+
Parent: comments.StrongRef{URI: "", CID: ""},
+
},
+
Content: "Valid content",
+
Langs: []string{"en"},
+
}
+
+
_, err := commentService.CreateComment(ctx, session, commentReq)
+
if err == nil {
+
t.Errorf("Expected error for invalid reply, got nil")
+
} else if err != comments.ErrInvalidReply {
+
t.Errorf("Expected ErrInvalidReply, got: %v", err)
+
} else {
+
t.Logf("โœ… Correctly rejected: invalid reply returns ErrInvalidReply")
+
}
+
})
+
}
+
+43 -12
internal/atproto/jetstream/community_consumer.go
···
cache, err := lru.New[string, cachedDIDDoc](1000)
if err != nil {
// This should never happen with a valid size, but handle gracefully
-
log.Printf("WARNING: Failed to create DID cache, verification will be slower: %v", err)
+
log.Printf("WARNING: Failed to create DID cache (size=1000), verification will be slower: %v", err)
// Create minimal cache to avoid nil pointer
-
cache, _ = lru.New[string, cachedDIDDoc](1)
+
cache, fallbackErr := lru.New[string, cachedDIDDoc](1)
+
if fallbackErr != nil {
+
// Both attempts failed - this indicates a serious issue with the LRU library
+
log.Printf("CRITICAL: Failed to create fallback DID cache (size=1): %v", fallbackErr)
+
panic(fmt.Sprintf("cannot create LRU cache: primary error=%v, fallback error=%v", err, fallbackErr))
+
}
+
return &CommunityEventConsumer{
+
repo: repo,
+
identityResolver: identityResolver,
+
instanceDID: instanceDID,
+
skipVerification: skipVerification,
+
httpClient: &http.Client{
+
Timeout: 10 * time.Second,
+
Transport: &http.Transport{
+
MaxIdleConns: 100,
+
MaxIdleConnsPerHost: 10,
+
IdleConnTimeout: 90 * time.Second,
+
},
+
},
+
didCache: cache,
+
wellKnownLimiter: rate.NewLimiter(10, 20),
+
}
}
return &CommunityEventConsumer{
···
// Handle description facets (rich text)
if profile.DescriptionFacets != nil {
facetsJSON, marshalErr := json.Marshal(profile.DescriptionFacets)
-
if marshalErr == nil {
+
if marshalErr != nil {
+
log.Printf("WARNING: Failed to marshal description facets for community %s: %v (facets will be omitted)", did, marshalErr)
+
} else {
community.DescriptionFacets = facetsJSON
}
}
···
// extractDomainFromHandle extracts the registrable domain from a community handle
// Handles both formats:
// - Bluesky-style: "!gaming@coves.social" โ†’ "coves.social"
-
// - DNS-style: "gaming.community.coves.social" โ†’ "coves.social"
+
// - DNS-style: "c-gaming.coves.social" โ†’ "coves.social"
//
// Uses golang.org/x/net/publicsuffix to correctly handle multi-part TLDs:
-
// - "gaming.community.coves.co.uk" โ†’ "coves.co.uk" (not "co.uk")
-
// - "gaming.community.example.com.au" โ†’ "example.com.au" (not "com.au")
+
// - "c-gaming.coves.co.uk" โ†’ "coves.co.uk" (not "co.uk")
+
// - "c-gaming.example.com.au" โ†’ "example.com.au" (not "com.au")
func extractDomainFromHandle(handle string) string {
// Remove leading ! if present
handle = strings.TrimPrefix(handle, "!")
···
if err != nil {
// If publicsuffix fails, fall back to returning the full domain part
// This handles edge cases like localhost, IP addresses, etc.
+
log.Printf("DEBUG: publicsuffix failed for @-format handle domain %q, using raw domain: %v", domain, err)
return domain
}
return registrable
···
return ""
}
-
// For DNS-style handles (e.g., "gaming.community.coves.social")
+
// For DNS-style handles (e.g., "c-gaming.coves.social")
// Extract the registrable domain (eTLD+1) using publicsuffix
// This correctly handles multi-part TLDs like .co.uk, .com.au, etc.
registrable, err := publicsuffix.EffectiveTLDPlusOne(handle)
if err != nil {
// If publicsuffix fails (e.g., invalid TLD, localhost, IP address)
// fall back to naive extraction (last 2 parts)
-
// This maintains backward compatibility for edge cases
+
// WARNING: This is incorrect for multi-part TLDs (.co.uk -> would return "co.uk")
+
// but maintains compatibility for localhost/dev environments
parts := strings.Split(handle, ".")
if len(parts) < 2 {
+
log.Printf("DEBUG: Invalid handle format (no dots): %q", handle)
return "" // Invalid handle
}
-
return strings.Join(parts[len(parts)-2:], ".")
+
fallbackDomain := strings.Join(parts[len(parts)-2:], ".")
+
log.Printf("DEBUG: publicsuffix failed for handle %q, using naive fallback: %q (error: %v)", handle, fallbackDomain, err)
+
return fallbackDomain
}
return registrable
···
}
// constructHandleFromProfile constructs a deterministic handle from profile data
-
// Format: {name}.community.{instanceDomain}
-
// Example: gaming.community.coves.social
+
// Format: c-{name}.{instanceDomain}
+
// Example: c-gaming.coves.social
// This is ONLY used in test mode (when identity resolver is nil)
// Production MUST resolve handles from PLC (source of truth)
// Returns empty string if hostedBy is not did:web format (caller will fail validation)
func constructHandleFromProfile(profile *CommunityProfile) string {
if !strings.HasPrefix(profile.HostedBy, "did:web:") {
// hostedBy must be did:web format for handle construction
+
// Log warning since this indicates invalid community data
+
log.Printf("WARNING: constructHandleFromProfile: hostedBy %q is not did:web format, cannot construct handle for community %q",
+
profile.HostedBy, profile.Name)
// Return empty to trigger validation error in repository
return ""
}
instanceDomain := strings.TrimPrefix(profile.HostedBy, "did:web:")
-
return fmt.Sprintf("%s.community.%s", profile.Name, instanceDomain)
+
return fmt.Sprintf("c-%s.%s", profile.Name, instanceDomain)
}
// extractContentVisibility extracts contentVisibility from subscription record with clamping
+5 -6
internal/core/communities/pds_provisioning.go
···
}
// 1. Generate unique handle for the community
-
// Format: {name}.community.{instance-domain}
-
// Example: "gaming.community.coves.social"
-
// NOTE: Using SINGULAR "community" to follow atProto lexicon conventions
-
// (all record types use singular: app.bsky.feed.post, app.bsky.graph.follow, etc.)
-
handle := fmt.Sprintf("%s.community.%s", strings.ToLower(communityName), p.instanceDomain)
+
// Format: c-{name}.{instance-domain}
+
// Example: "c-gaming.coves.social"
+
// Uses c- prefix to distinguish from user handles while keeping single-level subdomain
+
handle := fmt.Sprintf("c-%s.%s", strings.ToLower(communityName), p.instanceDomain)
// 2. Generate system email for PDS account management
// This email is used for account operations, not for user communication
-
email := fmt.Sprintf("community-%s@community.%s", strings.ToLower(communityName), p.instanceDomain)
+
email := fmt.Sprintf("c-%s@%s", strings.ToLower(communityName), p.instanceDomain)
// 3. Generate secure random password (32 characters)
// This password is never shown to users - it's for Coves to authenticate as the community
+49
internal/db/migrations/022_migrate_community_handles_to_c_prefix.sql
···
+
-- +goose Up
+
-- +goose StatementBegin
+
+
-- Migration: Change community handles from .community. subdomain to c- prefix
+
-- This simplifies DNS/Caddy configuration (works with *.coves.social wildcard)
+
--
+
-- Examples:
+
-- gardening.community.coves.social -> c-gardening.coves.social
+
-- gaming.community.coves.social -> c-gaming.coves.social
+
--
+
-- Also updates the system email format:
+
-- community-gardening@community.coves.social -> c-gardening@coves.social
+
+
-- Update community handles in the communities table
+
UPDATE communities
+
SET handle = 'c-' || SPLIT_PART(handle, '.community.', 1) || '.' || SPLIT_PART(handle, '.community.', 2)
+
WHERE handle LIKE '%.community.%';
+
+
-- Update email addresses to match new format
+
-- Old: community-{name}@community.{instance}
+
-- New: c-{name}@{instance}
+
UPDATE communities
+
SET pds_email = 'c-' || SUBSTRING(pds_email FROM 11 FOR POSITION('@' IN pds_email) - 11) || '@' || SUBSTRING(pds_email FROM POSITION('@community.' IN pds_email) + 11)
+
WHERE pds_email LIKE 'community-%@community.%';
+
+
-- +goose StatementEnd
+
+
-- +goose Down
+
-- +goose StatementBegin
+
+
-- Rollback: Revert handles from c- prefix back to .community. subdomain
+
-- Parse: c-{name}.{instance} -> {name}.community.{instance}
+
+
UPDATE communities
+
SET handle = SUBSTRING(handle FROM 3 FOR POSITION('.' IN SUBSTRING(handle FROM 3)) - 1)
+
|| '.community.'
+
|| SUBSTRING(handle FROM POSITION('.' IN SUBSTRING(handle FROM 3)) + 3)
+
WHERE handle LIKE 'c-%' AND handle NOT LIKE '%.community.%';
+
+
-- Revert email addresses
+
-- New: c-{name}@{instance}
+
-- Old: community-{name}@community.{instance}
+
UPDATE communities
+
SET pds_email = 'community-' || SUBSTRING(pds_email FROM 3 FOR POSITION('@' IN pds_email) - 3)
+
|| '@community.'
+
|| SUBSTRING(pds_email FROM POSITION('@' IN pds_email) + 1)
+
WHERE pds_email LIKE 'c-%@%' AND pds_email NOT LIKE 'community-%@community.%';
+
+
-- +goose StatementEnd
+5 -5
tests/integration/community_consumer_test.go
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("test-community-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("update-test-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("delete-test-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("sub-test-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.local", communityName)
// Set up mock resolver for this test DID
mockResolver := newMockIdentityResolver()
···
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
communityName := fmt.Sprintf("test-plc-%s", uniqueSuffix)
-
expectedHandle := fmt.Sprintf("%s.community.coves.social", communityName)
+
expectedHandle := fmt.Sprintf("c-%s.coves.social", communityName)
// Create mock resolver
mockResolver := newMockIdentityResolver()
+8 -8
tests/integration/community_v2_validation_test.go
···
// 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)
+
testHandle := fmt.Sprintf("c-testv2rkey%d.coves.social", timestamp)
event := &jetstream.JetstreamEvent{
Did: testDID,
···
CID: "bafyreiv1community",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "v1community.community.coves.social",
+
"handle": "c-v1community.coves.social",
"name": "v1community",
"createdBy": "did:plc:user456",
"hostedBy": "did:web:coves.social",
···
CID: "bafyreicustom",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "custom.community.coves.social",
+
"handle": "c-custom.coves.social",
"name": "custom",
"createdBy": "did:plc:user789",
"hostedBy": "did:web:coves.social",
···
CID: "bafyreiupdate1",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "updatetest.community.coves.social",
+
"handle": "c-updatetest.coves.social",
"name": "updatetest",
"createdBy": "did:plc:userUpdate",
"hostedBy": "did:web:coves.social",
···
CID: "bafyreiupdate2",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "updatetest.community.coves.social",
+
"handle": "c-updatetest.coves.social",
"name": "updatetest",
"displayName": "Updated Name",
"createdBy": "did:plc:userUpdate",
···
CID: "bafyreihandle",
Record: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "gamingtest.community.coves.social", // atProto handle (DNS-resolvable)
+
"handle": "c-gamingtest.coves.social", // atProto handle (DNS-resolvable)
"name": "gamingtest", // Short name for !mentions
"createdBy": "did:plc:user123",
"hostedBy": "did:web:coves.social",
···
}
// Verify the atProto handle is stored
-
if community.Handle != "gamingtest.community.coves.social" {
-
t.Errorf("Expected handle gamingtest.community.coves.social, got %s", community.Handle)
+
if community.Handle != "c-gamingtest.coves.social" {
+
t.Errorf("Expected handle c-gamingtest.coves.social, got %s", community.Handle)
}
// Note: The DID is the authoritative identifier for atProto resolution
+2 -2
tests/integration/token_refresh_test.go
···
// Create a test community first
community := &communities.Community{
DID: "did:plc:test123",
-
Handle: "test.community.coves.social",
+
Handle: "c-test.coves.social",
Name: "test",
OwnerDID: "did:plc:test123",
CreatedByDID: "did:plc:creator",
···
community := &communities.Community{
DID: "did:plc:expiring123",
-
Handle: "expiring.community.coves.social",
+
Handle: "c-expiring.coves.social",
Name: "expiring",
OwnerDID: "did:plc:expiring123",
CreatedByDID: "did:plc:creator",
+1 -1
internal/atproto/lexicon/social/coves/actor/getProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.getprofile",
+
"id": "social.coves.actor.getProfile",
"defs": {
"main": {
"type": "query",
+1 -1
internal/atproto/lexicon/social/coves/actor/updateProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.updateprofile",
+
"id": "social.coves.actor.updateProfile",
"defs": {
"main": {
"type": "procedure",
+2
internal/atproto/oauth/store_test.go
···
func TestPostgresOAuthStore_CleanupExpiredSessions(t *testing.T) {
db := setupTestDB(t)
defer func() { _ = db.Close() }()
+
// Clean up before AND after to ensure test isolation
+
cleanupOAuth(t, db)
defer cleanupOAuth(t, db)
storeInterface := NewPostgresOAuthStore(db, 0) // Use default TTL
+5 -5
internal/db/postgres/vote_repo_test.go
···
err = repo.Delete(ctx, vote.URI)
assert.NoError(t, err)
-
// Verify vote is soft-deleted (still exists but has deleted_at)
-
retrieved, err := repo.GetByURI(ctx, vote.URI)
-
assert.NoError(t, err)
-
assert.NotNil(t, retrieved.DeletedAt, "DeletedAt should be set after deletion")
+
// Verify vote is soft-deleted by checking it's no longer retrievable
+
// GetByURI excludes deleted votes (returns ErrVoteNotFound)
+
_, err = repo.GetByURI(ctx, vote.URI)
+
assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByURI should not return deleted votes")
-
// GetByVoterAndSubject should not find deleted votes
+
// GetByVoterAndSubject should also not find deleted votes
_, err = repo.GetByVoterAndSubject(ctx, voterDID, vote.SubjectURI)
assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByVoterAndSubject should not return deleted votes")
}
+26 -3
tests/integration/oauth_e2e_test.go
···
assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")
// Test cleanup of expired sessions
-
cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredSessions(ctx)
+
// Need to access the underlying PostgresOAuthStore through the wrapper
+
var pgStore *oauth.PostgresOAuthStore
+
if wrapper, ok := store.(*oauth.MobileAwareStoreWrapper); ok {
+
pgStore, _ = wrapper.ClientAuthStore.(*oauth.PostgresOAuthStore)
+
} else {
+
pgStore, _ = store.(*oauth.PostgresOAuthStore)
+
}
+
require.NotNil(t, pgStore, "Should be able to access PostgresOAuthStore")
+
+
cleaned, err := pgStore.CleanupExpiredSessions(ctx)
require.NoError(t, err, "Cleanup should succeed")
assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one session")
···
// Test cleanup of expired auth requests
// Create an auth request and manually set created_at to the past
+
// Use unique state to avoid conflicts with previous test runs
+
oldState := fmt.Sprintf("old-state-%d", time.Now().UnixNano())
oldAuthRequest := oauthlib.AuthRequestData{
-
State: "old-state-12345",
+
State: oldState,
PKCEVerifier: "old-verifier",
AuthServerURL: "http://localhost:3001",
Scopes: []string{"atproto"},
}
+
// Clean up any existing state first
+
_ = store.DeleteAuthRequestInfo(ctx, oldState)
+
err = store.SaveAuthRequestInfo(ctx, oldAuthRequest)
require.NoError(t, err)
···
require.NoError(t, err)
// Cleanup expired requests
-
cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredAuthRequests(ctx)
+
// Need to access the underlying PostgresOAuthStore through the wrapper
+
var pgStore *oauth.PostgresOAuthStore
+
if wrapper, ok := store.(*oauth.MobileAwareStoreWrapper); ok {
+
pgStore, _ = wrapper.ClientAuthStore.(*oauth.PostgresOAuthStore)
+
} else {
+
pgStore, _ = store.(*oauth.PostgresOAuthStore)
+
}
+
require.NotNil(t, pgStore, "Should be able to access PostgresOAuthStore")
+
+
cleaned, err := pgStore.CleanupExpiredAuthRequests(ctx)
require.NoError(t, err, "Cleanup should succeed")
assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one auth request")
+5 -5
tests/integration/vote_e2e_test.go
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user on PDS
-
testUserHandle := fmt.Sprintf("voter-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("vot%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("voter-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user
-
testUserHandle := fmt.Sprintf("toggle-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("tog%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("toggle-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user
-
testUserHandle := fmt.Sprintf("flip-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("flp%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("flip-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
// Create test user
-
testUserHandle := fmt.Sprintf("delete-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("dlt%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("delete-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
···
voteRepo := postgres.NewVoteRepository(db)
// Create test user on PDS
-
testUserHandle := fmt.Sprintf("jetstream-%d.local.coves.dev", time.Now().Unix())
+
testUserHandle := fmt.Sprintf("jet%d.local.coves.dev", time.Now().UnixNano()%1000000)
testUserEmail := fmt.Sprintf("jetstream-%d@test.local", time.Now().Unix())
testUserPassword := "test-password-123"
+13 -6
internal/api/handlers/aggregator/list_for_community.go
···
import (
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
···
// ListForCommunityHandler handles listing aggregators for a community
type ListForCommunityHandler struct {
-
service aggregators.Service
+
service aggregators.Service
+
communityService communities.Service
}
// NewListForCommunityHandler creates a new list for community handler
-
func NewListForCommunityHandler(service aggregators.Service) *ListForCommunityHandler {
+
func NewListForCommunityHandler(service aggregators.Service, communityService communities.Service) *ListForCommunityHandler {
return &ListForCommunityHandler{
-
service: service,
+
service: service,
+
communityService: communityService,
}
}
···
return
}
-
// Resolve community identifier to DID (handles both DIDs and handles)
-
// TODO: Implement identifier resolution service - for now, assume it's a DID
-
req.CommunityDID = communityIdentifier
+
// Resolve community identifier to DID (supports DIDs, handles, scoped identifiers)
+
communityDID, err := h.communityService.ResolveCommunityIdentifier(r.Context(), communityIdentifier)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
req.CommunityDID = communityDID
// Get authorizations from service
// Note: Community handle/name fields will be empty until we integrate with communities service
+18 -10
internal/api/handlers/communityFeed/get_community.go
···
package communityFeed
import (
-
"Coves/internal/api/handlers/common"
-
"Coves/internal/core/communityFeeds"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/votes"
"encoding/json"
"log"
"net/http"
"strconv"
+
+
"Coves/internal/api/handlers/common"
+
"Coves/internal/core/blueskypost"
+
"Coves/internal/core/communityFeeds"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/votes"
)
// GetCommunityHandler handles community feed retrieval
type GetCommunityHandler struct {
-
service communityFeeds.Service
-
voteService votes.Service
+
service communityFeeds.Service
+
voteService votes.Service
+
blueskyService blueskypost.Service
}
// NewGetCommunityHandler creates a new community feed handler
-
func NewGetCommunityHandler(service communityFeeds.Service, voteService votes.Service) *GetCommunityHandler {
+
func NewGetCommunityHandler(service communityFeeds.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetCommunityHandler {
+
if blueskyService == nil {
+
log.Printf("[COMMUNITY-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved")
+
}
return &GetCommunityHandler{
-
service: service,
-
voteService: voteService,
+
service: service,
+
voteService: voteService,
+
blueskyService: blueskyService,
}
}
···
// Populate viewer vote state if authenticated
common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed)
-
// Transform blob refs to URLs for all posts
+
// Transform blob refs to URLs and resolve post embeds for all posts
for _, feedPost := range response.Feed {
if feedPost.Post != nil {
posts.TransformBlobRefsToURLs(feedPost.Post)
+
posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService)
}
}
+19 -11
internal/api/handlers/timeline/get_timeline.go
···
package timeline
import (
-
"Coves/internal/api/handlers/common"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/timeline"
-
"Coves/internal/core/votes"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
+
+
"Coves/internal/api/handlers/common"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/blueskypost"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/timeline"
+
"Coves/internal/core/votes"
)
// GetTimelineHandler handles timeline feed retrieval
type GetTimelineHandler struct {
-
service timeline.Service
-
voteService votes.Service
+
service timeline.Service
+
voteService votes.Service
+
blueskyService blueskypost.Service
}
// NewGetTimelineHandler creates a new timeline handler
-
func NewGetTimelineHandler(service timeline.Service, voteService votes.Service) *GetTimelineHandler {
+
func NewGetTimelineHandler(service timeline.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetTimelineHandler {
+
if blueskyService == nil {
+
log.Printf("[TIMELINE-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved")
+
}
return &GetTimelineHandler{
-
service: service,
-
voteService: voteService,
+
service: service,
+
voteService: voteService,
+
blueskyService: blueskyService,
}
}
···
// Populate viewer vote state if authenticated
common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed)
-
// Transform blob refs to URLs for all posts
+
// Transform blob refs to URLs and resolve post embeds for all posts
for _, feedPost := range response.Feed {
if feedPost.Post != nil {
posts.TransformBlobRefsToURLs(feedPost.Post)
+
posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService)
}
}
+178
internal/core/blueskypost/circuit_breaker.go
···
+
package blueskypost
+
+
import (
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
)
+
+
// circuitState represents the state of a circuit breaker
+
type circuitState int
+
+
const (
+
stateClosed circuitState = iota // Normal operation
+
stateOpen // Circuit is open (provider failing)
+
stateHalfOpen // Testing if provider recovered
+
)
+
+
// circuitBreaker tracks failures per provider and stops trying failing providers
+
type circuitBreaker struct {
+
failures map[string]int
+
lastFailure map[string]time.Time
+
state map[string]circuitState
+
lastStateLog map[string]time.Time
+
failureThreshold int
+
openDuration time.Duration
+
mu sync.RWMutex
+
}
+
+
// newCircuitBreaker creates a circuit breaker with default settings
+
func newCircuitBreaker() *circuitBreaker {
+
return &circuitBreaker{
+
failureThreshold: 3, // Open after 3 consecutive failures
+
openDuration: 5 * time.Minute, // Keep open for 5 minutes
+
failures: make(map[string]int),
+
lastFailure: make(map[string]time.Time),
+
state: make(map[string]circuitState),
+
lastStateLog: make(map[string]time.Time),
+
}
+
}
+
+
// canAttempt checks if we should attempt to call this provider
+
// Returns true if circuit is closed or half-open (ready to retry)
+
func (cb *circuitBreaker) canAttempt(provider string) (bool, error) {
+
// First check under read lock if we need to transition
+
cb.mu.RLock()
+
state := cb.getState(provider)
+
lastFail := cb.lastFailure[provider]
+
needsTransition := state == stateOpen && time.Since(lastFail) > cb.openDuration
+
cb.mu.RUnlock()
+
+
// If we need to transition, acquire write lock and re-check
+
if needsTransition {
+
cb.mu.Lock()
+
// Re-check state in case another goroutine already transitioned
+
state = cb.getState(provider)
+
lastFail = cb.lastFailure[provider]
+
if state == stateOpen && time.Since(lastFail) > cb.openDuration {
+
cb.state[provider] = stateHalfOpen
+
cb.logStateChange(provider, stateHalfOpen)
+
}
+
state = cb.state[provider]
+
cb.mu.Unlock()
+
// Return based on new state
+
if state == stateHalfOpen {
+
return true, nil
+
}
+
}
+
+
// Now check state under read lock
+
cb.mu.RLock()
+
defer cb.mu.RUnlock()
+
+
state = cb.getState(provider)
+
+
switch state {
+
case stateClosed:
+
return true, nil
+
case stateOpen:
+
// Still in open period
+
failCount := cb.failures[provider]
+
nextRetry := cb.lastFailure[provider].Add(cb.openDuration)
+
return false, fmt.Errorf(
+
"%w for provider '%s' (failures: %d, next retry: %s)",
+
ErrCircuitOpen,
+
provider,
+
failCount,
+
nextRetry.Format("15:04:05"),
+
)
+
case stateHalfOpen:
+
return true, nil
+
default:
+
return true, nil
+
}
+
}
+
+
// recordSuccess records a successful fetch, resetting failure count
+
func (cb *circuitBreaker) recordSuccess(provider string) {
+
cb.mu.Lock()
+
defer cb.mu.Unlock()
+
+
oldState := cb.getState(provider)
+
+
// Reset failure tracking
+
delete(cb.failures, provider)
+
delete(cb.lastFailure, provider)
+
cb.state[provider] = stateClosed
+
+
// Log recovery if we were in a failure state
+
if oldState != stateClosed {
+
cb.logStateChange(provider, stateClosed)
+
}
+
}
+
+
// recordFailure records a failed fetch attempt
+
func (cb *circuitBreaker) recordFailure(provider string, err error) {
+
cb.mu.Lock()
+
defer cb.mu.Unlock()
+
+
// Increment failure count
+
cb.failures[provider]++
+
cb.lastFailure[provider] = time.Now()
+
+
failCount := cb.failures[provider]
+
+
// Check if we should open the circuit
+
if failCount >= cb.failureThreshold {
+
oldState := cb.getState(provider)
+
cb.state[provider] = stateOpen
+
if oldState != stateOpen {
+
log.Printf(
+
"[BLUESKY-CIRCUIT] Opening circuit for provider '%s' after %d consecutive failures. Last error: %v",
+
provider,
+
failCount,
+
err,
+
)
+
cb.lastStateLog[provider] = time.Now()
+
}
+
} else {
+
log.Printf(
+
"[BLUESKY-CIRCUIT] Failure %d/%d for provider '%s': %v",
+
failCount,
+
cb.failureThreshold,
+
provider,
+
err,
+
)
+
}
+
}
+
+
// getState returns the current state (must be called with lock held)
+
func (cb *circuitBreaker) getState(provider string) circuitState {
+
if state, exists := cb.state[provider]; exists {
+
return state
+
}
+
return stateClosed
+
}
+
+
// logStateChange logs state transitions (must be called with lock held)
+
// Debounced to avoid log spam (max once per minute per provider)
+
func (cb *circuitBreaker) logStateChange(provider string, newState circuitState) {
+
lastLog, exists := cb.lastStateLog[provider]
+
if exists && time.Since(lastLog) < time.Minute {
+
return // Don't spam logs
+
}
+
+
var stateStr string
+
switch newState {
+
case stateClosed:
+
stateStr = "CLOSED (recovered)"
+
case stateOpen:
+
stateStr = "OPEN (failing)"
+
case stateHalfOpen:
+
stateStr = "HALF-OPEN (testing)"
+
}
+
+
log.Printf("[BLUESKY-CIRCUIT] Circuit for provider '%s' is now %s", provider, stateStr)
+
cb.lastStateLog[provider] = time.Now()
+
}
+410
internal/core/blueskypost/circuit_breaker_test.go
···
+
package blueskypost
+
+
import (
+
"errors"
+
"sync"
+
"testing"
+
"time"
+
)
+
+
func TestCircuitBreaker_InitialState(t *testing.T) {
+
cb := newCircuitBreaker()
+
+
// Circuit should start in closed state
+
canAttempt, err := cb.canAttempt("test-provider")
+
if !canAttempt {
+
t.Error("Circuit breaker should start in closed state")
+
}
+
if err != nil {
+
t.Errorf("canAttempt() should not return error for closed circuit, got: %v", err)
+
}
+
}
+
+
func TestCircuitBreaker_OpensAfterThresholdFailures(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Record failures up to threshold (default is 3)
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Circuit should now be open
+
canAttempt, err := cb.canAttempt(provider)
+
if canAttempt {
+
t.Error("Circuit breaker should be open after threshold failures")
+
}
+
if err == nil {
+
t.Error("canAttempt() should return error when circuit is open")
+
}
+
}
+
+
func TestCircuitBreaker_StaysClosedBelowThreshold(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Record failures below threshold
+
for i := 0; i < cb.failureThreshold-1; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Circuit should still be closed
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Error("Circuit breaker should remain closed below threshold")
+
}
+
if err != nil {
+
t.Errorf("canAttempt() should not return error below threshold, got: %v", err)
+
}
+
}
+
+
func TestCircuitBreaker_TransitionsToHalfOpenAfterTimeout(t *testing.T) {
+
cb := newCircuitBreaker()
+
// Set a very short open duration for testing
+
cb.openDuration = 10 * time.Millisecond
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Open the circuit
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Verify circuit is open
+
canAttempt, err := cb.canAttempt(provider)
+
if canAttempt || err == nil {
+
t.Fatal("Circuit should be open after threshold failures")
+
}
+
+
// Wait for the open duration to pass
+
time.Sleep(cb.openDuration + 5*time.Millisecond)
+
+
// Circuit should transition to half-open and allow attempt
+
canAttempt, err = cb.canAttempt(provider)
+
if !canAttempt {
+
t.Error("Circuit breaker should transition to half-open after timeout")
+
}
+
if err != nil {
+
t.Errorf("canAttempt() should not return error in half-open state, got: %v", err)
+
}
+
}
+
+
func TestCircuitBreaker_ClosesOnSuccessAfterHalfOpen(t *testing.T) {
+
cb := newCircuitBreaker()
+
cb.openDuration = 10 * time.Millisecond
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Open the circuit
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Wait for half-open
+
time.Sleep(cb.openDuration + 5*time.Millisecond)
+
+
// Verify we can attempt
+
canAttempt, _ := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Fatal("Circuit should be half-open")
+
}
+
+
// Record a success
+
cb.recordSuccess(provider)
+
+
// Circuit should now be closed
+
cb.mu.RLock()
+
state := cb.getState(provider)
+
cb.mu.RUnlock()
+
+
if state != stateClosed {
+
t.Errorf("Circuit should be closed after success in half-open state, got state: %v", state)
+
}
+
+
// Should allow attempts without error
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Error("Circuit should be closed and allow attempts")
+
}
+
if err != nil {
+
t.Errorf("canAttempt() should not return error when closed, got: %v", err)
+
}
+
}
+
+
func TestCircuitBreaker_SuccessResetsFailureCount(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Record some failures (but below threshold)
+
for i := 0; i < cb.failureThreshold-1; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Verify failure count
+
cb.mu.RLock()
+
failCount := cb.failures[provider]
+
cb.mu.RUnlock()
+
if failCount != cb.failureThreshold-1 {
+
t.Errorf("Expected %d failures, got %d", cb.failureThreshold-1, failCount)
+
}
+
+
// Record a success
+
cb.recordSuccess(provider)
+
+
// Failure count should be reset
+
cb.mu.RLock()
+
failCount = cb.failures[provider]
+
cb.mu.RUnlock()
+
if failCount != 0 {
+
t.Errorf("Expected 0 failures after success, got %d", failCount)
+
}
+
}
+
+
func TestCircuitBreaker_IndependentProviders(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider1 := "provider-1"
+
provider2 := "provider-2"
+
testErr := errors.New("test error")
+
+
// Open circuit for provider1
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider1, testErr)
+
}
+
+
// Provider1 should be open
+
canAttempt1, err1 := cb.canAttempt(provider1)
+
if canAttempt1 || err1 == nil {
+
t.Error("Provider1 circuit should be open")
+
}
+
+
// Provider2 should still be closed
+
canAttempt2, err2 := cb.canAttempt(provider2)
+
if !canAttempt2 {
+
t.Error("Provider2 circuit should be closed")
+
}
+
if err2 != nil {
+
t.Errorf("Provider2 canAttempt() should not return error, got: %v", err2)
+
}
+
}
+
+
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Number of concurrent goroutines
+
numGoroutines := 100
+
var wg sync.WaitGroup
+
wg.Add(numGoroutines)
+
+
// Concurrently record failures and check state
+
for i := 0; i < numGoroutines; i++ {
+
go func(idx int) {
+
defer wg.Done()
+
+
// Mix of operations
+
if idx%3 == 0 {
+
cb.recordFailure(provider, testErr)
+
} else if idx%3 == 1 {
+
cb.recordSuccess(provider)
+
} else {
+
_, _ = cb.canAttempt(provider)
+
}
+
}(i)
+
}
+
+
wg.Wait()
+
+
// No panic or race conditions should occur
+
// Final state check - just ensure we can call canAttempt
+
_, _ = cb.canAttempt(provider)
+
}
+
+
func TestCircuitBreaker_MultipleProvidersThreadSafety(t *testing.T) {
+
cb := newCircuitBreaker()
+
testErr := errors.New("test error")
+
numProviders := 10
+
numOpsPerProvider := 100
+
+
var wg sync.WaitGroup
+
wg.Add(numProviders)
+
+
// Concurrent operations on different providers
+
for i := 0; i < numProviders; i++ {
+
go func(providerID int) {
+
defer wg.Done()
+
provider := "provider-" + string(rune('0'+providerID))
+
+
for j := 0; j < numOpsPerProvider; j++ {
+
switch j % 4 {
+
case 0:
+
cb.recordFailure(provider, testErr)
+
case 1:
+
cb.recordSuccess(provider)
+
case 2:
+
_, _ = cb.canAttempt(provider)
+
case 3:
+
// Read state
+
cb.mu.RLock()
+
_ = cb.getState(provider)
+
cb.mu.RUnlock()
+
}
+
}
+
}(i)
+
}
+
+
wg.Wait()
+
+
// Verify all providers are accessible without panic
+
for i := 0; i < numProviders; i++ {
+
provider := "provider-" + string(rune('0'+i))
+
_, _ = cb.canAttempt(provider)
+
}
+
}
+
+
func TestCircuitBreaker_StateTransitions(t *testing.T) {
+
cb := newCircuitBreaker()
+
cb.openDuration = 10 * time.Millisecond
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Initial state: closed
+
cb.mu.RLock()
+
if cb.getState(provider) != stateClosed {
+
t.Error("Initial state should be closed")
+
}
+
cb.mu.RUnlock()
+
+
// Transition to open
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
cb.mu.RLock()
+
if cb.getState(provider) != stateOpen {
+
t.Error("State should be open after threshold failures")
+
}
+
cb.mu.RUnlock()
+
+
// Wait for half-open transition
+
time.Sleep(cb.openDuration + 5*time.Millisecond)
+
_, _ = cb.canAttempt(provider) // Trigger state check
+
+
cb.mu.RLock()
+
state := cb.getState(provider)
+
cb.mu.RUnlock()
+
if state != stateHalfOpen {
+
t.Errorf("State should be half-open after timeout, got: %v", state)
+
}
+
+
// Transition back to closed
+
cb.recordSuccess(provider)
+
+
cb.mu.RLock()
+
if cb.getState(provider) != stateClosed {
+
t.Error("State should be closed after success in half-open")
+
}
+
cb.mu.RUnlock()
+
}
+
+
func TestCircuitBreaker_ErrorMessage(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Open the circuit
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Check error message contains useful information
+
_, err := cb.canAttempt(provider)
+
if err == nil {
+
t.Fatal("Expected error when circuit is open")
+
}
+
+
errMsg := err.Error()
+
if !contains(errMsg, "circuit breaker open") {
+
t.Errorf("Error message should mention circuit breaker, got: %s", errMsg)
+
}
+
if !contains(errMsg, provider) {
+
t.Errorf("Error message should contain provider name, got: %s", errMsg)
+
}
+
}
+
+
func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) {
+
cb := newCircuitBreaker()
+
cb.openDuration = 10 * time.Millisecond
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Open the circuit
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
// Wait for half-open
+
time.Sleep(cb.openDuration + 5*time.Millisecond)
+
_, _ = cb.canAttempt(provider)
+
+
// Record another failure in half-open state
+
cb.recordFailure(provider, testErr)
+
+
// Circuit should be open again (failure count incremented)
+
cb.mu.RLock()
+
failCount := cb.failures[provider]
+
cb.mu.RUnlock()
+
+
if failCount < cb.failureThreshold {
+
t.Errorf("Expected failure count >= %d after half-open failure, got: %d", cb.failureThreshold, failCount)
+
}
+
}
+
+
func TestCircuitBreaker_CustomThresholdAndDuration(t *testing.T) {
+
cb := &circuitBreaker{
+
failureThreshold: 5,
+
openDuration: 20 * time.Millisecond,
+
failures: make(map[string]int),
+
lastFailure: make(map[string]time.Time),
+
state: make(map[string]circuitState),
+
lastStateLog: make(map[string]time.Time),
+
}
+
+
provider := "test-provider"
+
testErr := errors.New("test error")
+
+
// Should not open until 5 failures
+
for i := 0; i < 4; i++ {
+
cb.recordFailure(provider, testErr)
+
}
+
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt || err != nil {
+
t.Error("Circuit should remain closed before threshold")
+
}
+
+
// 5th failure should open it
+
cb.recordFailure(provider, testErr)
+
+
canAttempt, err = cb.canAttempt(provider)
+
if canAttempt || err == nil {
+
t.Error("Circuit should be open after 5 failures")
+
}
+
+
// Should not transition to half-open before 20ms
+
time.Sleep(10 * time.Millisecond)
+
canAttempt, _ = cb.canAttempt(provider)
+
if canAttempt {
+
t.Error("Circuit should still be open before timeout")
+
}
+
+
// Should transition after 20ms
+
time.Sleep(15 * time.Millisecond)
+
canAttempt, _ = cb.canAttempt(provider)
+
if !canAttempt {
+
t.Error("Circuit should be half-open after timeout")
+
}
+
}
+39
internal/core/blueskypost/interfaces.go
···
+
package blueskypost
+
+
import (
+
"context"
+
"time"
+
)
+
+
// Service defines the interface for Bluesky post resolution and caching.
+
// It orchestrates URL parsing, cache lookups, API fetching, and circuit breaking.
+
type Service interface {
+
// ResolvePost fetches and resolves a Bluesky post by AT-URI.
+
// It checks the cache first, then fetches from public.api.bsky.app if needed.
+
// Returns BlueskyPostResult with Unavailable=true if the post cannot be resolved.
+
ResolvePost(ctx context.Context, atURI string) (*BlueskyPostResult, error)
+
+
// ParseBlueskyURL converts a bsky.app URL to an AT-URI.
+
// Example: https://bsky.app/profile/user.bsky.social/post/abc123
+
// -> at://did:plc:xxx/app.bsky.feed.post/abc123
+
// Returns error if the URL is invalid or handle resolution fails.
+
ParseBlueskyURL(ctx context.Context, url string) (string, error)
+
+
// IsBlueskyURL checks if a URL is a valid bsky.app post URL.
+
// Returns true for URLs matching https://bsky.app/profile/{handle}/post/{rkey}
+
IsBlueskyURL(url string) bool
+
}
+
+
// Repository defines the interface for Bluesky post cache persistence.
+
// This follows the same pattern as the unfurl cache repository.
+
type Repository interface {
+
// Get retrieves a cached Bluesky post result for the given AT-URI.
+
// Returns ErrCacheMiss if not found or expired (not an error condition).
+
// Returns error only on database failures.
+
Get(ctx context.Context, atURI string) (*BlueskyPostResult, error)
+
+
// Set stores a Bluesky post result in the cache with the specified TTL.
+
// If an entry already exists for the AT-URI, it will be updated.
+
// The expires_at is calculated as NOW() + ttl.
+
Set(ctx context.Context, atURI string, result *BlueskyPostResult, ttl time.Duration) error
+
}
+131
internal/core/blueskypost/repository.go
···
+
package blueskypost
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"strings"
+
"time"
+
)
+
+
// ErrCacheMiss is returned when a cache entry is not found or has expired
+
var ErrCacheMiss = errors.New("cache miss")
+
+
type postgresBlueskyPostRepo struct {
+
db *sql.DB
+
}
+
+
// NewRepository creates a new PostgreSQL Bluesky post cache repository
+
func NewRepository(db *sql.DB) Repository {
+
if db == nil {
+
panic("blueskypost: db cannot be nil")
+
}
+
return &postgresBlueskyPostRepo{db: db}
+
}
+
+
// Get retrieves a cached Bluesky post result for the given AT-URI.
+
// Returns ErrCacheMiss if not found or expired.
+
// Returns error only on database failures.
+
func (r *postgresBlueskyPostRepo) Get(ctx context.Context, atURI string) (*BlueskyPostResult, error) {
+
query := `
+
SELECT metadata
+
FROM bluesky_post_cache
+
WHERE at_uri = $1 AND expires_at > NOW()
+
`
+
+
var metadataJSON []byte
+
+
err := r.db.QueryRowContext(ctx, query, atURI).Scan(&metadataJSON)
+
if err == sql.ErrNoRows {
+
// Not found or expired is a cache miss
+
return nil, ErrCacheMiss
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get bluesky post cache entry: %w", err)
+
}
+
+
// Unmarshal metadata JSONB to BlueskyPostResult
+
var result BlueskyPostResult
+
if err := json.Unmarshal(metadataJSON, &result); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
+
}
+
+
return &result, nil
+
}
+
+
// Set stores a Bluesky post result in the cache with the specified TTL.
+
// If an entry already exists for the AT-URI, it will be updated.
+
// The expires_at is calculated as NOW() + ttl.
+
func (r *postgresBlueskyPostRepo) Set(ctx context.Context, atURI string, result *BlueskyPostResult, ttl time.Duration) error {
+
// Validate AT-URI format to prevent cache pollution
+
if err := validateATURI(atURI); err != nil {
+
return err
+
}
+
+
// Marshal BlueskyPostResult to JSON for metadata column
+
metadataJSON, err := json.Marshal(result)
+
if err != nil {
+
return fmt.Errorf("failed to marshal metadata: %w", err)
+
}
+
+
// Convert Go duration to PostgreSQL interval string
+
// e.g., "1 hour", "24 hours", "7 days"
+
intervalStr := formatInterval(ttl)
+
+
query := `
+
INSERT INTO bluesky_post_cache (at_uri, metadata, expires_at)
+
VALUES ($1, $2, NOW() + $3::interval)
+
ON CONFLICT (at_uri) DO UPDATE
+
SET metadata = EXCLUDED.metadata,
+
expires_at = EXCLUDED.expires_at,
+
fetched_at = NOW()
+
`
+
+
_, err = r.db.ExecContext(ctx, query, atURI, metadataJSON, intervalStr)
+
if err != nil {
+
return fmt.Errorf("failed to insert/update bluesky post cache entry: %w", err)
+
}
+
+
return nil
+
}
+
+
// formatInterval converts a Go duration to a PostgreSQL interval string
+
// PostgreSQL accepts intervals like "1 hour", "24 hours", "7 days"
+
func formatInterval(d time.Duration) string {
+
seconds := int64(d.Seconds())
+
+
// Convert to appropriate unit for readability
+
switch {
+
case seconds >= 86400: // >= 1 day
+
days := seconds / 86400
+
return fmt.Sprintf("%d days", days)
+
case seconds >= 3600: // >= 1 hour
+
hours := seconds / 3600
+
return fmt.Sprintf("%d hours", hours)
+
case seconds >= 60: // >= 1 minute
+
minutes := seconds / 60
+
return fmt.Sprintf("%d minutes", minutes)
+
default:
+
return fmt.Sprintf("%d seconds", seconds)
+
}
+
}
+
+
// validateATURI validates that a string is a properly formatted AT-URI for a Bluesky post.
+
// AT-URIs for Bluesky posts must:
+
// - Start with "at://"
+
// - Contain "/app.bsky.feed.post/"
+
//
+
// Example valid URI: at://did:plc:abc123/app.bsky.feed.post/xyz789
+
func validateATURI(atURI string) error {
+
if !strings.HasPrefix(atURI, "at://") {
+
return fmt.Errorf("invalid AT-URI: must start with 'at://'")
+
}
+
+
if !strings.Contains(atURI, "/app.bsky.feed.post/") {
+
return fmt.Errorf("invalid AT-URI: must contain '/app.bsky.feed.post/'")
+
}
+
+
return nil
+
}
+48
internal/core/blueskypost/repository_test.go
···
+
package blueskypost
+
+
import (
+
"testing"
+
)
+
+
func TestValidateATURI(t *testing.T) {
+
tests := []struct {
+
name string
+
atURI string
+
wantErr bool
+
}{
+
{
+
name: "valid AT-URI",
+
atURI: "at://did:plc:abc123/app.bsky.feed.post/xyz789",
+
wantErr: false,
+
},
+
{
+
name: "missing at:// prefix",
+
atURI: "did:plc:abc123/app.bsky.feed.post/xyz789",
+
wantErr: true,
+
},
+
{
+
name: "missing /app.bsky.feed.post/",
+
atURI: "at://did:plc:abc123/some.other.collection/xyz789",
+
wantErr: true,
+
},
+
{
+
name: "empty string",
+
atURI: "",
+
wantErr: true,
+
},
+
{
+
name: "http URL instead of AT-URI",
+
atURI: "https://bsky.app/profile/user.bsky.social/post/abc123",
+
wantErr: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
err := validateATURI(tt.atURI)
+
if (err != nil) != tt.wantErr {
+
t.Errorf("validateATURI() error = %v, wantErr %v", err, tt.wantErr)
+
}
+
})
+
}
+
}
+101
internal/core/blueskypost/url_parser.go
···
+
package blueskypost
+
+
import (
+
"Coves/internal/atproto/identity"
+
"context"
+
"fmt"
+
"net/url"
+
"regexp"
+
"strings"
+
)
+
+
// blueskyPostURLPattern matches https://bsky.app/profile/{handle}/post/{rkey}
+
var blueskyPostURLPattern = regexp.MustCompile(`^https://bsky\.app/profile/([^/]+)/post/([^/]+)$`)
+
+
// IsBlueskyURL checks if a URL is a valid bsky.app post URL.
+
// Returns true for URLs matching https://bsky.app/profile/{handle}/post/{rkey}
+
func IsBlueskyURL(urlStr string) bool {
+
return blueskyPostURLPattern.MatchString(urlStr)
+
}
+
+
// ParseBlueskyURL converts a bsky.app URL to an AT-URI.
+
// Example: https://bsky.app/profile/user.bsky.social/post/abc123
+
//
+
// -> at://did:plc:xxx/app.bsky.feed.post/abc123
+
//
+
// Returns error if the URL is invalid or handle resolution fails.
+
func ParseBlueskyURL(ctx context.Context, urlStr string, resolver identity.Resolver) (string, error) {
+
// Parse and validate the URL
+
parsedURL, err := url.Parse(urlStr)
+
if err != nil {
+
return "", fmt.Errorf("invalid URL: %w", err)
+
}
+
+
// Validate URL scheme and host
+
if parsedURL.Scheme != "https" {
+
return "", fmt.Errorf("URL must use HTTPS scheme")
+
}
+
if parsedURL.Host != "bsky.app" {
+
return "", fmt.Errorf("URL must be from bsky.app")
+
}
+
+
// Extract handle and rkey using regex
+
matches := blueskyPostURLPattern.FindStringSubmatch(urlStr)
+
if matches == nil || len(matches) != 3 {
+
return "", fmt.Errorf("invalid bsky.app URL format, expected: https://bsky.app/profile/{handle}/post/{rkey}")
+
}
+
+
handle := matches[1]
+
rkey := matches[2]
+
+
// Validate handle and rkey are not empty
+
if handle == "" || rkey == "" {
+
return "", fmt.Errorf("handle and rkey cannot be empty")
+
}
+
+
// Validate rkey format
+
// TID format: base32-sortable timestamp IDs are typically 13 characters
+
// Allow alphanumeric characters, reasonable length (3-20 chars to be permissive)
+
if err := validateRkey(rkey); err != nil {
+
return "", fmt.Errorf("invalid rkey: %w", err)
+
}
+
+
// Resolve handle to DID
+
// If the handle is already a DID (starts with "did:"), use it directly
+
var did string
+
if strings.HasPrefix(handle, "did:") {
+
did = handle
+
} else {
+
// Resolve handle to DID using identity resolver
+
resolvedDID, _, err := resolver.ResolveHandle(ctx, handle)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle %s: %w", handle, err)
+
}
+
did = resolvedDID
+
}
+
+
// Construct AT-URI
+
// Format: at://{did}/app.bsky.feed.post/{rkey}
+
atURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
+
+
return atURI, nil
+
}
+
+
// rkeyPattern matches valid rkey formats (alphanumeric, typically base32 TID format)
+
var rkeyPattern = regexp.MustCompile(`^[a-zA-Z0-9]+$`)
+
+
// validateRkey validates the rkey (record key) format.
+
// TIDs (Timestamp Identifiers) are typically 13 characters in base32-sortable format.
+
// We allow 3-20 characters to be permissive while preventing abuse.
+
func validateRkey(rkey string) error {
+
if len(rkey) < 3 {
+
return fmt.Errorf("rkey too short (minimum 3 characters)")
+
}
+
if len(rkey) > 20 {
+
return fmt.Errorf("rkey too long (maximum 20 characters)")
+
}
+
if !rkeyPattern.MatchString(rkey) {
+
return fmt.Errorf("rkey contains invalid characters (must be alphanumeric)")
+
}
+
return nil
+
}
+327
internal/core/blueskypost/url_parser_test.go
···
+
package blueskypost
+
+
import (
+
"Coves/internal/atproto/identity"
+
"context"
+
"errors"
+
"testing"
+
)
+
+
// mockIdentityResolver implements identity.Resolver for testing
+
type mockIdentityResolver struct {
+
handleToDID map[string]string
+
err error
+
}
+
+
func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) {
+
if m.err != nil {
+
return "", "", m.err
+
}
+
did, ok := m.handleToDID[handle]
+
if !ok {
+
return "", "", errors.New("handle not found")
+
}
+
return did, "", nil
+
}
+
+
func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) {
+
if m.err != nil {
+
return nil, m.err
+
}
+
did, _, err := m.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return nil, err
+
}
+
return &identity.Identity{
+
DID: did,
+
Handle: identifier,
+
}, nil
+
}
+
+
func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) {
+
return nil, errors.New("not implemented")
+
}
+
+
func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error {
+
return nil
+
}
+
+
func TestIsBlueskyURL(t *testing.T) {
+
tests := []struct {
+
name string
+
url string
+
expected bool
+
}{
+
{
+
name: "valid bsky.app URL with handle",
+
url: "https://bsky.app/profile/user.bsky.social/post/abc123xyz",
+
expected: true,
+
},
+
{
+
name: "valid bsky.app URL with DID",
+
url: "https://bsky.app/profile/did:plc:abc123/post/xyz789",
+
expected: true,
+
},
+
{
+
name: "valid bsky.app URL with alphanumeric rkey",
+
url: "https://bsky.app/profile/alice.example/post/3k2j4h5g6f7d8s9a",
+
expected: true,
+
},
+
{
+
name: "wrong domain",
+
url: "https://twitter.com/profile/user/post/abc123",
+
expected: false,
+
},
+
{
+
name: "wrong path format - missing post",
+
url: "https://bsky.app/profile/user.bsky.social/abc123",
+
expected: false,
+
},
+
{
+
name: "wrong path format - extra segments",
+
url: "https://bsky.app/profile/user.bsky.social/post/abc123/extra",
+
expected: false,
+
},
+
{
+
name: "missing handle",
+
url: "https://bsky.app/profile//post/abc123",
+
expected: false,
+
},
+
{
+
name: "missing rkey",
+
url: "https://bsky.app/profile/user.bsky.social/post/",
+
expected: false,
+
},
+
{
+
name: "empty string",
+
url: "",
+
expected: false,
+
},
+
{
+
name: "malformed URL",
+
url: "not-a-url",
+
expected: false,
+
},
+
{
+
name: "http instead of https",
+
url: "http://bsky.app/profile/user.bsky.social/post/abc123",
+
expected: false,
+
},
+
{
+
name: "AT-URI instead of bsky.app URL",
+
url: "at://did:plc:abc123/app.bsky.feed.post/xyz789",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := IsBlueskyURL(tt.url)
+
if result != tt.expected {
+
t.Errorf("IsBlueskyURL(%q) = %v, want %v", tt.url, result, tt.expected)
+
}
+
})
+
}
+
}
+
+
func TestParseBlueskyURL(t *testing.T) {
+
ctx := context.Background()
+
+
tests := []struct {
+
resolver *mockIdentityResolver
+
name string
+
url string
+
expectedURI string
+
errContains string
+
wantErr bool
+
}{
+
{
+
name: "valid URL with handle",
+
url: "https://bsky.app/profile/alice.bsky.social/post/abc123xyz",
+
resolver: &mockIdentityResolver{
+
handleToDID: map[string]string{
+
"alice.bsky.social": "did:plc:alice123",
+
},
+
},
+
expectedURI: "at://did:plc:alice123/app.bsky.feed.post/abc123xyz",
+
wantErr: false,
+
},
+
{
+
name: "valid URL with DID (no resolution needed)",
+
url: "https://bsky.app/profile/did:plc:bob456/post/xyz789",
+
resolver: &mockIdentityResolver{
+
handleToDID: map[string]string{},
+
},
+
expectedURI: "at://did:plc:bob456/app.bsky.feed.post/xyz789",
+
wantErr: false,
+
},
+
{
+
name: "handle resolution fails",
+
url: "https://bsky.app/profile/unknown.bsky.social/post/abc123",
+
resolver: &mockIdentityResolver{
+
handleToDID: map[string]string{},
+
},
+
wantErr: true,
+
errContains: "failed to resolve handle",
+
},
+
{
+
name: "resolver returns error",
+
url: "https://bsky.app/profile/error.bsky.social/post/abc123",
+
resolver: &mockIdentityResolver{
+
err: errors.New("network error"),
+
},
+
wantErr: true,
+
errContains: "failed to resolve handle",
+
},
+
{
+
name: "invalid URL - wrong scheme",
+
url: "http://bsky.app/profile/alice.bsky.social/post/abc123",
+
resolver: &mockIdentityResolver{},
+
wantErr: true,
+
errContains: "must use HTTPS scheme",
+
},
+
{
+
name: "invalid URL - wrong host",
+
url: "https://twitter.com/profile/alice/post/abc123",
+
resolver: &mockIdentityResolver{},
+
wantErr: true,
+
errContains: "must be from bsky.app",
+
},
+
{
+
name: "invalid URL - wrong path format",
+
url: "https://bsky.app/feed/alice.bsky.social/post/abc123",
+
resolver: &mockIdentityResolver{},
+
wantErr: true,
+
errContains: "invalid bsky.app URL format",
+
},
+
{
+
name: "invalid URL - missing rkey",
+
url: "https://bsky.app/profile/alice.bsky.social/post/",
+
resolver: &mockIdentityResolver{},
+
wantErr: true,
+
errContains: "invalid bsky.app URL format",
+
},
+
{
+
name: "invalid URL - empty string",
+
url: "",
+
resolver: &mockIdentityResolver{},
+
wantErr: true,
+
errContains: "HTTPS scheme",
+
},
+
{
+
name: "malformed URL",
+
url: "not-a-valid-url",
+
resolver: &mockIdentityResolver{},
+
wantErr: true,
+
errContains: "HTTPS scheme",
+
},
+
{
+
name: "valid URL with complex handle",
+
url: "https://bsky.app/profile/user.subdomain.example.com/post/3k2j4h5g",
+
resolver: &mockIdentityResolver{
+
handleToDID: map[string]string{
+
"user.subdomain.example.com": "did:plc:complex789",
+
},
+
},
+
expectedURI: "at://did:plc:complex789/app.bsky.feed.post/3k2j4h5g",
+
wantErr: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, err := ParseBlueskyURL(ctx, tt.url, tt.resolver)
+
+
if tt.wantErr {
+
if err == nil {
+
t.Errorf("ParseBlueskyURL() expected error containing %q, got nil", tt.errContains)
+
return
+
}
+
if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
+
t.Errorf("ParseBlueskyURL() error = %q, expected to contain %q", err.Error(), tt.errContains)
+
}
+
return
+
}
+
+
if err != nil {
+
t.Errorf("ParseBlueskyURL() unexpected error: %v", err)
+
return
+
}
+
+
if result != tt.expectedURI {
+
t.Errorf("ParseBlueskyURL() = %q, want %q", result, tt.expectedURI)
+
}
+
})
+
}
+
}
+
+
func TestParseBlueskyURL_EdgeCases(t *testing.T) {
+
ctx := context.Background()
+
+
t.Run("rkey with special characters should still match pattern", func(t *testing.T) {
+
// Bluesky rkeys are base32-like and should be alphanumeric
+
// but let's test that our regex handles them
+
resolver := &mockIdentityResolver{
+
handleToDID: map[string]string{
+
"alice.bsky.social": "did:plc:alice123",
+
},
+
}
+
+
url := "https://bsky.app/profile/alice.bsky.social/post/3km3l4n5m6k7j8h9"
+
result, err := ParseBlueskyURL(ctx, url, resolver)
+
if err != nil {
+
t.Errorf("ParseBlueskyURL() with alphanumeric rkey failed: %v", err)
+
}
+
+
expected := "at://did:plc:alice123/app.bsky.feed.post/3km3l4n5m6k7j8h9"
+
if result != expected {
+
t.Errorf("ParseBlueskyURL() = %q, want %q", result, expected)
+
}
+
})
+
+
t.Run("handle that looks like DID should not be resolved", func(t *testing.T) {
+
// If handle starts with "did:", treat it as DID
+
resolver := &mockIdentityResolver{
+
handleToDID: map[string]string{
+
// Empty map - should not be called
+
},
+
}
+
+
url := "https://bsky.app/profile/did:plc:direct123/post/abc123"
+
result, err := ParseBlueskyURL(ctx, url, resolver)
+
if err != nil {
+
t.Errorf("ParseBlueskyURL() with DID should not need resolution: %v", err)
+
}
+
+
expected := "at://did:plc:direct123/app.bsky.feed.post/abc123"
+
if result != expected {
+
t.Errorf("ParseBlueskyURL() = %q, want %q", result, expected)
+
}
+
})
+
+
t.Run("empty handle after split should fail", func(t *testing.T) {
+
// This is caught by the regex, but testing defensive validation
+
resolver := &mockIdentityResolver{}
+
url := "https://bsky.app/profile//post/abc123"
+
+
_, err := ParseBlueskyURL(ctx, url, resolver)
+
if err == nil {
+
t.Error("ParseBlueskyURL() with empty handle should fail")
+
}
+
})
+
}
+
+
// Helper function to check if a string contains a substring
+
func contains(s, substr string) bool {
+
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+
(len(s) > 0 && len(substr) > 0 && containsHelper(s, substr)))
+
}
+
+
func containsHelper(s, substr string) bool {
+
for i := 0; i <= len(s)-len(substr); i++ {
+
if s[i:i+len(substr)] == substr {
+
return true
+
}
+
}
+
return false
+
}
+93
internal/core/posts/blob_transform.go
···
package posts
import (
+
"context"
+
"errors"
"fmt"
+
"log"
+
"strings"
+
+
"Coves/internal/core/blueskypost"
)
// TransformBlobRefsToURLs transforms all blob references in a PostView to PDS URLs
···
// Replace blob ref with URL string
external["thumb"] = blobURL
}
+
+
// TransformPostEmbeds enriches post embeds with resolved Bluesky post data
+
// This modifies the Embed field in-place, adding a "resolved" field with BlueskyPostResult
+
// Only processes social.coves.embed.post embeds with app.bsky.feed.post URIs
+
func TransformPostEmbeds(ctx context.Context, postView *PostView, blueskyService blueskypost.Service) {
+
if postView == nil || postView.Embed == nil || blueskyService == nil {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: postView nil=%v, embed nil=%v, blueskyService nil=%v",
+
postView == nil, postView == nil || postView.Embed == nil, blueskyService == nil)
+
return
+
}
+
+
// Check if embed is a map (should be for post embeds)
+
embedMap, ok := postView.Embed.(map[string]interface{})
+
if !ok {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: embed is not a map (type: %T)", postView.Embed)
+
return
+
}
+
+
// Check embed type
+
embedType, ok := embedMap["$type"].(string)
+
if !ok || embedType != "social.coves.embed.post" {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: embed type is not social.coves.embed.post (type: %v)", embedType)
+
return
+
}
+
+
// Extract the post reference
+
postRef, ok := embedMap["post"].(map[string]interface{})
+
if !ok {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: post reference is not a map")
+
return
+
}
+
+
// Get the AT-URI from the post reference
+
atURI, ok := postRef["uri"].(string)
+
if !ok || atURI == "" {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: AT-URI is missing or not a string")
+
return
+
}
+
+
// Only process app.bsky.feed.post URIs (Bluesky posts)
+
// Format: at://did:plc:xxx/app.bsky.feed.post/abc123
+
if len(atURI) < 20 || atURI[:5] != "at://" {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: invalid AT-URI format: %s", atURI)
+
return
+
}
+
+
// Simple check for app.bsky.feed.post collection
+
// We don't want to process other types of embeds (e.g., Coves posts)
+
if !strings.Contains(atURI, "/app.bsky.feed.post/") {
+
log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: not a Bluesky post (URI: %s)", atURI)
+
return
+
}
+
+
// Resolve the Bluesky post
+
result, err := blueskyService.ResolvePost(ctx, atURI)
+
if err != nil {
+
// Log the error but don't fail - set unavailable instead
+
log.Printf("[TRANSFORM-EMBED] Failed to resolve Bluesky post %s: %v", atURI, err)
+
+
// Differentiate between temporary and permanent failures using typed errors
+
errorMessage := "This Bluesky post is unavailable"
+
retryable := false
+
+
// Check if it's a circuit breaker error (temporary/retryable)
+
if errors.Is(err, blueskypost.ErrCircuitOpen) {
+
errorMessage = "Bluesky is temporarily unavailable, please try again later"
+
retryable = true
+
} else if errors.Is(err, context.DeadlineExceeded) {
+
errorMessage = "Failed to load Bluesky post, please try again"
+
retryable = true
+
} else if strings.Contains(err.Error(), "timeout") ||
+
strings.Contains(err.Error(), "temporary failure") {
+
errorMessage = "Failed to load Bluesky post, please try again"
+
retryable = true
+
}
+
+
embedMap["resolved"] = map[string]interface{}{
+
"unavailable": true,
+
"message": errorMessage,
+
"retryable": retryable,
+
}
+
return
+
}
+
+
// Add resolved data to embed
+
embedMap["resolved"] = result
+
}
+18
internal/db/migrations/023_create_bluesky_post_cache.sql
···
+
-- +goose Up
+
CREATE TABLE bluesky_post_cache (
+
at_uri TEXT PRIMARY KEY,
+
metadata JSONB NOT NULL,
+
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
expires_at TIMESTAMPTZ NOT NULL
+
);
+
+
CREATE INDEX idx_bluesky_post_cache_expires ON bluesky_post_cache(expires_at);
+
+
COMMENT ON TABLE bluesky_post_cache IS 'Cache for Bluesky post data fetched from public.api.bsky.app';
+
COMMENT ON COLUMN bluesky_post_cache.at_uri IS 'AT-URI of the Bluesky post (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123)';
+
COMMENT ON COLUMN bluesky_post_cache.metadata IS 'Full BlueskyPostResult as JSON (text, author, stats, etc.)';
+
COMMENT ON COLUMN bluesky_post_cache.expires_at IS 'When this cache entry should be refetched (shorter TTL than unfurl since posts can be edited/deleted)';
+
+
-- +goose Down
+
DROP INDEX IF EXISTS idx_bluesky_post_cache_expires;
+
DROP TABLE IF EXISTS bluesky_post_cache;
-9
.beads/beads.base.jsonl
···
-
{"id":"Coves-8b1","content_hash":"a949ba526ad819badab625c0d5fdbc6a7994d22f059f4a4f7e68635750bd5ea3","title":"Apply functional options pattern to NewGetDiscoverHandler","description":"Location: internal/api/handlers/discover/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.315877238-08:00","updated_at":"2025-12-22T21:35:58.061823373-08:00","source_repo":"."}
-
{"id":"Coves-8k1","content_hash":"a10053af68636b722a86aa75dd483ece4509d0de4884230beb52453585895589","title":"Refactor service constructors to use functional options pattern","description":"Multiple service constructors have grown to accept many optional dependencies, leading to hard-to-read nil chains:\n```go\nposts.NewPostService(repo, communityService, nil, nil, nil, nil, \"http://localhost:3001\")\n```\n\nApply the functional options pattern to all affected constructors:\n- NewPostService (7 params, 4 optional)\n- NewGetDiscoverHandler (3 params, 2 optional)\n- NewGetCommunityHandler (3 params, 2 optional)\n- NewGetTimelineHandler (3 params, 2 optional)\n- RegisterTimelineRoutes (5 params, 2 optional)\n\nThis will improve readability, make tests self-documenting, and prevent breakage when adding new optional params.\n\nScope: ~20 files, ~50 call sites\nRisk: Low (purely mechanical, no logic changes)","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:35:19.91257167-08:00","updated_at":"2025-12-22T21:35:39.69736147-08:00","source_repo":"."}
-
{"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]}
-
{"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."}
-
{"id":"Coves-f9q","content_hash":"a1a38759edc37d11227d5992cdbed1b8cf27e09496165e45c542b208f58d34ce","title":"Apply functional options pattern to NewGetTimelineHandler and RegisterTimelineRoutes","description":"Locations:\n- internal/api/handlers/timeline/get.go (NewGetTimelineHandler)\n- internal/api/routes/timeline.go (RegisterTimelineRoutes)\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nUpdate RegisterTimelineRoutes last after handlers are refactored.\n\nDepends on: Coves-jdf, Coves-8b1, Coves-iw5\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.420117481-08:00","updated_at":"2025-12-22T21:35:58.166765845-08:00","source_repo":"."}
-
{"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."}
-
{"id":"Coves-iw5","content_hash":"d3379c617b7583f6b88a0523b3cdd1e4415176877ab00b48710819f2484c4856","title":"Apply functional options pattern to NewGetCommunityHandler","description":"Location: internal/api/handlers/communityFeed/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.369297201-08:00","updated_at":"2025-12-22T21:35:58.115771178-08:00","source_repo":"."}
-
{"id":"Coves-jdf","content_hash":"cb27689d71f44fd555e29d2988f2ad053efb6c565cd4f803ff68eaade59c7546","title":"Apply functional options pattern to NewPostService","description":"Location: internal/core/posts/service.go\n\nCurrent constructor (7 params, 4 optional):\n```go\nfunc NewPostService(repo Repository, communityService communities.Service, aggregatorService aggregators.Service, blobService blobs.Service, unfurlService unfurl.Service, blueskyService blueskypost.Service, pdsURL string) Service\n```\n\nRefactor to:\n```go\ntype Option func(*postService)\n\nfunc WithAggregatorService(svc aggregators.Service) Option\nfunc WithBlobService(svc blobs.Service) Option\nfunc WithUnfurlService(svc unfurl.Service) Option\nfunc WithBlueskyService(svc blueskypost.Service) Option\n\nfunc NewPostService(repo Repository, communityService communities.Service, pdsURL string, opts ...Option) Service\n```\n\nFiles to update:\n- internal/core/posts/service.go (define Option type and With* functions)\n- cmd/server/main.go (production caller)\n- ~15 test files with call sites\n\nStart with this one as it has the most params and is most impacted.\nParent: Coves-8k1","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-22T21:35:27.264325344-08:00","updated_at":"2025-12-22T21:35:58.003863381-08:00","source_repo":"."}
-
{"id":"Coves-p44","content_hash":"6f12091f6e5f1ad9812f8da4ecd720e0f9df1afd1fdb593b3e52c32be0193d94","title":"Bluesky embed conversion Phase 2: resolve post and populate CID","description":"When converting a Bluesky URL to a social.coves.embed.post, we need to:\n\n1. Call blueskyService.ResolvePost() to get the full post data including CID\n2. Populate both URI and CID in the strongRef\n3. Consider caching/re-using resolved post data for rendering\n\nCurrently disabled in Phase 1 (text-only) because:\n- social.coves.embed.post requires a valid CID in com.atproto.repo.strongRef\n- Empty CID causes PDS to reject the record creation\n\nRelated files:\n- internal/core/posts/service.go:tryConvertBlueskyURLToPostEmbed()\n- internal/atproto/lexicon/social/coves/embed/post.json\n\nThis is part of the Bluesky post cross-posting feature (images/embeds phase).","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:25:23.540135876-08:00","updated_at":"2025-12-22T21:25:41.704980685-08:00","source_repo":"."}
-1
.beads/beads.base.meta.json
···
-
{"version":"0.23.1","timestamp":"2025-12-22T21:37:18.747538079-08:00","commit":"6b49f88"}
+6
.beads/.gitignore
···
!*.jsonl
!metadata.json
!config.json
+
+
# Merge artifacts (created during conflict resolution)
+
beads.base.jsonl
+
beads.base.meta.json
+
beads.left.jsonl
+
beads.left.meta.json
+3 -1
internal/api/routes/post.go
···
// RegisterPostRoutes registers post-related XRPC endpoints on the router
// Implements social.coves.community.post.* lexicon endpoints
-
func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+
// authMiddleware can be either OAuthAuthMiddleware or DualAuthMiddleware
+
func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware middleware.AuthMiddleware) {
// Initialize handlers
createHandler := post.NewCreateHandler(service)
// Procedure endpoints (POST) - require authentication
// social.coves.community.post.create - create a new post in a community
+
// Supports both OAuth (users) and service JWT (aggregators) authentication
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate)
// Future endpoints (Beta):
+79
internal/core/blueskypost/fetcher_test.go
···
t.Error("Expected no quoted post with nil embeds")
}
}
+
+
func TestMapAPIPostToResult_ExternalEmbed(t *testing.T) {
+
// Test that external link embeds are correctly extracted
+
apiPost := &blueskyAPIPost{
+
URI: "at://did:plc:test/app.bsky.feed.post/test",
+
CID: "bafyreiabc123",
+
Author: blueskyAPIAuthor{
+
DID: "did:plc:test",
+
Handle: "english.lemonde.fr",
+
DisplayName: "Le Monde",
+
},
+
Record: blueskyAPIRecord{
+
Text: "Check out this article",
+
CreatedAt: "2025-12-21T10:30:00Z",
+
},
+
Embed: &blueskyAPIEmbed{
+
Type: "app.bsky.embed.external#view",
+
External: &blueskyAPIExternal{
+
URI: "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html",
+
Title: "NBA and Fiba announce search for teams",
+
Description: "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league.",
+
Thumb: "https://cdn.lemonde.fr/thumbnail.jpg",
+
},
+
},
+
ReplyCount: 10,
+
RepostCount: 5,
+
LikeCount: 100,
+
}
+
+
result := mapAPIPostToResult(apiPost)
+
+
// Verify basic fields
+
if result.URI != "at://did:plc:test/app.bsky.feed.post/test" {
+
t.Errorf("Expected URI 'at://did:plc:test/app.bsky.feed.post/test', got %s", result.URI)
+
}
+
if result.Author.Handle != "english.lemonde.fr" {
+
t.Errorf("Expected Handle 'english.lemonde.fr', got %s", result.Author.Handle)
+
}
+
+
// Verify external embed is extracted
+
if result.Embed == nil {
+
t.Fatal("Expected Embed to be set for external link post")
+
}
+
if result.Embed.URI != "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html" {
+
t.Errorf("Expected external URI, got %s", result.Embed.URI)
+
}
+
if result.Embed.Title != "NBA and Fiba announce search for teams" {
+
t.Errorf("Expected external title, got %s", result.Embed.Title)
+
}
+
if result.Embed.Description != "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league." {
+
t.Errorf("Expected external description, got %s", result.Embed.Description)
+
}
+
if result.Embed.Thumb != "https://cdn.lemonde.fr/thumbnail.jpg" {
+
t.Errorf("Expected external thumb, got %s", result.Embed.Thumb)
+
}
+
}
+
+
func TestMapAPIPostToResult_ExternalEmbedNil(t *testing.T) {
+
// Test that posts without external embeds don't have Embed set
+
apiPost := &blueskyAPIPost{
+
URI: "at://did:plc:test/app.bsky.feed.post/test",
+
CID: "bafyreiabc123",
+
Author: blueskyAPIAuthor{
+
DID: "did:plc:test",
+
Handle: "user.bsky.social",
+
},
+
Record: blueskyAPIRecord{
+
Text: "Just a regular post without links",
+
CreatedAt: "2025-12-21T10:30:00Z",
+
},
+
Embed: nil,
+
}
+
+
result := mapAPIPostToResult(apiPost)
+
+
if result.Embed != nil {
+
t.Errorf("Expected Embed to be nil for post without external embed, got %+v", result.Embed)
+
}
+
}
+24 -7
tests/integration/bluesky_post_test.go
···
"Coves/internal/atproto/identity"
"Coves/internal/core/blueskypost"
"context"
+
"database/sql"
"fmt"
"net/http"
"testing"
···
//
// Use this for tests that need to resolve real Bluesky handles like "ianboudreau.com".
// Do NOT use for tests involving local Coves identities (use local PLC instead).
-
func productionPLCIdentityResolver() identity.Resolver {
+
//
+
// NOTE: Requires a database connection for the identity cache. Pass the test db.
+
func productionPLCIdentityResolver(db *sql.DB) identity.Resolver {
config := identity.DefaultConfig()
config.PLCURL = "https://plc.directory" // Production PLC - READ ONLY
-
return identity.NewResolver(nil, config)
+
return identity.NewResolver(db, config)
}
// TestBlueskyPostCrossPosting_URLParsing tests URL detection and parsing
···
defer func() { _ = db.Close() }()
// Use production PLC resolver for real Bluesky handles (READ-ONLY)
-
identityResolver := productionPLCIdentityResolver()
+
identityResolver := productionPLCIdentityResolver(db)
// Setup Bluesky post service
repo := blueskypost.NewRepository(db)
···
_, _ = db.Exec("DELETE FROM bluesky_post_cache")
// Use production PLC resolver for real Bluesky handles (READ-ONLY)
-
identityResolver := productionPLCIdentityResolver()
+
identityResolver := productionPLCIdentityResolver(db)
repo := blueskypost.NewRepository(db)
service := blueskypost.NewService(repo, identityResolver,
···
assert.Equal(t, "davidpfau.com", result.Author.Handle)
assert.NotEmpty(t, result.Text)
+
// Verify external embed is extracted
+
if result.Embed != nil {
+
assert.NotEmpty(t, result.Embed.URI, "External embed should have URI")
+
t.Logf(" External embed URI: %s", result.Embed.URI)
+
if result.Embed.Title != "" {
+
t.Logf(" External embed title: %s", result.Embed.Title)
+
}
+
if result.Embed.Thumb != "" {
+
t.Logf(" External embed thumb: %s", result.Embed.Thumb)
+
}
+
} else {
+
t.Log(" Note: No external embed found (post may have been modified)")
+
}
+
t.Logf("โœ“ Successfully fetched post with link embed:")
t.Logf(" Author: @%s", result.Author.Handle)
t.Logf(" Text: %.80s...", result.Text)
···
defer func() { _ = db.Close() }()
// Use production PLC resolver for real Bluesky handles (READ-ONLY)
-
identityResolver := productionPLCIdentityResolver()
+
identityResolver := productionPLCIdentityResolver(db)
repo := blueskypost.NewRepository(db)
service := blueskypost.NewService(repo, identityResolver,
···
_, _ = db.Exec("DELETE FROM bluesky_post_cache WHERE at_uri LIKE 'at://did:plc:%'")
// Use production PLC resolver for real Bluesky handles (READ-ONLY)
-
identityResolver := productionPLCIdentityResolver()
+
identityResolver := productionPLCIdentityResolver(db)
repo := blueskypost.NewRepository(db)
service := blueskypost.NewService(repo, identityResolver,
···
_, _ = db.Exec("DELETE FROM bluesky_post_cache")
// Use production PLC resolver for real Bluesky handles (READ-ONLY)
-
identityResolver := productionPLCIdentityResolver()
+
identityResolver := productionPLCIdentityResolver(db)
// Setup Bluesky post service
repo := blueskypost.NewRepository(db)
+7 -5
internal/core/communities/token_refresh.go
···
// refreshPDSToken exchanges a refresh token for new access and refresh tokens
// Uses com.atproto.server.refreshSession endpoint via Indigo SDK
// CRITICAL: Refresh tokens are single-use - old refresh token is revoked on success
-
func refreshPDSToken(ctx context.Context, pdsURL, currentAccessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) {
+
func refreshPDSToken(ctx context.Context, pdsURL, refreshToken string) (newAccessToken, newRefreshToken string, err error) {
if pdsURL == "" {
return "", "", fmt.Errorf("PDS URL is required")
}
···
return "", "", fmt.Errorf("refresh token is required")
}
-
// Create XRPC client with auth credentials
-
// The refresh endpoint requires authentication with the refresh token
+
// Create XRPC client with refresh token as the auth credential
+
// IMPORTANT: The xrpc client always sends AccessJwt as the Authorization header,
+
// but refreshSession requires the refresh token in that header.
+
// So we put the refresh token in AccessJwt to make it work correctly.
client := &xrpc.Client{
Host: pdsURL,
Auth: &xrpc.AuthInfo{
-
AccessJwt: currentAccessToken, // Can be expired (not used for refresh auth)
-
RefreshJwt: refreshToken, // This is what authenticates the refresh request
+
AccessJwt: refreshToken, // Refresh token goes here (sent as Authorization header)
+
RefreshJwt: refreshToken, // Also set here for completeness
},
}
+63
internal/atproto/lexicon/social/coves/aggregator/createApiKey.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.createApiKey",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create an API key for the authenticated aggregator. Requires OAuth authentication. The API key is returned ONCE and cannot be retrieved again. Store it securely.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"description": "No input required. The key is generated server-side for the authenticated aggregator.",
+
"properties": {}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["key", "keyPrefix", "did", "createdAt"],
+
"properties": {
+
"key": {
+
"type": "string",
+
"description": "The plain-text API key. This is shown ONCE and cannot be retrieved again. Format: ckapi_<64-hex-chars> (32 bytes hex-encoded)"
+
},
+
"keyPrefix": {
+
"type": "string",
+
"description": "First 12 characters of the key (e.g., 'ckapi_ab12cd') for identification in logs and UI"
+
},
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator that owns this key"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "ISO8601 timestamp when the key was created"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "AuthenticationRequired",
+
"description": "OAuth authentication is required to create an API key"
+
},
+
{
+
"name": "OAuthSessionRequired",
+
"description": "OAuth session is required (not service JWT) to create an API key"
+
},
+
{
+
"name": "AggregatorRequired",
+
"description": "Only registered aggregators can create API keys"
+
},
+
{
+
"name": "KeyGenerationFailed",
+
"description": "Failed to generate the API key"
+
}
+
]
+
}
+
}
+
}
+30
internal/atproto/lexicon/social/coves/aggregator/defs.json
···
"format": "at-uri"
}
}
+
},
+
"apiKeyView": {
+
"type": "object",
+
"description": "View of an API key's metadata. The actual key value is never returned after initial creation.",
+
"required": ["prefix", "createdAt", "isRevoked"],
+
"properties": {
+
"prefix": {
+
"type": "string",
+
"description": "First 12 characters of the key (e.g., 'ckapi_ab12cd') for identification in logs and UI"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the key was created"
+
},
+
"lastUsedAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the key was last used for authentication"
+
},
+
"isRevoked": {
+
"type": "boolean",
+
"description": "Whether the key has been revoked"
+
},
+
"revokedAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the key was revoked"
+
}
+
}
}
}
}
+47
internal/atproto/lexicon/social/coves/aggregator/getApiKey.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.getApiKey",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get information about the authenticated aggregator's API key. Note: The actual key value is NEVER returned - only metadata about the key.",
+
"parameters": {
+
"type": "params",
+
"description": "No parameters required. Returns key info for the authenticated aggregator.",
+
"properties": {}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["hasKey"],
+
"properties": {
+
"hasKey": {
+
"type": "boolean",
+
"description": "Whether the aggregator has an API key (active or revoked)"
+
},
+
"keyInfo": {
+
"type": "ref",
+
"ref": "social.coves.aggregator.defs#apiKeyView",
+
"description": "API key metadata. Only present if hasKey is true."
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "AuthenticationRequired",
+
"description": "Authentication is required to get API key info"
+
},
+
{
+
"name": "AggregatorRequired",
+
"description": "Only registered aggregators can get API key info"
+
},
+
{
+
"name": "AggregatorNotFound",
+
"description": "Aggregator not found"
+
}
+
]
+
}
+
}
+
}
+58
internal/atproto/lexicon/social/coves/aggregator/revokeApiKey.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.revokeApiKey",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Revoke the authenticated aggregator's API key. After revocation, the aggregator must complete OAuth flow again to create a new API key. This action cannot be undone.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"description": "No input required. Revokes the key for the authenticated aggregator.",
+
"properties": {}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["revokedAt"],
+
"properties": {
+
"revokedAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "ISO8601 timestamp when the key was revoked"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "AuthenticationRequired",
+
"description": "Authentication is required to revoke an API key"
+
},
+
{
+
"name": "AggregatorRequired",
+
"description": "Only registered aggregators can revoke API keys"
+
},
+
{
+
"name": "AggregatorNotFound",
+
"description": "Aggregator not found"
+
},
+
{
+
"name": "ApiKeyNotFound",
+
"description": "No API key exists to revoke"
+
},
+
{
+
"name": "ApiKeyAlreadyRevoked",
+
"description": "API key has already been revoked"
+
},
+
{
+
"name": "RevocationFailed",
+
"description": "Failed to revoke the API key"
+
}
+
]
+
}
+
}
+
}
+77
internal/db/migrations/024_add_aggregator_api_keys.sql
···
+
-- +goose Up
+
-- Add API key authentication and OAuth credential storage for aggregators
+
-- This enables aggregators to authenticate using API keys backed by OAuth sessions
+
+
-- ============================================================================
+
-- Add API key columns to aggregators table
+
-- ============================================================================
+
ALTER TABLE aggregators
+
-- API key identification (prefix for log correlation, hash for auth)
+
ADD COLUMN api_key_prefix VARCHAR(12),
+
ADD COLUMN api_key_hash VARCHAR(64) UNIQUE,
+
+
-- OAuth credentials (encrypted at application layer before storage)
+
-- SECURITY: These columns contain sensitive OAuth tokens
+
ADD COLUMN oauth_access_token TEXT,
+
ADD COLUMN oauth_refresh_token TEXT,
+
ADD COLUMN oauth_token_expires_at TIMESTAMPTZ,
+
+
-- OAuth session metadata for token refresh
+
ADD COLUMN oauth_pds_url TEXT,
+
ADD COLUMN oauth_auth_server_iss TEXT,
+
ADD COLUMN oauth_auth_server_token_endpoint TEXT,
+
+
-- DPoP keys and nonces for token refresh (multibase encoded)
+
-- SECURITY: Contains private key material
+
ADD COLUMN oauth_dpop_private_key_multibase TEXT,
+
ADD COLUMN oauth_dpop_authserver_nonce TEXT,
+
ADD COLUMN oauth_dpop_pds_nonce TEXT,
+
+
-- API key lifecycle timestamps
+
ADD COLUMN api_key_created_at TIMESTAMPTZ,
+
ADD COLUMN api_key_revoked_at TIMESTAMPTZ,
+
ADD COLUMN api_key_last_used_at TIMESTAMPTZ;
+
+
-- Index for API key lookup during authentication
+
-- Partial index excludes NULL values since not all aggregators have API keys
+
CREATE INDEX idx_aggregators_api_key_hash
+
ON aggregators(api_key_hash)
+
WHERE api_key_hash IS NOT NULL;
+
+
-- ============================================================================
+
-- Security comments on sensitive columns
+
-- ============================================================================
+
COMMENT ON COLUMN aggregators.api_key_prefix IS 'First 12 characters of API key for identification in logs (not secret)';
+
COMMENT ON COLUMN aggregators.api_key_hash IS 'SHA-256 hash of full API key for authentication lookup';
+
COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: Encrypted OAuth access token for PDS operations';
+
COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: Encrypted OAuth refresh token for session renewal';
+
COMMENT ON COLUMN aggregators.oauth_token_expires_at IS 'When the OAuth access token expires (triggers refresh)';
+
COMMENT ON COLUMN aggregators.oauth_pds_url IS 'PDS URL for this aggregators OAuth session';
+
COMMENT ON COLUMN aggregators.oauth_auth_server_iss IS 'OAuth authorization server issuer URL';
+
COMMENT ON COLUMN aggregators.oauth_auth_server_token_endpoint IS 'OAuth token refresh endpoint URL';
+
COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh';
+
COMMENT ON COLUMN aggregators.oauth_dpop_authserver_nonce IS 'Latest DPoP nonce from authorization server';
+
COMMENT ON COLUMN aggregators.oauth_dpop_pds_nonce IS 'Latest DPoP nonce from PDS';
+
COMMENT ON COLUMN aggregators.api_key_created_at IS 'When the API key was generated';
+
COMMENT ON COLUMN aggregators.api_key_revoked_at IS 'When the API key was revoked (NULL = active)';
+
COMMENT ON COLUMN aggregators.api_key_last_used_at IS 'Last successful authentication using this API key';
+
+
-- +goose Down
+
-- Remove API key columns from aggregators table
+
DROP INDEX IF EXISTS idx_aggregators_api_key_hash;
+
+
ALTER TABLE aggregators
+
DROP COLUMN IF EXISTS api_key_prefix,
+
DROP COLUMN IF EXISTS api_key_hash,
+
DROP COLUMN IF EXISTS oauth_access_token,
+
DROP COLUMN IF EXISTS oauth_refresh_token,
+
DROP COLUMN IF EXISTS oauth_token_expires_at,
+
DROP COLUMN IF EXISTS oauth_pds_url,
+
DROP COLUMN IF EXISTS oauth_auth_server_iss,
+
DROP COLUMN IF EXISTS oauth_auth_server_token_endpoint,
+
DROP COLUMN IF EXISTS oauth_dpop_private_key_multibase,
+
DROP COLUMN IF EXISTS oauth_dpop_authserver_nonce,
+
DROP COLUMN IF EXISTS oauth_dpop_pds_nonce,
+
DROP COLUMN IF EXISTS api_key_created_at,
+
DROP COLUMN IF EXISTS api_key_revoked_at,
+
DROP COLUMN IF EXISTS api_key_last_used_at;
+92
internal/db/migrations/025_encrypt_aggregator_oauth_tokens.sql
···
+
-- +goose Up
+
-- Encrypt aggregator OAuth tokens at rest using pgp_sym_encrypt
+
-- This addresses the security issue where OAuth tokens were stored in plaintext
+
-- despite migration 024 claiming "encrypted at application layer before storage"
+
+
-- +goose StatementBegin
+
+
-- Step 1: Add new encrypted columns for OAuth tokens and DPoP private key
+
ALTER TABLE aggregators
+
ADD COLUMN oauth_access_token_encrypted BYTEA,
+
ADD COLUMN oauth_refresh_token_encrypted BYTEA,
+
ADD COLUMN oauth_dpop_private_key_encrypted BYTEA;
+
+
-- Step 2: Migrate existing plaintext data to encrypted columns
+
-- Uses the same encryption key table as community credentials (migration 006)
+
UPDATE aggregators
+
SET
+
oauth_access_token_encrypted = CASE
+
WHEN oauth_access_token IS NOT NULL AND oauth_access_token != ''
+
THEN pgp_sym_encrypt(oauth_access_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
+
ELSE NULL
+
END,
+
oauth_refresh_token_encrypted = CASE
+
WHEN oauth_refresh_token IS NOT NULL AND oauth_refresh_token != ''
+
THEN pgp_sym_encrypt(oauth_refresh_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
+
ELSE NULL
+
END,
+
oauth_dpop_private_key_encrypted = CASE
+
WHEN oauth_dpop_private_key_multibase IS NOT NULL AND oauth_dpop_private_key_multibase != ''
+
THEN pgp_sym_encrypt(oauth_dpop_private_key_multibase, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
+
ELSE NULL
+
END
+
WHERE oauth_access_token IS NOT NULL
+
OR oauth_refresh_token IS NOT NULL
+
OR oauth_dpop_private_key_multibase IS NOT NULL;
+
+
-- Step 3: Drop the old plaintext columns
+
ALTER TABLE aggregators
+
DROP COLUMN oauth_access_token,
+
DROP COLUMN oauth_refresh_token,
+
DROP COLUMN oauth_dpop_private_key_multibase;
+
+
-- Step 4: Add security comments
+
COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: Encrypted OAuth access token (pgp_sym_encrypt) for PDS operations';
+
COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: Encrypted OAuth refresh token (pgp_sym_encrypt) for session renewal';
+
COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: Encrypted DPoP private key (pgp_sym_encrypt) for token refresh';
+
+
-- +goose StatementEnd
+
+
-- +goose Down
+
-- +goose StatementBegin
+
+
-- Restore plaintext columns
+
ALTER TABLE aggregators
+
ADD COLUMN oauth_access_token TEXT,
+
ADD COLUMN oauth_refresh_token TEXT,
+
ADD COLUMN oauth_dpop_private_key_multibase TEXT;
+
+
-- Decrypt data back to plaintext (for rollback)
+
UPDATE aggregators
+
SET
+
oauth_access_token = CASE
+
WHEN oauth_access_token_encrypted IS NOT NULL
+
THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
+
ELSE NULL
+
END,
+
oauth_refresh_token = CASE
+
WHEN oauth_refresh_token_encrypted IS NOT NULL
+
THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
+
ELSE NULL
+
END,
+
oauth_dpop_private_key_multibase = CASE
+
WHEN oauth_dpop_private_key_encrypted IS NOT NULL
+
THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))
+
ELSE NULL
+
END
+
WHERE oauth_access_token_encrypted IS NOT NULL
+
OR oauth_refresh_token_encrypted IS NOT NULL
+
OR oauth_dpop_private_key_encrypted IS NOT NULL;
+
+
-- Drop encrypted columns
+
ALTER TABLE aggregators
+
DROP COLUMN oauth_access_token_encrypted,
+
DROP COLUMN oauth_refresh_token_encrypted,
+
DROP COLUMN oauth_dpop_private_key_encrypted;
+
+
-- Restore comments
+
COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: OAuth access token for PDS operations';
+
COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: OAuth refresh token for session renewal';
+
COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh';
+
+
-- +goose StatementEnd
+42
internal/api/handlers/aggregator/metrics.go
···
+
package aggregator
+
+
import (
+
"net/http"
+
+
"Coves/internal/core/aggregators"
+
)
+
+
// MetricsHandler provides API key service metrics for monitoring
+
type MetricsHandler struct {
+
apiKeyService aggregators.APIKeyServiceInterface
+
}
+
+
// NewMetricsHandler creates a new metrics handler
+
func NewMetricsHandler(apiKeyService aggregators.APIKeyServiceInterface) *MetricsHandler {
+
return &MetricsHandler{
+
apiKeyService: apiKeyService,
+
}
+
}
+
+
// MetricsResponse contains API key service operational metrics
+
type MetricsResponse struct {
+
FailedLastUsedUpdates int64 `json:"failedLastUsedUpdates"`
+
FailedNonceUpdates int64 `json:"failedNonceUpdates"`
+
}
+
+
// HandleMetrics handles GET /xrpc/social.coves.aggregator.getMetrics
+
// Returns operational metrics for the API key service.
+
// This endpoint is intended for internal monitoring and health checks.
+
func (h *MetricsHandler) HandleMetrics(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
response := MetricsResponse{
+
FailedLastUsedUpdates: h.apiKeyService.GetFailedLastUsedUpdates(),
+
FailedNonceUpdates: h.apiKeyService.GetFailedNonceUpdates(),
+
}
+
+
writeJSONResponse(w, http.StatusOK, response)
+
}
+7 -1
internal/api/routes/aggregator.go
···
func RegisterAggregatorAPIKeyRoutes(
r chi.Router,
authMiddleware middleware.AuthMiddleware,
-
apiKeyService *aggregators.APIKeyService,
+
apiKeyService aggregators.APIKeyServiceInterface,
aggregatorService aggregators.Service,
) {
// Create API key handlers
createAPIKeyHandler := aggregator.NewCreateAPIKeyHandler(apiKeyService, aggregatorService)
getAPIKeyHandler := aggregator.NewGetAPIKeyHandler(apiKeyService, aggregatorService)
revokeAPIKeyHandler := aggregator.NewRevokeAPIKeyHandler(apiKeyService, aggregatorService)
+
metricsHandler := aggregator.NewMetricsHandler(apiKeyService)
// API key management endpoints (require OAuth authentication)
// POST /xrpc/social.coves.aggregator.createApiKey
···
// Revokes the authenticated aggregator's API key
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.aggregator.revokeApiKey",
revokeAPIKeyHandler.HandleRevokeAPIKey)
+
+
// GET /xrpc/social.coves.aggregator.getMetrics
+
// Returns operational metrics for the API key service (internal monitoring endpoint)
+
// No authentication required - metrics are non-sensitive operational data
+
r.Get("/xrpc/social.coves.aggregator.getMetrics", metricsHandler.HandleMetrics)
}
+2 -3
aggregators/kagi-news/.env.example
···
-
# Aggregator Identity (pre-created account credentials)
-
AGGREGATOR_HANDLE=kagi-news.local.coves.dev
-
AGGREGATOR_PASSWORD=your-secure-password-here
+
# Coves API Key (get from https://coves.social after OAuth login)
+
COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Optional: Override Coves API URL (defaults to config.yaml)
# COVES_API_URL=http://localhost:3001
-1
aggregators/kagi-news/requirements.txt
···
feedparser==6.0.11
beautifulsoup4==4.12.3
requests==2.31.0
-
atproto==0.0.55
pyyaml==6.0.1
# Testing
+24 -15
aggregators/kagi-news/README.md
···
Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves.
+
### Handle Options
+
+
You have two choices:
+
+
1. **PDS-assigned handle** (simpler): Use `my-aggregator.bsky.social`. No domain verification needed.
+
2. **Custom domain** (branded): Use `news.example.com`. Requires hosting a `.well-known/atproto-did` file.
+
### Quick Setup (Automated)
The automated setup script handles the entire registration process:
···
This will:
1. **Create a PDS account** for your aggregator (generates a DID)
-
2. **Generate `.well-known/atproto-did`** file for domain verification
-
3. **Pause for manual upload** - you'll upload the file to your web server
-
4. **Register with Coves** instance via XRPC
-
5. **Create service declaration** record (indexed by Jetstream)
+
2. **(Optional)** Generate `.well-known/atproto-did` file for custom domain handle
+
3. **(Optional)** Pause for manual upload if using custom domain
+
4. **Create service declaration** record (indexed by Jetstream)
+
5. **Generate an API key** for authentication (requires browser OAuth)
-
**Manual step required:** During the process, you'll need to upload the `.well-known/atproto-did` file to your domain so it's accessible at `https://yourdomain.com/.well-known/atproto-did`.
+
**Manual steps required:**
+
- **(If using custom domain)** Upload `.well-known/atproto-did` to your domain
+
- Complete OAuth login in browser to generate API key
-
After completion, you'll have a `kagi-aggregator-config.env` file with:
-
- Aggregator DID and credentials
-
- Access/refresh JWTs
-
- Service declaration URI
+
After completion, you'll have:
+
- `kagi-aggregator-config.env` - Full configuration with API key
+
- `COVES_API_KEY` - Your authentication token for posting
-
**Keep this file secure!** It contains your aggregator's credentials.
+
**Keep the API key secure!** It cannot be retrieved after generation.
### Manual Setup (Step-by-step)
···
# From the Coves project root
cd scripts/aggregator-setup
-
# Follow the 4-step process
+
# Follow the 5-step process
./1-create-pds-account.sh
./2-setup-wellknown.sh
./3-register-with-coves.sh
./4-create-service-declaration.sh
+
./5-create-api-key.sh
```
See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step.
···
2. **Domain Verification**: Proves you control your aggregator's domain
3. **Coves Registration**: Inserts your DID into the Coves instance's `users` table
4. **Service Declaration**: Creates a record that gets indexed into the `aggregators` table
-
5. **Ready for Authorization**: Community moderators can now authorize your aggregator
+
5. **API Key Generation**: Creates a secure API key for authentication
+
6. **Ready for Authorization**: Community moderators can now authorize your aggregator
Once registered and authorized by a community, your aggregator can post content.
···
```
4. Edit `config.yaml` to map RSS feeds to communities
-
5. Set environment variables in `.env` (aggregator DID and private key)
+
5. Set `COVES_API_KEY` in `.env` (from registration step 5)
## Running Tests
···
The `docker-compose.yml` file supports these environment variables:
-
- **`AGGREGATOR_HANDLE`** (required): Your aggregator's handle
-
- **`AGGREGATOR_PASSWORD`** (required): Your aggregator's password
+
- **`COVES_API_KEY`** (required): Your aggregator's API key (format: `ckapi_...`)
- **`COVES_API_URL`** (optional): Override Coves API endpoint (defaults to `https://api.coves.social`)
- **`RUN_ON_STARTUP`** (optional): Set to `true` to run immediately on container start (useful for testing)
+148
scripts/aggregator-setup/5-create-api-key.sh
···
+
#!/bin/bash
+
#
+
# Step 5: Create API Key for Aggregator
+
#
+
# This script guides you through generating an API key for your aggregator.
+
# API keys are used for authentication instead of PDS JWTs.
+
#
+
# Prerequisites:
+
# - Completed steps 1-4 (PDS account, .well-known, Coves registration, service declaration)
+
# - Aggregator indexed by Coves (check: curl https://coves.social/xrpc/social.coves.aggregator.get?did=YOUR_DID)
+
#
+
# Usage: ./5-create-api-key.sh
+
#
+
+
set -e
+
+
# Colors for output
+
RED='\033[0;31m'
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
BLUE='\033[0;34m'
+
NC='\033[0m' # No Color
+
+
echo -e "${BLUE}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}"
+
echo -e "${BLUE}โ•‘ Coves Aggregator - Step 5: Create API Key โ•‘${NC}"
+
echo -e "${BLUE}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo
+
+
# Load existing configuration
+
CONFIG_FILE="aggregator-config.env"
+
if [ -f "$CONFIG_FILE" ]; then
+
echo -e "${GREEN}โœ“${NC} Loading existing configuration from $CONFIG_FILE"
+
source "$CONFIG_FILE"
+
else
+
echo -e "${YELLOW}โš ${NC} No $CONFIG_FILE found. Please run steps 1-4 first."
+
echo
+
read -p "Enter your Coves instance URL [https://coves.social]: " COVES_INSTANCE_URL
+
COVES_INSTANCE_URL=${COVES_INSTANCE_URL:-https://coves.social}
+
fi
+
+
echo
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo -e "${YELLOW} API Key Generation Process${NC}"
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo
+
echo "API keys allow your aggregator to authenticate without managing"
+
echo "OAuth token refresh. The key is shown ONCE and cannot be retrieved later."
+
echo
+
echo -e "${BLUE}Steps:${NC}"
+
echo "1. Complete OAuth login in your browser"
+
echo "2. Call the createApiKey endpoint"
+
echo "3. Save the key securely"
+
echo
+
+
# Check if aggregator is indexed
+
echo -e "${BLUE}Checking if aggregator is indexed...${NC}"
+
if [ -n "$AGGREGATOR_DID" ]; then
+
AGGREGATOR_CHECK=$(curl -s "${COVES_INSTANCE_URL}/xrpc/social.coves.aggregator.get?did=${AGGREGATOR_DID}" 2>/dev/null || echo "error")
+
if echo "$AGGREGATOR_CHECK" | grep -q "error"; then
+
echo -e "${YELLOW}โš ${NC} Could not verify aggregator status. Proceeding anyway..."
+
else
+
echo -e "${GREEN}โœ“${NC} Aggregator found in Coves instance"
+
fi
+
fi
+
+
echo
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo -e "${YELLOW} Step 5.1: OAuth Login${NC}"
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo
+
echo "Open this URL in your browser to authenticate:"
+
echo
+
AGGREGATOR_HANDLE=${AGGREGATOR_HANDLE:-"your-aggregator.example.com"}
+
echo -e " ${BLUE}${COVES_INSTANCE_URL}/oauth/login?handle=${AGGREGATOR_HANDLE}${NC}"
+
echo
+
echo "This will:"
+
echo " 1. Redirect you to your PDS for authentication"
+
echo " 2. Return you to Coves with an OAuth session"
+
echo
+
echo -e "${YELLOW}After authenticating, press Enter to continue...${NC}"
+
read
+
+
echo
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo -e "${YELLOW} Step 5.2: Create API Key${NC}"
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo
+
echo "In your browser's Developer Console (F12 โ†’ Console), run:"
+
echo
+
echo -e "${GREEN}fetch('/xrpc/social.coves.aggregator.createApiKey', {"
+
echo " method: 'POST',"
+
echo " credentials: 'include'"
+
echo "})"
+
echo ".then(r => r.json())"
+
echo -e ".then(data => console.log('API Key:', data.key))${NC}"
+
echo
+
echo "This will return your API key. It looks like:"
+
echo " ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
echo
+
echo -e "${RED}โš  IMPORTANT: Save this key immediately! It cannot be retrieved again.${NC}"
+
echo
+
read -p "Enter the API key you received: " API_KEY
+
+
# Validate API key format
+
if [[ ! $API_KEY =~ ^ckapi_[a-f0-9]{64}$ ]]; then
+
echo -e "${RED}โœ— Invalid API key format. Expected: ckapi_ followed by 64 hex characters${NC}"
+
echo " Example: ckapi_dcbdec0a0d1b3c440125547d21fe582bbf1587d2dcd364c56ad285af841cc934"
+
exit 1
+
fi
+
+
echo -e "${GREEN}โœ“${NC} API key format valid"
+
+
# Save to config
+
echo
+
echo "COVES_API_KEY=\"$API_KEY\"" >> "$CONFIG_FILE"
+
echo -e "${GREEN}โœ“${NC} API key saved to $CONFIG_FILE"
+
+
echo
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo -e "${YELLOW} Step 5.3: Update Your .env File${NC}"
+
echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo
+
echo "Update your aggregator's .env file with:"
+
echo
+
echo -e "${GREEN}COVES_API_KEY=${API_KEY}${NC}"
+
echo -e "${GREEN}COVES_API_URL=${COVES_INSTANCE_URL}${NC}"
+
echo
+
echo "You can remove the old AGGREGATOR_HANDLE and AGGREGATOR_PASSWORD variables."
+
echo
+
+
echo
+
echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}"
+
echo -e "${GREEN}โ•‘ Setup Complete! โ•‘${NC}"
+
echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo
+
echo "Your aggregator is now configured with API key authentication."
+
echo
+
echo "Next steps:"
+
echo " 1. Update your aggregator's .env file with COVES_API_KEY"
+
echo " 2. Rebuild your Docker container: docker compose build --no-cache"
+
echo " 3. Start the aggregator: docker compose up -d"
+
echo " 4. Check logs: docker compose logs -f"
+
echo
+
echo -e "${YELLOW}Security Reminders:${NC}"
+
echo " - Never commit your API key to version control"
+
echo " - Store it securely (environment variables or secrets manager)"
+
echo " - Rotate periodically by generating a new key (revokes the old one)"
+
echo
+73 -28
scripts/aggregator-setup/README.md
···
Aggregators are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers. To use aggregators with Coves, you need to:
1. Create a PDS account for your aggregator (gets you a DID)
-
2. Prove you own a domain via `.well-known/atproto-did`
+
2. **(Optional)** Verify a custom domain via `.well-known/atproto-did`
3. Register with a Coves instance
4. Create a service declaration record
+
5. **Generate an API key** for authentication
These scripts automate this process for you.
+
### Handle Options
+
+
You have two choices for your aggregator's handle:
+
+
1. **PDS-assigned handle** (simpler): Use the handle from your PDS, e.g., `my-aggregator.bsky.social`. No domain verification neededโ€”skip steps 2-3.
+
+
2. **Custom domain handle** (branded): Use your own domain, e.g., `news.example.com`. Requires hosting a `.well-known/atproto-did` file on your domain.
+
## Prerequisites
-
- **Domain ownership**: You must own a domain where you can host the `.well-known/atproto-did` file
-
- **Web server**: Ability to serve static files over HTTPS
- **Tools**: `curl`, `jq` (for JSON processing)
- **Account**: Email address for creating the PDS account
+
- **(For custom domain only)**: Domain ownership and ability to serve HTTPS files
## Quick Start
···
# Step 1: Create PDS account
./1-create-pds-account.sh
-
# Step 2: Generate .well-known file
-
./2-setup-wellknown.sh
-
-
# Step 3: Register with Coves (after uploading .well-known)
-
./3-register-with-coves.sh
+
# Steps 2-3: OPTIONAL - Only if you want a custom domain handle
+
# ./2-setup-wellknown.sh
+
# ./3-register-with-coves.sh (after uploading .well-known)
# Step 4: Create service declaration
./4-create-service-declaration.sh
+
+
# Step 5: Generate API key (requires browser for OAuth)
+
./5-create-api-key.sh
```
+
**Minimal setup** (PDS handle only): Steps 1, 4, 5
+
**Custom domain**: Steps 1, 2, 3, 4, 5
+
### Automated Setup Example
For a reference implementation of automated setup, see the Kagi News aggregator at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh).
···
- Updates `aggregator-config.env` with record URI and CID
- Prints record details
+
### 5-create-api-key.sh
+
+
**Purpose**: Generates an API key for aggregator authentication
+
+
**Prerequisites**:
+
- Steps 1-4 completed
+
- Aggregator indexed by Coves (usually takes a few seconds after step 4)
+
- Web browser for OAuth login
+
+
**What it does**:
+
1. Guides you through OAuth login in your browser
+
2. Provides the JavaScript to call the `createApiKey` endpoint
+
3. Validates the API key format
+
4. Saves the key to your config file
+
+
**Outputs**:
+
- Updates `aggregator-config.env` with `COVES_API_KEY`
+
- Provides instructions for updating your `.env` file
+
+
**Important Notes**:
+
- The API key is shown **ONCE** and cannot be retrieved later
+
- API keys replace password-based authentication
+
- Keys can be revoked and regenerated at any time
+
- Store securely - never commit to version control
+
## Configuration File
-
After running the scripts, you'll have an `aggregator-config.env` file with:
+
After running all scripts, you'll have an `aggregator-config.env` file with:
```bash
+
# Identity
AGGREGATOR_DID="did:plc:..."
-
AGGREGATOR_HANDLE="mynewsbot.bsky.social"
+
AGGREGATOR_HANDLE="mynewsbot.example.com"
AGGREGATOR_PDS_URL="https://bsky.social"
-
AGGREGATOR_EMAIL="bot@example.com"
-
AGGREGATOR_PASSWORD="..."
-
AGGREGATOR_ACCESS_JWT="..."
-
AGGREGATOR_REFRESH_JWT="..."
-
AGGREGATOR_DOMAIN="rss-bot.example.com"
-
COVES_INSTANCE_URL="https://api.coves.social"
+
AGGREGATOR_DOMAIN="mynewsbot.example.com"
+
+
# Coves Instance
+
COVES_INSTANCE_URL="https://coves.social"
SERVICE_DECLARATION_URI="at://did:plc:.../social.coves.aggregator.service/self"
SERVICE_DECLARATION_CID="..."
+
+
# API Key (from Step 5)
+
COVES_API_KEY="ckapi_..."
```
-
**Use this in your aggregator code** to authenticate and post.
+
**For your aggregator's `.env` file, you only need:**
+
+
```bash
+
COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
COVES_API_URL=https://coves.social
+
```
## What Happens Next?
-
After completing all 4 steps:
+
After completing all 5 steps:
1. **Your aggregator is registered** in the Coves instance's `users` table
2. **Your service declaration is indexed** in the `aggregators` table (takes a few seconds)
-
3. **Community moderators can now authorize** your aggregator for their communities
-
4. **Once authorized**, your aggregator can post to those communities
+
3. **Your API key is stored** and can be used for authentication
+
4. **Community moderators can authorize** your aggregator for their communities
+
5. **Your aggregator can post** to authorized communities (or all if you're a trusted aggregator)
## Creating an Authorization
···
## Posting to Communities
-
Once authorized, your aggregator can post using:
+
Once authorized, your aggregator can post using your API key:
```bash
-
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
-
-H "Authorization: DPoP $AGGREGATOR_ACCESS_JWT" \
+
curl -X POST https://coves.social/xrpc/social.coves.community.post.create \
+
-H "Authorization: Bearer $COVES_API_KEY" \
-H "Content-Type: application/json" \
-d '{
-
"communityDid": "did:plc:...",
-
"post": {
-
"text": "Your post content",
-
"createdAt": "2024-01-15T12:00:00Z"
-
}
+
"community": "c-worldnews.coves.social",
+
"content": "Your post content",
+
"facets": []
}'
```
+
The API key handles all authentication - no OAuth token refresh needed.
+
## Troubleshooting
### Error: "DomainVerificationFailed"
+200
scripts/setup_dev_aggregator.go
···
+
// setup_dev_aggregator.go - Creates a local test aggregator on the local PDS
+
//
+
// This script creates an aggregator account on the local PDS for development testing.
+
// After running, you'll need to:
+
// 1. Register the aggregator via OAuth UI
+
// 2. Generate an API key via the createApiKey endpoint
+
//
+
// Usage: go run scripts/setup_dev_aggregator.go
+
package main
+
+
import (
+
"bytes"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"io"
+
"log"
+
"net/http"
+
+
_ "github.com/lib/pq"
+
)
+
+
const (
+
PDSURL = "http://localhost:3001"
+
DatabaseURL = "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable"
+
)
+
+
type CreateAccountRequest struct {
+
Email string `json:"email"`
+
Handle string `json:"handle"`
+
Password string `json:"password"`
+
}
+
+
type CreateAccountResponse struct {
+
DID string `json:"did"`
+
Handle string `json:"handle"`
+
AccessJWT string `json:"accessJwt"`
+
}
+
+
type CreateSessionRequest struct {
+
Identifier string `json:"identifier"`
+
Password string `json:"password"`
+
}
+
+
type CreateSessionResponse struct {
+
DID string `json:"did"`
+
Handle string `json:"handle"`
+
AccessJWT string `json:"accessJwt"`
+
}
+
+
func main() {
+
ctx := context.Background()
+
+
// Configuration
+
handle := "test-aggregator.local.coves.dev"
+
email := "test-aggregator@example.com"
+
password := "test-password-12345"
+
displayName := "Test Aggregator (Dev)"
+
+
log.Printf("Setting up dev aggregator: %s", handle)
+
+
// Connect to database
+
db, err := sql.Open("postgres", DatabaseURL)
+
if err != nil {
+
log.Fatalf("Failed to connect to database: %v", err)
+
}
+
defer db.Close()
+
+
// Step 1: Try to create account on PDS (or get existing session)
+
log.Printf("Creating account on PDS: %s", PDSURL)
+
+
var did string
+
+
// First try to create account
+
createResp, err := createAccount(handle, email, password)
+
if err != nil {
+
log.Printf("Account creation failed (may already exist): %v", err)
+
log.Printf("Trying to create session with existing account...")
+
+
// Try to login instead
+
sessionResp, err := createSession(handle, password)
+
if err != nil {
+
log.Fatalf("Failed to create session: %v", err)
+
}
+
did = sessionResp.DID
+
log.Printf("Logged in as existing account: %s", did)
+
} else {
+
did = createResp.DID
+
log.Printf("Created new account: %s", did)
+
}
+
+
// Step 2: Check if already in users table
+
var existingHandle string
+
err = db.QueryRowContext(ctx, "SELECT handle FROM users WHERE did = $1", did).Scan(&existingHandle)
+
if err == nil {
+
log.Printf("User already exists in users table: %s", existingHandle)
+
} else {
+
// Insert into users table
+
log.Printf("Inserting user into users table...")
+
_, err = db.ExecContext(ctx, `
+
INSERT INTO users (did, handle, pds_url)
+
VALUES ($1, $2, $3)
+
ON CONFLICT (did) DO UPDATE SET handle = $2
+
`, did, handle, PDSURL)
+
if err != nil {
+
log.Fatalf("Failed to insert user: %v", err)
+
}
+
}
+
+
// Step 3: Check if already in aggregators table
+
var existingAggDID string
+
err = db.QueryRowContext(ctx, "SELECT did FROM aggregators WHERE did = $1", did).Scan(&existingAggDID)
+
if err == nil {
+
log.Printf("Aggregator already exists in aggregators table")
+
} else {
+
// Insert into aggregators table
+
log.Printf("Inserting aggregator into aggregators table...")
+
recordURI := fmt.Sprintf("at://%s/social.coves.aggregator.declaration/self", did)
+
recordCID := "dev-placeholder-cid"
+
+
_, err = db.ExecContext(ctx, `
+
INSERT INTO aggregators (did, display_name, description, record_uri, record_cid, created_at, indexed_at)
+
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
+
`, did, displayName, "Development test aggregator", recordURI, recordCID)
+
if err != nil {
+
log.Fatalf("Failed to insert aggregator: %v", err)
+
}
+
}
+
+
fmt.Println()
+
fmt.Println("========================================")
+
fmt.Println(" DEV AGGREGATOR ACCOUNT CREATED")
+
fmt.Println("========================================")
+
fmt.Println()
+
fmt.Printf(" DID: %s\n", did)
+
fmt.Printf(" Handle: %s\n", handle)
+
fmt.Printf(" Password: %s\n", password)
+
fmt.Println()
+
fmt.Println(" Next steps:")
+
fmt.Println(" 1. Start Coves server: make run")
+
fmt.Println(" 2. Authenticate as this account via OAuth UI")
+
fmt.Println(" 3. Call POST /xrpc/social.coves.aggregator.createApiKey")
+
fmt.Println(" 4. Save the API key and add to aggregators/kagi-news/.env")
+
fmt.Println()
+
fmt.Println("========================================")
+
}
+
+
func createAccount(handle, email, password string) (*CreateAccountResponse, error) {
+
reqBody := CreateAccountRequest{
+
Email: email,
+
Handle: handle,
+
Password: password,
+
}
+
+
body, _ := json.Marshal(reqBody)
+
resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createAccount", "application/json", bytes.NewReader(body))
+
if err != nil {
+
return nil, fmt.Errorf("request failed: %w", err)
+
}
+
defer resp.Body.Close()
+
+
respBody, _ := io.ReadAll(resp.Body)
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
+
}
+
+
var result CreateAccountResponse
+
if err := json.Unmarshal(respBody, &result); err != nil {
+
return nil, fmt.Errorf("failed to parse response: %w", err)
+
}
+
+
return &result, nil
+
}
+
+
func createSession(identifier, password string) (*CreateSessionResponse, error) {
+
reqBody := CreateSessionRequest{
+
Identifier: identifier,
+
Password: password,
+
}
+
+
body, _ := json.Marshal(reqBody)
+
resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createSession", "application/json", bytes.NewReader(body))
+
if err != nil {
+
return nil, fmt.Errorf("request failed: %w", err)
+
}
+
defer resp.Body.Close()
+
+
respBody, _ := io.ReadAll(resp.Body)
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
+
}
+
+
var result CreateSessionResponse
+
if err := json.Unmarshal(respBody, &result); err != nil {
+
return nil, fmt.Errorf("failed to parse response: %w", err)
+
}
+
+
return &result, nil
+
}
+2 -1
aggregators/kagi-news/crontab
···
# Run Kagi News aggregator daily at 1 PM UTC (after Kagi updates around noon)
-
0 13 * * * cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1
+
# Source environment variables exported by docker-entrypoint.sh
+
0 13 * * * . /etc/environment; cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1
# Blank line required at end of crontab
+1 -1
internal/atproto/lexicon/social/coves/embed/external.json
···
"thumb": {
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"],
-
"maxSize": 1000000,
+
"maxSize": 6000000,
"description": "Thumbnail image for the post (applies to primary link)"
},
"domain": {
+6 -6
internal/core/blobs/service.go
···
return nil, fmt.Errorf("failed to read image data: %w", err)
}
-
// Validate size (1MB = 1048576 bytes)
-
const maxSize = 1048576
+
// Validate size (6MB = 6291456 bytes)
+
const maxSize = 6291456
if len(data) > maxSize {
-
return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize)
+
return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize)
}
// Upload to PDS
···
return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType)
}
-
// Validate size (1MB = 1048576 bytes)
-
const maxSize = 1048576
+
// Validate size (6MB = 6291456 bytes)
+
const maxSize = 6291456
if len(data) > maxSize {
-
return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize)
+
return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize)
}
// Use community's PDS URL (for federated communities)
+28 -17
internal/db/postgres/feed_repo_base.go
···
return filter, []interface{}{score, createdAt, uri}, nil
case "hot":
-
// Cursor format: hot_rank::timestamp::uri
-
// CRITICAL: Must use computed hot_rank, not raw score, to prevent pagination bugs
-
if len(payloadParts) != 3 {
+
// Cursor format: hot_rank::post_created_at::uri::cursor_timestamp
+
// CRITICAL: cursor_timestamp is when the cursor was created, used for stable hot_rank comparison
+
// This prevents pagination bugs caused by hot_rank drift when NOW() changes between requests
+
if len(payloadParts) != 4 {
return "", nil, fmt.Errorf("invalid cursor format for hot sort")
}
hotRankStr := payloadParts[0]
-
createdAt := payloadParts[1]
+
postCreatedAt := payloadParts[1]
uri := payloadParts[2]
+
cursorTimestamp := payloadParts[3]
// Validate hot_rank is numeric (float)
hotRank := 0.0
···
return "", nil, fmt.Errorf("invalid cursor hot rank")
}
-
// Validate timestamp format
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
+
// Validate post timestamp format
+
if _, err := time.Parse(time.RFC3339Nano, postCreatedAt); err != nil {
+
return "", nil, fmt.Errorf("invalid cursor post timestamp")
}
// Validate URI format (must be AT-URI)
···
return "", nil, fmt.Errorf("invalid cursor URI")
}
-
// CRITICAL: Compare against the computed hot_rank expression, not p.score
-
filter := fmt.Sprintf(`AND ((%s < $%d OR (%s = $%d AND p.created_at < $%d) OR (%s = $%d AND p.created_at = $%d AND p.uri < $%d)) AND p.uri != $%d)`,
-
r.hotRankExpression, paramOffset,
-
r.hotRankExpression, paramOffset, paramOffset+1,
-
r.hotRankExpression, paramOffset, paramOffset+1, paramOffset+2,
+
// Validate cursor timestamp format
+
if _, err := time.Parse(time.RFC3339Nano, cursorTimestamp); err != nil {
+
return "", nil, fmt.Errorf("invalid cursor timestamp")
+
}
+
+
// CRITICAL: Use cursor_timestamp instead of NOW() for stable hot_rank comparison
+
// This ensures posts don't drift across page boundaries due to time passing
+
stableHotRankExpr := fmt.Sprintf(
+
`((p.score + 1) / POWER(EXTRACT(EPOCH FROM ($%d::timestamptz - p.created_at))/3600 + 2, 1.5))`,
paramOffset+3)
-
return filter, []interface{}{hotRank, createdAt, uri, uri}, nil
+
+
// Use tuple comparison for clean keyset pagination: (hot_rank, created_at, uri) < (cursor_values)
+
filter := fmt.Sprintf(`AND ((%s, p.created_at, p.uri) < ($%d, $%d, $%d))`,
+
stableHotRankExpr, paramOffset, paramOffset+1, paramOffset+2)
+
return filter, []interface{}{hotRank, postCreatedAt, uri, cursorTimestamp}, nil
default:
return "", nil, nil
···
// buildCursor creates HMAC-signed pagination cursor from last post
// SECURITY: Cursor is signed with HMAC-SHA256 to prevent manipulation
-
func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64) string {
+
// queryTime is the timestamp when the query was executed, used for stable hot_rank comparison
+
func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64, queryTime time.Time) string {
var payload string
// Use :: as delimiter following Bluesky convention
const delimiter = "::"
···
payload = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
case "hot":
-
// Format: hot_rank::timestamp::uri
-
// CRITICAL: Use computed hot_rank with full precision
+
// Format: hot_rank::post_created_at::uri::cursor_timestamp
+
// CRITICAL: Include cursor_timestamp for stable hot_rank comparison across requests
hotRankStr := strconv.FormatFloat(hotRank, 'g', -1, 64)
-
payload = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
+
payload = fmt.Sprintf("%s%s%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI, delimiter, queryTime.Format(time.RFC3339Nano))
default:
payload = post.URI
+13 -1
internal/core/users/errors.go
···
package users
-
import "fmt"
+
import (
+
"errors"
+
"fmt"
+
)
+
+
// Sentinel errors for common user operations
+
var (
+
// ErrUserNotFound is returned when a user lookup finds no matching record
+
ErrUserNotFound = errors.New("user not found")
+
+
// ErrHandleAlreadyTaken is returned when attempting to use a handle that belongs to another user
+
ErrHandleAlreadyTaken = errors.New("handle already taken")
+
)
// Domain errors for user service operations
// These map to lexicon error types defined in social.coves.actor.signup
+94
internal/api/handlers/actor/errors.go
···
+
package actor
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"log"
+
"net/http"
+
+
"Coves/internal/core/posts"
+
)
+
+
// ErrorResponse represents an XRPC error response
+
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 encoding errors but can't send error response (headers already sent)
+
log.Printf("ERROR: Failed to encode error response: %v", err)
+
}
+
}
+
+
// handleServiceError maps service errors to HTTP responses
+
func handleServiceError(w http.ResponseWriter, err error) {
+
// Check for handler-level errors first
+
var actorNotFound *actorNotFoundError
+
if errors.As(err, &actorNotFound) {
+
writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found")
+
return
+
}
+
+
// Check for service-level errors
+
switch {
+
case errors.Is(err, posts.ErrNotFound):
+
writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found")
+
+
case errors.Is(err, posts.ErrActorNotFound):
+
writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found")
+
+
case errors.Is(err, posts.ErrCommunityNotFound):
+
writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found")
+
+
case errors.Is(err, posts.ErrInvalidCursor):
+
writeError(w, http.StatusBadRequest, "InvalidCursor", "Invalid pagination cursor")
+
+
case posts.IsValidationError(err):
+
// Extract message from ValidationError for cleaner response
+
var valErr *posts.ValidationError
+
if errors.As(err, &valErr) {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", valErr.Message)
+
} else {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
}
+
+
default:
+
// Internal server error - don't leak details
+
log.Printf("ERROR: Actor posts service error: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
+
}
+
}
+
+
// actorNotFoundError represents an actor not found error
+
type actorNotFoundError struct {
+
actor string
+
}
+
+
func (e *actorNotFoundError) Error() string {
+
return fmt.Sprintf("actor not found: %s", e.actor)
+
}
+
+
// resolutionFailedError represents an infrastructure failure during resolution
+
// (database down, DNS failures, TLS errors, etc.)
+
// This is distinct from actorNotFoundError to avoid masking real problems as "not found"
+
type resolutionFailedError struct {
+
actor string
+
cause error
+
}
+
+
func (e *resolutionFailedError) Error() string {
+
return fmt.Sprintf("failed to resolve actor %s: %v", e.actor, e.cause)
+
}
+
+
func (e *resolutionFailedError) Unwrap() error {
+
return e.cause
+
}
+185
internal/api/handlers/actor/get_posts.go
···
+
package actor
+
+
import (
+
"encoding/json"
+
"errors"
+
"log"
+
"net/http"
+
"strconv"
+
"strings"
+
+
"Coves/internal/api/handlers/common"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/blueskypost"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
+
)
+
+
// GetPostsHandler handles actor post retrieval
+
type GetPostsHandler struct {
+
postService posts.Service
+
userService users.UserService
+
voteService votes.Service
+
blueskyService blueskypost.Service
+
}
+
+
// NewGetPostsHandler creates a new actor posts handler
+
func NewGetPostsHandler(
+
postService posts.Service,
+
userService users.UserService,
+
voteService votes.Service,
+
blueskyService blueskypost.Service,
+
) *GetPostsHandler {
+
if blueskyService == nil {
+
log.Printf("[ACTOR-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved")
+
}
+
return &GetPostsHandler{
+
postService: postService,
+
userService: userService,
+
voteService: voteService,
+
blueskyService: blueskyService,
+
}
+
}
+
+
// HandleGetPosts retrieves posts by an actor (user)
+
// GET /xrpc/social.coves.actor.getPosts?actor={did_or_handle}&filter=posts_with_replies&community=...&limit=50&cursor=...
+
func (h *GetPostsHandler) HandleGetPosts(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse query parameters
+
req, err := h.parseRequest(r)
+
if err != nil {
+
// Check if it's an actor not found error (from handle resolution)
+
var actorNotFound *actorNotFoundError
+
if errors.As(err, &actorNotFound) {
+
writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found")
+
return
+
}
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
return
+
}
+
+
// Get viewer DID for populating viewer state (optional)
+
viewerDID := middleware.GetUserDID(r)
+
req.ViewerDID = viewerDID
+
+
// Get actor posts from service
+
response, err := h.postService.GetAuthorPosts(r.Context(), req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Populate viewer vote state if authenticated
+
common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed)
+
+
// Transform blob refs to URLs and resolve post embeds for all posts
+
for _, feedPost := range response.Feed {
+
if feedPost.Post != nil {
+
posts.TransformBlobRefsToURLs(feedPost.Post)
+
posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService)
+
}
+
}
+
+
// Pre-encode response to buffer before writing headers
+
// This ensures we can return a proper error if encoding fails
+
responseBytes, err := json.Marshal(response)
+
if err != nil {
+
log.Printf("ERROR: Failed to encode actor posts response: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response")
+
return
+
}
+
+
// Return feed
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if _, err := w.Write(responseBytes); err != nil {
+
log.Printf("ERROR: Failed to write actor posts response: %v", err)
+
}
+
}
+
+
// parseRequest parses query parameters into GetAuthorPostsRequest
+
func (h *GetPostsHandler) parseRequest(r *http.Request) (posts.GetAuthorPostsRequest, error) {
+
req := posts.GetAuthorPostsRequest{}
+
+
// Required: actor (handle or DID)
+
actor := r.URL.Query().Get("actor")
+
if actor == "" {
+
return req, posts.NewValidationError("actor", "actor parameter is required")
+
}
+
// Validate actor length to prevent DoS via massive strings
+
// Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer)
+
// Max handle length is 253 chars (DNS limit)
+
const maxActorLength = 2048
+
if len(actor) > maxActorLength {
+
return req, posts.NewValidationError("actor", "actor parameter exceeds maximum length")
+
}
+
+
// Resolve actor to DID if it's a handle
+
actorDID, err := h.resolveActor(r, actor)
+
if err != nil {
+
return req, err
+
}
+
req.ActorDID = actorDID
+
+
// Optional: filter (default: posts_with_replies)
+
req.Filter = r.URL.Query().Get("filter")
+
+
// Optional: community (handle or DID)
+
req.Community = r.URL.Query().Get("community")
+
+
// Optional: limit (default: 50, max: 100)
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
limit, err := strconv.Atoi(limitStr)
+
if err != nil {
+
return req, posts.NewValidationError("limit", "limit must be a valid integer")
+
}
+
req.Limit = limit
+
}
+
+
// Optional: cursor
+
if cursor := r.URL.Query().Get("cursor"); cursor != "" {
+
req.Cursor = &cursor
+
}
+
+
return req, nil
+
}
+
+
// resolveActor converts an actor identifier (handle or DID) to a DID
+
func (h *GetPostsHandler) resolveActor(r *http.Request, actor string) (string, error) {
+
// If it's already a DID, return it
+
if strings.HasPrefix(actor, "did:") {
+
return actor, nil
+
}
+
+
// It's a handle - resolve to DID using user service
+
did, err := h.userService.ResolveHandleToDID(r.Context(), actor)
+
if err != nil {
+
// Check for context errors (timeouts, cancellation) - these are infrastructure errors
+
if r.Context().Err() != nil {
+
log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err)
+
return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()}
+
}
+
+
// Check for common "not found" patterns in error message
+
errStr := err.Error()
+
isNotFound := strings.Contains(errStr, "not found") ||
+
strings.Contains(errStr, "no rows") ||
+
strings.Contains(errStr, "unable to resolve")
+
+
if isNotFound {
+
return "", &actorNotFoundError{actor: actor}
+
}
+
+
// For other errors (network, database, DNS failures), return infrastructure error
+
// This ensures users see "internal error" not "actor not found" for real problems
+
log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err)
+
return "", &resolutionFailedError{actor: actor, cause: err}
+
}
+
+
return did, nil
+
}
+66
internal/atproto/lexicon/social/coves/actor/getPosts.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.actor.getPosts",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get a user's posts for their profile page.",
+
"parameters": {
+
"type": "params",
+
"required": ["actor"],
+
"properties": {
+
"actor": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the user"
+
},
+
"filter": {
+
"type": "string",
+
"knownValues": ["posts_with_replies", "posts_no_replies", "posts_with_media"],
+
"default": "posts_with_replies",
+
"description": "Filter for post types"
+
},
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "Filter to posts in a specific community"
+
},
+
"limit": {
+
"type": "integer",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["feed"],
+
"properties": {
+
"feed": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.feed.defs#feedViewPost"
+
}
+
},
+
"cursor": {
+
"type": "string"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotFound",
+
"description": "Actor not found"
+
}
+
]
+
}
+
}
+
}
+6
internal/core/posts/errors.go
···
// ErrRateLimitExceeded is returned when an aggregator exceeds rate limits
ErrRateLimitExceeded = errors.New("rate limit exceeded")
+
+
// ErrInvalidCursor is returned when a pagination cursor is malformed
+
ErrInvalidCursor = errors.New("invalid pagination cursor")
+
+
// ErrActorNotFound is returned when the requested actor does not exist
+
ErrActorNotFound = errors.New("actor not found")
)
// ValidationError represents a validation error with field context
+10
internal/core/posts/interfaces.go
···
// AppView indexing happens asynchronously via Jetstream consumer
CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error)
+
// GetAuthorPosts retrieves posts authored by a specific user for their profile page
+
// Supports filtering by post type (with/without replies, media only) and community
+
// Returns paginated feed with cursor
+
GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error)
+
// Future methods (Beta):
// GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error)
// UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error)
···
// Used for E2E test verification and future GET endpoint
GetByURI(ctx context.Context, uri string) (*Post, error)
+
// GetByAuthor retrieves posts authored by a specific user
+
// Supports filtering by post type and community
+
// Returns posts, cursor for pagination, and error
+
GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error)
+
// Future methods (Beta):
// Update(ctx context.Context, post *Post) error
// Delete(ctx context.Context, uri string) error
+71
internal/core/posts/post.go
···
Tags []string `json:"tags,omitempty"`
Saved bool `json:"saved"`
}
+
+
// Filter constants for GetAuthorPosts
+
const (
+
FilterPostsWithReplies = "posts_with_replies"
+
FilterPostsNoReplies = "posts_no_replies"
+
FilterPostsWithMedia = "posts_with_media"
+
)
+
+
// GetAuthorPostsRequest represents input for fetching author's posts
+
// Matches social.coves.actor.getPosts lexicon input
+
type GetAuthorPostsRequest struct {
+
ActorDID string // Resolved DID from actor param (handle or DID)
+
Filter string // FilterPostsWithReplies, FilterPostsNoReplies, FilterPostsWithMedia
+
Community string // Optional community DID filter
+
Limit int // Number of posts to return (1-100, default 50)
+
Cursor *string // Pagination cursor
+
ViewerDID string // Viewer's DID for enriching viewer state
+
}
+
+
// GetAuthorPostsResponse represents author posts response
+
// Matches social.coves.actor.getPosts lexicon output
+
type GetAuthorPostsResponse struct {
+
Feed []*FeedViewPost `json:"feed"`
+
Cursor *string `json:"cursor,omitempty"`
+
}
+
+
// FeedViewPost matches social.coves.feed.defs#feedViewPost
+
// Wraps a post with optional context about why it appears in a feed
+
type FeedViewPost struct {
+
Post *PostView `json:"post"`
+
Reason *FeedReason `json:"reason,omitempty"` // Context for why post appears in feed
+
Reply *ReplyRef `json:"reply,omitempty"` // Reply context if post is a reply
+
}
+
+
// GetPost returns the underlying PostView for viewer state enrichment
+
func (f *FeedViewPost) GetPost() *PostView {
+
return f.Post
+
}
+
+
// FeedReason represents the reason a post appears in a feed
+
// Matches social.coves.feed.defs union type for feed context
+
type FeedReason struct {
+
Type string `json:"$type"`
+
Repost *ReasonRepost `json:"repost,omitempty"`
+
Pin *ReasonPin `json:"pin,omitempty"`
+
}
+
+
// ReasonRepost indicates the post was reposted by another user
+
type ReasonRepost struct {
+
By *AuthorView `json:"by"`
+
IndexedAt string `json:"indexedAt"`
+
}
+
+
// ReasonPin indicates the post is pinned by the community
+
type ReasonPin struct {
+
Community *CommunityRef `json:"community"`
+
}
+
+
// ReplyRef contains context about post replies
+
// Matches social.coves.feed.defs#replyRef
+
type ReplyRef struct {
+
Root *PostRef `json:"root"`
+
Parent *PostRef `json:"parent"`
+
}
+
+
// PostRef is a minimal reference to a post (URI + CID)
+
// Matches social.coves.feed.defs#postRef
+
type PostRef struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+122
internal/core/posts/service.go
···
log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID)
return true
}
+
+
// GetAuthorPosts retrieves posts by a specific author with optional filtering
+
// Supports filtering by: posts_with_replies, posts_no_replies, posts_with_media
+
// Optionally filter to a specific community
+
func (s *postService) GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) {
+
// 1. Validate request
+
if err := s.validateGetAuthorPostsRequest(&req); err != nil {
+
return nil, err
+
}
+
+
// 2. If community is provided, resolve it to DID
+
if req.Community != "" {
+
communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
return nil, ErrCommunityNotFound
+
}
+
if communities.IsValidationError(err) {
+
return nil, NewValidationError("community", err.Error())
+
}
+
return nil, fmt.Errorf("failed to resolve community identifier: %w", err)
+
}
+
req.Community = communityDID
+
}
+
+
// 3. Fetch posts from repository
+
postViews, cursor, err := s.repo.GetByAuthor(ctx, req)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get author posts: %w", err)
+
}
+
+
// 4. Wrap PostViews in FeedViewPost
+
feed := make([]*FeedViewPost, len(postViews))
+
for i, postView := range postViews {
+
feed[i] = &FeedViewPost{
+
Post: postView,
+
}
+
}
+
+
// 5. Return response
+
return &GetAuthorPostsResponse{
+
Feed: feed,
+
Cursor: cursor,
+
}, nil
+
}
+
+
// validateGetAuthorPostsRequest validates the GetAuthorPosts request
+
func (s *postService) validateGetAuthorPostsRequest(req *GetAuthorPostsRequest) error {
+
// Validate actor DID is set
+
if req.ActorDID == "" {
+
return NewValidationError("actor", "actor is required")
+
}
+
+
// Validate DID format - AT Protocol supports did:plc and did:web
+
if err := validateDIDFormat(req.ActorDID); err != nil {
+
return NewValidationError("actor", err.Error())
+
}
+
+
// Validate and set defaults for filter
+
validFilters := map[string]bool{
+
FilterPostsWithReplies: true,
+
FilterPostsNoReplies: true,
+
FilterPostsWithMedia: true,
+
}
+
if req.Filter == "" {
+
req.Filter = FilterPostsWithReplies // Default
+
}
+
if !validFilters[req.Filter] {
+
return NewValidationError("filter", "filter must be one of: posts_with_replies, posts_no_replies, posts_with_media")
+
}
+
+
// Validate and set defaults for limit
+
if req.Limit <= 0 {
+
req.Limit = 50 // Default
+
}
+
if req.Limit > 100 {
+
req.Limit = 100 // Max
+
}
+
+
return nil
+
}
+
+
// validateDIDFormat validates that a string is a properly formatted DID
+
// Supports did:plc: (24 char base32 identifier) and did:web: (domain-based)
+
func validateDIDFormat(did string) error {
+
const maxDIDLength = 2048
+
+
if len(did) > maxDIDLength {
+
return fmt.Errorf("DID exceeds maximum length")
+
}
+
+
switch {
+
case strings.HasPrefix(did, "did:plc:"):
+
// did:plc: format - identifier is 24 lowercase alphanumeric chars
+
identifier := strings.TrimPrefix(did, "did:plc:")
+
if len(identifier) == 0 {
+
return fmt.Errorf("invalid did:plc format: missing identifier")
+
}
+
// Base32 uses lowercase a-z and 2-7
+
for _, c := range identifier {
+
if !((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7')) {
+
return fmt.Errorf("invalid did:plc format: identifier contains invalid characters")
+
}
+
}
+
return nil
+
+
case strings.HasPrefix(did, "did:web:"):
+
// did:web: format - domain-based identifier
+
domain := strings.TrimPrefix(did, "did:web:")
+
if len(domain) == 0 {
+
return fmt.Errorf("invalid did:web format: missing domain")
+
}
+
// Basic domain validation - must contain at least one dot or be localhost
+
if !strings.Contains(domain, ".") && domain != "localhost" {
+
return fmt.Errorf("invalid did:web format: invalid domain")
+
}
+
return nil
+
+
default:
+
return fmt.Errorf("unsupported DID method: must be did:plc or did:web")
+
}
+
}
+277
internal/core/posts/service_author_posts_test.go
···
+
package posts
+
+
import (
+
"context"
+
"testing"
+
)
+
+
// mockRepository implements Repository for testing
+
type mockRepository struct {
+
getByAuthorFunc func(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error)
+
}
+
+
func (m *mockRepository) Create(ctx context.Context, post *Post) error {
+
return nil
+
}
+
+
func (m *mockRepository) GetByURI(ctx context.Context, uri string) (*Post, error) {
+
return nil, nil
+
}
+
+
func (m *mockRepository) GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) {
+
if m.getByAuthorFunc != nil {
+
return m.getByAuthorFunc(ctx, req)
+
}
+
return []*PostView{}, nil, nil
+
}
+
+
func (m *mockRepository) SoftDelete(ctx context.Context, uri string) error {
+
return nil
+
}
+
+
func (m *mockRepository) Update(ctx context.Context, post *Post) error {
+
return nil
+
}
+
+
func (m *mockRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error {
+
return nil
+
}
+
+
func TestValidateDIDFormat(t *testing.T) {
+
tests := []struct {
+
name string
+
did string
+
wantErr bool
+
errMsg string
+
}{
+
{
+
name: "valid did:plc",
+
did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
wantErr: false,
+
},
+
{
+
name: "valid did:web",
+
did: "did:web:example.com",
+
wantErr: false,
+
},
+
{
+
name: "valid did:web with subdomain",
+
did: "did:web:bsky.social",
+
wantErr: false,
+
},
+
{
+
name: "valid did:web localhost",
+
did: "did:web:localhost",
+
wantErr: false,
+
},
+
{
+
name: "invalid - missing method",
+
did: "did:",
+
wantErr: true,
+
errMsg: "unsupported DID method",
+
},
+
{
+
name: "invalid - unsupported method",
+
did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
+
wantErr: true,
+
errMsg: "unsupported DID method",
+
},
+
{
+
name: "invalid did:plc - empty identifier",
+
did: "did:plc:",
+
wantErr: true,
+
errMsg: "missing identifier",
+
},
+
{
+
name: "invalid did:plc - uppercase chars",
+
did: "did:plc:UPPERCASE",
+
wantErr: true,
+
errMsg: "invalid characters",
+
},
+
{
+
name: "invalid did:plc - numbers outside base32",
+
did: "did:plc:abc0189",
+
wantErr: true,
+
errMsg: "invalid characters",
+
},
+
{
+
name: "invalid did:web - empty domain",
+
did: "did:web:",
+
wantErr: true,
+
errMsg: "missing domain",
+
},
+
{
+
name: "invalid did:web - no dot in domain",
+
did: "did:web:nodot",
+
wantErr: true,
+
errMsg: "invalid domain",
+
},
+
{
+
name: "invalid - not a DID",
+
did: "notadid",
+
wantErr: true,
+
errMsg: "unsupported DID method",
+
},
+
{
+
name: "invalid - too long",
+
did: "did:plc:" + string(make([]byte, 2100)),
+
wantErr: true,
+
errMsg: "exceeds maximum length",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
err := validateDIDFormat(tt.did)
+
if tt.wantErr {
+
if err == nil {
+
t.Errorf("validateDIDFormat(%q) = nil, want error containing %q", tt.did, tt.errMsg)
+
} else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) {
+
t.Errorf("validateDIDFormat(%q) = %v, want error containing %q", tt.did, err, tt.errMsg)
+
}
+
} else {
+
if err != nil {
+
t.Errorf("validateDIDFormat(%q) = %v, want nil", tt.did, err)
+
}
+
}
+
})
+
}
+
}
+
+
// helper function for contains check (named testContains to avoid conflict with package function)
+
func testContains(s, substr string) bool {
+
for i := 0; i <= len(s)-len(substr); i++ {
+
if s[i:i+len(substr)] == substr {
+
return true
+
}
+
}
+
return false
+
}
+
+
func TestValidateGetAuthorPostsRequest(t *testing.T) {
+
// Create a minimal service for testing validation
+
// We only need to test the validation logic, not the full service
+
+
tests := []struct {
+
name string
+
req GetAuthorPostsRequest
+
wantErr bool
+
errMsg string
+
}{
+
{
+
name: "valid request - minimal",
+
req: GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
},
+
wantErr: false,
+
},
+
{
+
name: "valid request - with filter",
+
req: GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
Filter: FilterPostsWithMedia,
+
},
+
wantErr: false,
+
},
+
{
+
name: "valid request - with limit",
+
req: GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
Limit: 25,
+
},
+
wantErr: false,
+
},
+
{
+
name: "invalid - empty actor",
+
req: GetAuthorPostsRequest{
+
ActorDID: "",
+
},
+
wantErr: true,
+
errMsg: "actor is required",
+
},
+
{
+
name: "invalid - bad DID format",
+
req: GetAuthorPostsRequest{
+
ActorDID: "notadid",
+
},
+
wantErr: true,
+
errMsg: "unsupported DID method",
+
},
+
{
+
name: "invalid - unknown filter",
+
req: GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
Filter: "unknown_filter",
+
},
+
wantErr: true,
+
errMsg: "filter must be one of",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
// Create service with nil dependencies - we only test validation
+
s := &postService{}
+
err := s.validateGetAuthorPostsRequest(&tt.req)
+
+
if tt.wantErr {
+
if err == nil {
+
t.Errorf("validateGetAuthorPostsRequest() = nil, want error containing %q", tt.errMsg)
+
} else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) {
+
t.Errorf("validateGetAuthorPostsRequest() = %v, want error containing %q", err, tt.errMsg)
+
}
+
} else {
+
if err != nil {
+
t.Errorf("validateGetAuthorPostsRequest() = %v, want nil", err)
+
}
+
}
+
})
+
}
+
}
+
+
func TestValidateGetAuthorPostsRequest_DefaultsSet(t *testing.T) {
+
s := &postService{}
+
+
// Test that defaults are set
+
t.Run("filter defaults to posts_with_replies", func(t *testing.T) {
+
req := GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
Filter: "", // empty
+
}
+
err := s.validateGetAuthorPostsRequest(&req)
+
if err != nil {
+
t.Fatalf("unexpected error: %v", err)
+
}
+
if req.Filter != FilterPostsWithReplies {
+
t.Errorf("Filter = %q, want %q", req.Filter, FilterPostsWithReplies)
+
}
+
})
+
+
t.Run("limit defaults to 50 when 0", func(t *testing.T) {
+
req := GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
Limit: 0,
+
}
+
err := s.validateGetAuthorPostsRequest(&req)
+
if err != nil {
+
t.Fatalf("unexpected error: %v", err)
+
}
+
if req.Limit != 50 {
+
t.Errorf("Limit = %d, want 50", req.Limit)
+
}
+
})
+
+
t.Run("limit capped at 100", func(t *testing.T) {
+
req := GetAuthorPostsRequest{
+
ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+
Limit: 200,
+
}
+
err := s.validateGetAuthorPostsRequest(&req)
+
if err != nil {
+
t.Fatalf("unexpected error: %v", err)
+
}
+
if req.Limit != 100 {
+
t.Errorf("Limit = %d, want 100", req.Limit)
+
}
+
})
+
}
+12
internal/db/migrations/026_add_author_posts_index.sql
···
+
-- +goose Up
+
-- +goose NO TRANSACTION
+
-- Add optimized index for author posts queries with soft delete filter
+
-- This supports the social.coves.actor.getPosts endpoint which retrieves posts by author
+
-- The existing idx_posts_author doesn't filter deleted posts, causing full index scans
+
CREATE INDEX CONCURRENTLY idx_posts_author_created
+
ON posts(author_did, created_at DESC)
+
WHERE deleted_at IS NULL;
+
+
-- +goose Down
+
-- +goose NO TRANSACTION
+
DROP INDEX CONCURRENTLY IF EXISTS idx_posts_author_created;
+285
internal/db/postgres/post_repo.go
···
"Coves/internal/core/posts"
"context"
"database/sql"
+
"encoding/base64"
+
"encoding/json"
"fmt"
+
"log"
"strings"
+
"time"
)
type postgresPostRepo struct {
···
return &post, nil
}
+
+
// GetByAuthor retrieves posts by author with filtering and pagination
+
// Supports filter options: posts_with_replies (default), posts_no_replies, posts_with_media
+
// Uses cursor-based pagination with created_at + uri for stable ordering
+
// Returns []*PostView, next cursor, and error
+
func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) {
+
// Build WHERE clauses based on filters
+
whereConditions := []string{
+
"p.author_did = $1",
+
"p.deleted_at IS NULL",
+
}
+
args := []interface{}{req.ActorDID}
+
paramIndex := 2
+
+
// Optional community filter
+
if req.Community != "" {
+
whereConditions = append(whereConditions, fmt.Sprintf("p.community_did = $%d", paramIndex))
+
args = append(args, req.Community)
+
paramIndex++
+
}
+
+
// Filter by post type
+
// Design note: Coves architecture separates posts from comments (unlike Bluesky where
+
// posts can be replies to other posts). The posts_no_replies filter exists for API
+
// compatibility with Bluesky's getAuthorFeed, but is intentionally a no-op in Coves
+
// since all Coves posts are top-level (comments are stored in a separate table).
+
switch req.Filter {
+
case posts.FilterPostsWithMedia:
+
whereConditions = append(whereConditions, "p.embed IS NOT NULL")
+
case posts.FilterPostsNoReplies:
+
// No-op: All Coves posts are top-level; comments are in the comments table.
+
// This filter exists for Bluesky API compatibility.
+
case posts.FilterPostsWithReplies, "":
+
// Default: return all posts (no additional filter needed)
+
}
+
+
// Build cursor filter for pagination
+
cursorFilter, cursorArgs, cursorErr := r.parseAuthorPostsCursor(req.Cursor, paramIndex)
+
if cursorErr != nil {
+
return nil, nil, cursorErr
+
}
+
if cursorFilter != "" {
+
whereConditions = append(whereConditions, cursorFilter)
+
args = append(args, cursorArgs...)
+
paramIndex += len(cursorArgs)
+
}
+
+
// Add limit to args
+
limit := req.Limit
+
if limit <= 0 {
+
limit = 50 // default
+
}
+
if limit > 100 {
+
limit = 100 // max
+
}
+
args = append(args, limit+1) // +1 to check for next page
+
+
whereClause := strings.Join(whereConditions, " AND ")
+
+
query := fmt.Sprintf(`
+
SELECT
+
p.uri, p.cid, p.rkey,
+
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, c.pds_url as community_pds_url,
+
p.title, p.content, p.content_facets, p.embed, p.content_labels,
+
p.created_at, p.edited_at, p.indexed_at,
+
p.upvote_count, p.downvote_count, p.score, p.comment_count
+
FROM posts p
+
INNER JOIN users u ON p.author_did = u.did
+
INNER JOIN communities c ON p.community_did = c.did
+
WHERE %s
+
ORDER BY p.created_at DESC, p.uri DESC
+
LIMIT $%d
+
`, whereClause, paramIndex)
+
+
// Execute query
+
rows, err := r.db.QueryContext(ctx, query, args...)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to query author posts: %w", err)
+
}
+
defer func() {
+
if err := rows.Close(); err != nil {
+
log.Printf("WARN: failed to close rows: %v", err)
+
}
+
}()
+
+
// Scan results
+
var postViews []*posts.PostView
+
for rows.Next() {
+
postView, err := r.scanAuthorPost(rows)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to scan author post: %w", err)
+
}
+
postViews = append(postViews, postView)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, nil, fmt.Errorf("error iterating author posts results: %w", err)
+
}
+
+
// Handle pagination cursor
+
var cursor *string
+
if len(postViews) > limit && limit > 0 {
+
postViews = postViews[:limit]
+
lastPost := postViews[len(postViews)-1]
+
cursorStr := r.buildAuthorPostsCursor(lastPost)
+
cursor = &cursorStr
+
}
+
+
return postViews, cursor, nil
+
}
+
+
// parseAuthorPostsCursor decodes pagination cursor for author posts
+
// Cursor format: base64(created_at|uri)
+
// Uses simple | delimiter since this is an internal cursor (not signed like feed cursors)
+
// Returns filter clause, arguments, and error. Error is returned for malformed cursors
+
// to provide clear feedback rather than silently returning the first page.
+
func (r *postgresPostRepo) parseAuthorPostsCursor(cursor *string, paramOffset int) (string, []interface{}, error) {
+
if cursor == nil || *cursor == "" {
+
return "", nil, nil
+
}
+
+
// Validate cursor size to prevent DoS via massive base64 strings
+
const maxCursorSize = 512
+
if len(*cursor) > maxCursorSize {
+
return "", nil, fmt.Errorf("%w: cursor exceeds maximum length", posts.ErrInvalidCursor)
+
}
+
+
// Decode base64 cursor
+
decoded, err := base64.URLEncoding.DecodeString(*cursor)
+
if err != nil {
+
return "", nil, fmt.Errorf("%w: invalid base64 encoding", posts.ErrInvalidCursor)
+
}
+
+
// Parse cursor: created_at|uri
+
parts := strings.Split(string(decoded), "|")
+
if len(parts) != 2 {
+
return "", nil, fmt.Errorf("%w: malformed cursor format", posts.ErrInvalidCursor)
+
}
+
+
createdAt := parts[0]
+
uri := parts[1]
+
+
// Validate timestamp format
+
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
+
return "", nil, fmt.Errorf("%w: invalid timestamp in cursor", posts.ErrInvalidCursor)
+
}
+
+
// Validate URI format (must be AT-URI)
+
if !strings.HasPrefix(uri, "at://") {
+
return "", nil, fmt.Errorf("%w: invalid URI format in cursor", posts.ErrInvalidCursor)
+
}
+
+
// Use composite key comparison for stable cursor pagination
+
// (created_at, uri) < (cursor_created_at, cursor_uri)
+
filter := fmt.Sprintf("(p.created_at < $%d OR (p.created_at = $%d AND p.uri < $%d))",
+
paramOffset, paramOffset, paramOffset+1)
+
return filter, []interface{}{createdAt, uri}, nil
+
}
+
+
// buildAuthorPostsCursor creates pagination cursor from last post
+
// Cursor format: base64(created_at|uri)
+
func (r *postgresPostRepo) buildAuthorPostsCursor(post *posts.PostView) string {
+
cursorStr := fmt.Sprintf("%s|%s", post.CreatedAt.Format(time.RFC3339Nano), post.URI)
+
return base64.URLEncoding.EncodeToString([]byte(cursorStr))
+
}
+
+
// scanAuthorPost scans a database row into a PostView for author posts query
+
func (r *postgresPostRepo) scanAuthorPost(rows *sql.Rows) (*posts.PostView, error) {
+
var (
+
postView posts.PostView
+
authorView posts.AuthorView
+
communityRef posts.CommunityRef
+
title, content sql.NullString
+
facets, embed sql.NullString
+
labelsJSON sql.NullString
+
editedAt sql.NullTime
+
communityHandle sql.NullString
+
communityAvatar sql.NullString
+
communityPDSURL sql.NullString
+
)
+
+
err := rows.Scan(
+
&postView.URI, &postView.CID, &postView.RKey,
+
&authorView.DID, &authorView.Handle,
+
&communityRef.DID, &communityHandle, &communityRef.Name, &communityAvatar, &communityPDSURL,
+
&title, &content, &facets, &embed, &labelsJSON,
+
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
+
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
// Build author view
+
postView.Author = &authorView
+
+
// Build community ref
+
if communityHandle.Valid {
+
communityRef.Handle = communityHandle.String
+
}
+
if communityAvatar.Valid {
+
communityRef.Avatar = &communityAvatar.String
+
}
+
if communityPDSURL.Valid {
+
communityRef.PDSURL = communityPDSURL.String
+
}
+
postView.Community = &communityRef
+
+
// Set optional fields
+
if title.Valid {
+
postView.Title = &title.String
+
}
+
if content.Valid {
+
postView.Text = &content.String
+
}
+
if editedAt.Valid {
+
postView.EditedAt = &editedAt.Time
+
}
+
+
// Parse facets JSON
+
if facets.Valid {
+
var facetArray []interface{}
+
if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil {
+
return nil, fmt.Errorf("failed to parse facets JSON for post %s: %w", postView.URI, err)
+
}
+
postView.TextFacets = facetArray
+
}
+
+
// Parse embed JSON
+
if embed.Valid {
+
var embedData interface{}
+
if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil {
+
return nil, fmt.Errorf("failed to parse embed JSON for post %s: %w", postView.URI, err)
+
}
+
postView.Embed = embedData
+
}
+
+
// Build stats
+
postView.Stats = &posts.PostStats{
+
Upvotes: postView.UpvoteCount,
+
Downvotes: postView.DownvoteCount,
+
Score: postView.Score,
+
CommentCount: postView.CommentCount,
+
}
+
+
// Build the record (required by lexicon)
+
record := map[string]interface{}{
+
"$type": "social.coves.community.post",
+
"community": communityRef.DID,
+
"author": authorView.DID,
+
"createdAt": postView.CreatedAt.Format(time.RFC3339),
+
}
+
+
// Add optional fields to record if present
+
if title.Valid {
+
record["title"] = title.String
+
}
+
if content.Valid {
+
record["content"] = content.String
+
}
+
// Reuse already-parsed facets and embed from PostView to avoid double parsing
+
if facets.Valid {
+
record["facets"] = postView.TextFacets
+
}
+
if embed.Valid {
+
record["embed"] = postView.Embed
+
}
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
var selfLabels posts.SelfLabels
+
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil {
+
return nil, fmt.Errorf("failed to parse labels JSON for post %s: %w", postView.URI, err)
+
}
+
record["labels"] = selfLabels
+
}
+
+
postView.Record = record
+
+
return &postView, nil
+
}
+244
internal/db/postgres/post_repo_cursor_test.go
···
+
package postgres
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/base64"
+
"testing"
+
"time"
+
+
"Coves/internal/core/posts"
+
)
+
+
func TestParseAuthorPostsCursor(t *testing.T) {
+
repo := &postgresPostRepo{db: nil} // db not needed for cursor parsing
+
+
// Helper to create a valid cursor
+
makeCursor := func(timestamp, uri string) string {
+
return base64.URLEncoding.EncodeToString([]byte(timestamp + "|" + uri))
+
}
+
+
validTimestamp := time.Now().Format(time.RFC3339Nano)
+
validURI := "at://did:plc:test123/social.coves.community.post/abc123"
+
+
tests := []struct {
+
name string
+
cursor *string
+
wantFilter bool
+
wantErr bool
+
errMsg string
+
}{
+
{
+
name: "nil cursor returns empty filter",
+
cursor: nil,
+
wantFilter: false,
+
wantErr: false,
+
},
+
{
+
name: "empty cursor returns empty filter",
+
cursor: strPtr(""),
+
wantFilter: false,
+
wantErr: false,
+
},
+
{
+
name: "valid cursor",
+
cursor: strPtr(makeCursor(validTimestamp, validURI)),
+
wantFilter: true,
+
wantErr: false,
+
},
+
{
+
name: "cursor too long",
+
cursor: strPtr(makeCursor(validTimestamp, string(make([]byte, 600)))),
+
wantFilter: false,
+
wantErr: true,
+
errMsg: "exceeds maximum length",
+
},
+
{
+
name: "invalid base64",
+
cursor: strPtr("not-valid-base64!!!"),
+
wantFilter: false,
+
wantErr: true,
+
errMsg: "invalid base64",
+
},
+
{
+
name: "missing pipe delimiter",
+
cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("no-pipe-here"))),
+
wantFilter: false,
+
wantErr: true,
+
errMsg: "malformed cursor format",
+
},
+
{
+
name: "invalid timestamp",
+
cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("not-a-timestamp|" + validURI))),
+
wantFilter: false,
+
wantErr: true,
+
errMsg: "invalid timestamp",
+
},
+
{
+
name: "invalid URI format",
+
cursor: strPtr(base64.URLEncoding.EncodeToString([]byte(validTimestamp + "|not-an-at-uri"))),
+
wantFilter: false,
+
wantErr: true,
+
errMsg: "invalid URI format",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
filter, args, err := repo.parseAuthorPostsCursor(tt.cursor, 1)
+
+
if tt.wantErr {
+
if err == nil {
+
t.Errorf("parseAuthorPostsCursor() = nil error, want error containing %q", tt.errMsg)
+
} else if !posts.IsValidationError(err) && err != posts.ErrInvalidCursor {
+
// Check if error wraps ErrInvalidCursor
+
if tt.errMsg != "" && !containsStr(err.Error(), tt.errMsg) {
+
t.Errorf("parseAuthorPostsCursor() error = %v, want error containing %q", err, tt.errMsg)
+
}
+
}
+
} else {
+
if err != nil {
+
t.Errorf("parseAuthorPostsCursor() = %v, want nil error", err)
+
}
+
}
+
+
if tt.wantFilter {
+
if filter == "" {
+
t.Error("parseAuthorPostsCursor() filter = empty, want non-empty filter")
+
}
+
if len(args) == 0 {
+
t.Error("parseAuthorPostsCursor() args = empty, want non-empty args")
+
}
+
} else if !tt.wantErr {
+
if filter != "" {
+
t.Errorf("parseAuthorPostsCursor() filter = %q, want empty", filter)
+
}
+
}
+
})
+
}
+
}
+
+
func TestBuildAuthorPostsCursor(t *testing.T) {
+
repo := &postgresPostRepo{db: nil}
+
+
now := time.Now()
+
post := &posts.PostView{
+
URI: "at://did:plc:test123/social.coves.community.post/abc123",
+
CreatedAt: now,
+
}
+
+
cursor := repo.buildAuthorPostsCursor(post)
+
+
// Decode and verify cursor
+
decoded, err := base64.URLEncoding.DecodeString(cursor)
+
if err != nil {
+
t.Fatalf("Failed to decode cursor: %v", err)
+
}
+
+
// Should contain timestamp|uri
+
decodedStr := string(decoded)
+
if !containsStr(decodedStr, "|") {
+
t.Errorf("Cursor should contain '|' delimiter, got %q", decodedStr)
+
}
+
if !containsStr(decodedStr, post.URI) {
+
t.Errorf("Cursor should contain URI, got %q", decodedStr)
+
}
+
if !containsStr(decodedStr, now.Format(time.RFC3339Nano)) {
+
t.Errorf("Cursor should contain timestamp, got %q", decodedStr)
+
}
+
}
+
+
func TestBuildAndParseCursorRoundTrip(t *testing.T) {
+
repo := &postgresPostRepo{db: nil}
+
+
now := time.Now()
+
post := &posts.PostView{
+
URI: "at://did:plc:test123/social.coves.community.post/abc123",
+
CreatedAt: now,
+
}
+
+
// Build cursor
+
cursor := repo.buildAuthorPostsCursor(post)
+
+
// Parse it back
+
filter, args, err := repo.parseAuthorPostsCursor(&cursor, 1)
+
+
if err != nil {
+
t.Fatalf("Failed to parse cursor: %v", err)
+
}
+
+
if filter == "" {
+
t.Error("Expected non-empty filter")
+
}
+
+
if len(args) != 2 {
+
t.Errorf("Expected 2 args, got %d", len(args))
+
}
+
+
// First arg should be timestamp string
+
if ts, ok := args[0].(string); ok {
+
parsedTime, err := time.Parse(time.RFC3339Nano, ts)
+
if err != nil {
+
t.Errorf("First arg is not a valid timestamp: %v", err)
+
}
+
if !parsedTime.Equal(now) {
+
t.Errorf("Timestamp mismatch: got %v, want %v", parsedTime, now)
+
}
+
} else {
+
t.Errorf("First arg should be string, got %T", args[0])
+
}
+
+
// Second arg should be URI
+
if uri, ok := args[1].(string); ok {
+
if uri != post.URI {
+
t.Errorf("URI mismatch: got %q, want %q", uri, post.URI)
+
}
+
} else {
+
t.Errorf("Second arg should be string, got %T", args[1])
+
}
+
}
+
+
// Helper functions
+
func strPtr(s string) *string {
+
return &s
+
}
+
+
func containsStr(s, substr string) bool {
+
for i := 0; i <= len(s)-len(substr); i++ {
+
if s[i:i+len(substr)] == substr {
+
return true
+
}
+
}
+
return false
+
}
+
+
// Ensure the mock repository satisfies the interface
+
var _ posts.Repository = (*mockPostRepository)(nil)
+
+
type mockPostRepository struct {
+
db *sql.DB
+
}
+
+
func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error {
+
return nil
+
}
+
+
func (m *mockPostRepository) GetByURI(ctx context.Context, uri string) (*posts.Post, error) {
+
return nil, nil
+
}
+
+
func (m *mockPostRepository) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) {
+
return nil, nil, nil
+
}
+
+
func (m *mockPostRepository) SoftDelete(ctx context.Context, uri string) error {
+
return nil
+
}
+
+
func (m *mockPostRepository) Update(ctx context.Context, post *posts.Post) error {
+
return nil
+
}
+
+
func (m *mockPostRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error {
+
return nil
+
}
+2
CLAUDE.md
···
- [ ] ย **Does the Lexicon make sense?**ย (Would it work for other forums?)
- [ ] ย **AppView only indexes**: We don't write to CAR files, only read from firehose
+
Always prefer error codes over dataintegrity boolean markers
+
## Security-First Building
### Every Feature MUST:
+47 -1
Makefile
···
-
.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean verify-stack create-test-account mobile-full-setup
+
.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test test-all e2e-test clean verify-stack create-test-account mobile-full-setup
# Default target - show help
.DEFAULT_GOAL := help
···
RESET := \033[0m
GREEN := \033[32m
YELLOW := \033[33m
+
RED := \033[31m
# Load test database configuration from .env.dev
include .env.dev
···
@docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test stop postgres-test
@echo "$(GREEN)โœ“ Test database stopped$(RESET)"
+
test-all: ## Run ALL tests with live infrastructure (required before merge)
+
@echo ""
+
@echo "$(CYAN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)"
+
@echo "$(CYAN) FULL TEST SUITE - All tests with live infrastructure $(RESET)"
+
@echo "$(CYAN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)"
+
@echo ""
+
@echo "$(YELLOW)โ–ถ Checking infrastructure...$(RESET)"
+
@echo ""
+
@# Check dev stack is running
+
@echo " Checking dev stack (PDS, Jetstream, PLC)..."
+
@docker-compose -f docker-compose.dev.yml --env-file .env.dev ps 2>/dev/null | grep -q "Up" || \
+
(echo "$(RED) โœ— Dev stack not running. Run 'make dev-up' first.$(RESET)" && exit 1)
+
@echo " $(GREEN)โœ“ Dev stack is running$(RESET)"
+
@# Check AppView is running
+
@echo " Checking AppView (port 8081)..."
+
@curl -sf http://127.0.0.1:8081/xrpc/_health >/dev/null 2>&1 || \
+
curl -sf http://127.0.0.1:8081/ >/dev/null 2>&1 || \
+
(echo "$(RED) โœ— AppView not running. Run 'make run' in another terminal.$(RESET)" && exit 1)
+
@echo " $(GREEN)โœ“ AppView is running$(RESET)"
+
@# Check test database
+
@echo " Checking test database (port 5434)..."
+
@docker-compose -f docker-compose.dev.yml --env-file .env.dev ps postgres-test 2>/dev/null | grep -q "Up" || \
+
(echo "$(YELLOW) โš  Test database not running, starting it...$(RESET)" && \
+
docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test up -d postgres-test && \
+
sleep 3 && \
+
goose -dir internal/db/migrations postgres "postgresql://$(POSTGRES_TEST_USER):$(POSTGRES_TEST_PASSWORD)@localhost:$(POSTGRES_TEST_PORT)/$(POSTGRES_TEST_DB)?sslmode=disable" up)
+
@echo " $(GREEN)โœ“ Test database is running$(RESET)"
+
@echo ""
+
@echo "$(GREEN)โ–ถ [1/3] Unit & Package Tests (./cmd/... ./internal/...)$(RESET)"
+
@echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)"
+
@LOG_ENABLED=false go test ./cmd/... ./internal/... -timeout 120s
+
@echo ""
+
@echo "$(GREEN)โ–ถ [2/3] Integration Tests (./tests/integration/...)$(RESET)"
+
@echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)"
+
@LOG_ENABLED=false go test ./tests/integration/... -timeout 180s
+
@echo ""
+
@echo "$(GREEN)โ–ถ [3/3] E2E Tests (./tests/e2e/...)$(RESET)"
+
@echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)"
+
@LOG_ENABLED=false go test ./tests/e2e/... -timeout 180s
+
@echo ""
+
@echo "$(GREEN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)"
+
@echo "$(GREEN) โœ“ ALL TESTS PASSED - Safe to merge $(RESET)"
+
@echo "$(GREEN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)"
+
@echo ""
+
##@ Code Quality
fmt: ## Format all Go code with gofmt
+13
tests/lexicon_validation_test.go
···
package tests
import (
+
"io"
+
"log"
"os"
"path/filepath"
"strings"
···
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
)
+
// TestMain controls test setup for the tests package.
+
// Set LOG_ENABLED=false to suppress application log output during tests.
+
func TestMain(m *testing.M) {
+
// Silence logs when LOG_ENABLED=false (used by make test-all)
+
if os.Getenv("LOG_ENABLED") == "false" {
+
log.SetOutput(io.Discard)
+
}
+
+
os.Exit(m.Run())
+
}
+
func TestLexiconSchemaValidation(t *testing.T) {
// Create a new catalog
catalog := lexicon.NewBaseCatalog()
+14
tests/unit/community_service_test.go
···
"Coves/internal/core/communities"
"context"
"fmt"
+
"io"
+
"log"
"net/http"
"net/http/httptest"
+
"os"
"strings"
"sync/atomic"
"testing"
"time"
)
+
// TestMain controls test setup for the unit package.
+
// Set LOG_ENABLED=false to suppress application log output during tests.
+
func TestMain(m *testing.M) {
+
// Silence logs when LOG_ENABLED=false (used by make test-all)
+
if os.Getenv("LOG_ENABLED") == "false" {
+
log.SetOutput(io.Discard)
+
}
+
+
os.Exit(m.Run())
+
}
+
// mockCommunityRepo is a minimal mock for testing service layer
type mockCommunityRepo struct {
communities map[string]*communities.Community
+82 -17
tests/e2e/error_recovery_test.go
···
t.Run("Events processed successfully after connection", func(t *testing.T) {
// Even though we can't easily test WebSocket reconnection in unit tests,
// we can verify that events are processed correctly after establishing connection
-
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
ctx := context.Background()
+
// Pre-create user - identity events only update existing users
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: "did:plc:reconnect123",
+
Handle: "reconnect.old.test",
+
PDSURL: "http://localhost:3001",
+
})
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
+
+
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
+
+
// Send identity event with new handle
event := jetstream.JetstreamEvent{
Did: "did:plc:reconnect123",
Kind: "identity",
···
},
}
-
err := consumer.HandleIdentityEventPublic(ctx, &event)
+
err = consumer.HandleIdentityEventPublic(ctx, &event)
if err != nil {
t.Fatalf("Failed to process event: %v", err)
}
···
// Verify consumer can still process valid events after malformed ones
t.Run("Valid event after malformed events", func(t *testing.T) {
-
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
ctx := context.Background()
+
// Pre-create user - identity events only update existing users
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: "did:plc:recovery123",
+
Handle: "recovery.old.test",
+
PDSURL: "http://localhost:3001",
+
})
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
+
+
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
+
+
// Send valid identity event with new handle
validEvent := jetstream.JetstreamEvent{
Did: "did:plc:recovery123",
Kind: "identity",
···
},
}
-
err := consumer.HandleIdentityEventPublic(ctx, &validEvent)
+
err = consumer.HandleIdentityEventPublic(ctx, &validEvent)
if err != nil {
t.Fatalf("Failed to process valid event after malformed events: %v", err)
}
-
// Verify user was indexed
+
// Verify user handle was updated
user, err := userService.GetUserByDID(ctx, "did:plc:recovery123")
if err != nil {
-
t.Fatalf("User not indexed after malformed events: %v", err)
+
t.Fatalf("User not found after valid event: %v", err)
}
if user.Handle != "recovery.test" {
···
ctx := context.Background()
t.Run("Indexing continues during PDS unavailability", func(t *testing.T) {
-
// Even though PDS is "unavailable", we can still index events from Jetstream
+
// Even though PDS is "unavailable", we can still update events from Jetstream
// because we don't need to contact PDS for identity events
+
+
// Pre-create user - identity events only update existing users
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: "did:plc:pdsfail123",
+
Handle: "pdsfail.old.test",
+
PDSURL: mockPDS.URL,
+
})
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
+
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
event := jetstream.JetstreamEvent{
···
},
}
-
err := consumer.HandleIdentityEventPublic(ctx, &event)
+
err = consumer.HandleIdentityEventPublic(ctx, &event)
if err != nil {
-
t.Fatalf("Failed to index event during PDS unavailability: %v", err)
+
t.Fatalf("Failed to process event during PDS unavailability: %v", err)
}
-
// Verify user was indexed
+
// Verify user handle was updated
user, err := userService.GetUserByDID(ctx, "did:plc:pdsfail123")
if err != nil {
t.Fatalf("Failed to get user during PDS unavailability: %v", err)
···
t.Errorf("Expected handle pdsfail.test, got %s", user.Handle)
}
-
t.Log("โœ“ Indexing continues successfully even when PDS is unavailable")
+
t.Log("โœ“ Handle updates continue successfully even when PDS is unavailable")
})
t.Run("System recovers when PDS comes back online", func(t *testing.T) {
// Mark PDS as available again
shouldFail.Store(false)
+
// Pre-create user - identity events only update existing users
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: "did:plc:pdsrecovery123",
+
Handle: "pdsrecovery.old.test",
+
PDSURL: mockPDS.URL,
+
})
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
+
// Now operations that require PDS should work
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
···
},
}
-
err := consumer.HandleIdentityEventPublic(ctx, &event)
+
err = consumer.HandleIdentityEventPublic(ctx, &event)
if err != nil {
-
t.Fatalf("Failed to index event after PDS recovery: %v", err)
+
t.Fatalf("Failed to process event after PDS recovery: %v", err)
}
user, err := userService.GetUserByDID(ctx, "did:plc:pdsrecovery123")
···
t.Run("Handle updates arriving out of order", func(t *testing.T) {
did := "did:plc:outoforder123"
+
// Pre-create user - identity events only update existing users
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: did,
+
Handle: "initial.handle",
+
PDSURL: "http://localhost:3001",
+
})
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
+
// Event 3: Latest handle
event3 := jetstream.JetstreamEvent{
Did: did,
···
})
t.Run("Duplicate events at different times", func(t *testing.T) {
-
did := "did:plc:duplicate123"
+
did := "did:plc:dupevents123"
+
+
// Pre-create user - identity events only update existing users
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: did,
+
Handle: "duplicate.handle",
+
PDSURL: "http://localhost:3001",
+
})
+
if err != nil {
+
t.Fatalf("Failed to create test user: %v", err)
+
}
-
// Create user
+
// Send identity event
event1 := jetstream.JetstreamEvent{
Did: did,
Kind: "identity",
···
},
}
-
err := consumer.HandleIdentityEventPublic(ctx, &event1)
+
err = consumer.HandleIdentityEventPublic(ctx, &event1)
if err != nil {
t.Fatalf("Failed to process first event: %v", err)
}
···
t.Fatalf("Failed to process duplicate event: %v", err)
}
-
// Verify still only one user
+
// Verify still only one user with same handle
user, err := userService.GetUserByDID(ctx, did)
if err != nil {
t.Fatalf("Failed to get user: %v", err)
+43 -35
tests/integration/jetstream_consumer_test.go
···
ctx := context.Background()
-
t.Run("Index new user from identity event", func(t *testing.T) {
-
// Simulate an identity event from Jetstream
+
t.Run("Skip identity event for non-existent user", func(t *testing.T) {
+
// Identity events for users not in our database should be silently skipped
+
// Users are only indexed during OAuth login/signup, not from Jetstream events
event := jetstream.JetstreamEvent{
-
Did: "did:plc:jetstream123",
+
Did: "did:plc:nonexistent123",
Kind: "identity",
Identity: &jetstream.IdentityEvent{
-
Did: "did:plc:jetstream123",
-
Handle: "alice.jetstream.test",
+
Did: "did:plc:nonexistent123",
+
Handle: "nonexistent.jetstream.test",
Seq: 12345,
Time: time.Now().Format(time.RFC3339),
},
···
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
-
// Handle the event
+
// Handle the event - should return nil (skip silently, not error)
err := consumer.HandleIdentityEventPublic(ctx, &event)
if err != nil {
-
t.Fatalf("failed to handle identity event: %v", err)
+
t.Fatalf("expected nil error for non-existent user, got: %v", err)
}
-
// Verify user was indexed
-
user, err := userService.GetUserByDID(ctx, "did:plc:jetstream123")
-
if err != nil {
-
t.Fatalf("failed to get indexed user: %v", err)
-
}
-
-
if user.DID != "did:plc:jetstream123" {
-
t.Errorf("expected DID did:plc:jetstream123, got %s", user.DID)
-
}
-
-
if user.Handle != "alice.jetstream.test" {
-
t.Errorf("expected handle alice.jetstream.test, got %s", user.Handle)
+
// Verify user was NOT created
+
_, err = userService.GetUserByDID(ctx, "did:plc:nonexistent123")
+
if err == nil {
+
t.Fatal("expected user to NOT be created, but found in database")
}
})
···
}
})
-
t.Run("Index multiple users", func(t *testing.T) {
-
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
-
-
users := []struct {
-
did string
-
handle string
+
t.Run("Update multiple existing users via identity events", func(t *testing.T) {
+
// Pre-create users - identity events only update existing users
+
testUsers := []struct {
+
did string
+
oldHandle string
+
newHandle string
}{
-
{"did:plc:multi1", "user1.test"},
-
{"did:plc:multi2", "user2.test"},
-
{"did:plc:multi3", "user3.test"},
+
{"did:plc:multi1", "user1.old.test", "user1.new.test"},
+
{"did:plc:multi2", "user2.old.test", "user2.new.test"},
+
{"did:plc:multi3", "user3.old.test", "user3.new.test"},
+
}
+
+
// Create users first
+
for _, u := range testUsers {
+
_, err := userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: u.did,
+
Handle: u.oldHandle,
+
PDSURL: "https://bsky.social",
+
})
+
if err != nil {
+
t.Fatalf("failed to create user %s: %v", u.oldHandle, err)
+
}
}
-
for _, u := range users {
+
consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "")
+
+
// Send identity events with new handles
+
for _, u := range testUsers {
event := jetstream.JetstreamEvent{
Did: u.did,
Kind: "identity",
Identity: &jetstream.IdentityEvent{
Did: u.did,
-
Handle: u.handle,
+
Handle: u.newHandle,
Seq: 12345,
Time: time.Now().Format(time.RFC3339),
},
···
err := consumer.HandleIdentityEventPublic(ctx, &event)
if err != nil {
-
t.Fatalf("failed to index user %s: %v", u.handle, err)
+
t.Fatalf("failed to handle identity event for %s: %v", u.newHandle, err)
}
}
-
// Verify all users indexed
-
for _, u := range users {
+
// Verify all users have updated handles
+
for _, u := range testUsers {
user, err := userService.GetUserByDID(ctx, u.did)
if err != nil {
t.Fatalf("user %s not found: %v", u.did, err)
}
-
if user.Handle != u.handle {
-
t.Errorf("expected handle %s, got %s", u.handle, user.Handle)
+
if user.Handle != u.newHandle {
+
t.Errorf("expected handle %s, got %s", u.newHandle, user.Handle)
}
}
})
+42 -21
internal/api/routes/user.go
···
"errors"
"log"
"net/http"
-
"time"
+
"strings"
"github.com/go-chi/chi/v5"
)
···
// GetProfile handles social.coves.actor.getprofile
// Query endpoint that retrieves a user profile by DID or handle
+
// Returns profileViewDetailed with stats per lexicon specification
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get actor parameter (DID or handle)
actor := r.URL.Query().Get("actor")
if actor == "" {
-
http.Error(w, "actor parameter is required", http.StatusBadRequest)
+
writeXRPCError(w, "InvalidRequest", "actor parameter is required", http.StatusBadRequest)
return
}
-
var user *users.User
-
var err error
-
-
// Determine if actor is a DID or handle
-
// DIDs start with "did:", handles don't
-
if len(actor) > 4 && actor[:4] == "did:" {
-
user, err = h.userService.GetUserByDID(ctx, actor)
+
// Resolve actor to DID
+
var did string
+
if strings.HasPrefix(actor, "did:") {
+
did = actor
} else {
-
user, err = h.userService.GetUserByHandle(ctx, actor)
+
// Resolve handle to DID
+
resolvedDID, err := h.userService.ResolveHandleToDID(ctx, actor)
+
if err != nil {
+
writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound)
+
return
+
}
+
did = resolvedDID
}
+
// Get full profile with stats
+
profile, err := h.userService.GetProfile(ctx, did)
if err != nil {
-
http.Error(w, "user not found", http.StatusNotFound)
+
if errors.Is(err, users.ErrUserNotFound) {
+
writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound)
+
return
+
}
+
log.Printf("Failed to get profile for %s: %v", did, err)
+
writeXRPCError(w, "InternalError", "failed to get profile", http.StatusInternalServerError)
return
}
-
// Minimal profile response (matching lexicon structure)
-
response := map[string]interface{}{
-
"did": user.DID,
-
"profile": map[string]interface{}{
-
"handle": user.Handle,
-
"createdAt": user.CreatedAt.Format(time.RFC3339),
-
},
+
// Marshal to bytes first to avoid partial writes on encoding errors
+
responseBytes, err := json.Marshal(profile)
+
if err != nil {
+
log.Printf("Failed to marshal profile response: %v", err)
+
writeXRPCError(w, "InternalError", "failed to encode response", http.StatusInternalServerError)
+
return
}
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
log.Printf("Failed to encode response: %v", err)
+
if _, err := w.Write(responseBytes); err != nil {
+
log.Printf("Failed to write response: %v", err)
+
}
+
}
+
+
// writeXRPCError writes a standardized XRPC error response
+
func writeXRPCError(w http.ResponseWriter, errorName, message string, statusCode int) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(statusCode)
+
if err := json.NewEncoder(w).Encode(map[string]interface{}{
+
"error": errorName,
+
"message": message,
+
}); err != nil {
+
log.Printf("Failed to encode error response: %v", err)
}
}
+33 -1
internal/core/users/service.go
···
if err == nil && user != nil {
return user.DID, nil
}
-
// If not found locally, fall through to external resolution
+
// Log database errors (but not "not found" which is expected for unindexed users)
+
if err != nil && !errors.Is(err, ErrUserNotFound) {
+
log.Printf("Warning: database error during handle lookup for %s (falling back to external resolution): %v", handle, err)
+
}
+
// If not found locally or error, fall through to external resolution
// Slow path: use identity resolver for external DNS/HTTPS resolution
did, _, err := s.identityResolver.ResolveHandle(ctx, handle)
···
return nil
}
+
// GetProfile retrieves a user's full profile with aggregated statistics.
+
// Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon.
+
func (s *userService) GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) {
+
did = strings.TrimSpace(did)
+
if did == "" {
+
return nil, fmt.Errorf("DID is required")
+
}
+
+
// Get the user first
+
user, err := s.userRepo.GetByDID(ctx, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get user: %w", err)
+
}
+
+
// Get aggregated stats
+
stats, err := s.userRepo.GetProfileStats(ctx, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get profile stats: %w", err)
+
}
+
+
return &ProfileViewDetailed{
+
DID: user.DID,
+
Handle: user.Handle,
+
CreatedAt: user.CreatedAt,
+
Stats: stats,
+
}, nil
+
}
+
func (s *userService) validateCreateRequest(req CreateUserRequest) error {
if strings.TrimSpace(req.DID) == "" {
return fmt.Errorf("DID is required")
+22
internal/core/users/user.go
···
RefreshJwt string `json:"refreshJwt"`
PDSURL string `json:"pdsUrl"`
}
+
+
// ProfileStats contains aggregated user statistics
+
// Matches the social.coves.actor.defs#profileStats lexicon
+
type ProfileStats struct {
+
PostCount int `json:"postCount"`
+
CommentCount int `json:"commentCount"`
+
CommunityCount int `json:"communityCount"` // Number of communities subscribed to
+
Reputation int `json:"reputation"` // Global reputation score (sum across communities)
+
MembershipCount int `json:"membershipCount"` // Number of communities with active membership
+
}
+
+
// ProfileViewDetailed is the full profile response
+
// Matches the social.coves.actor.defs#profileViewDetailed lexicon
+
type ProfileViewDetailed struct {
+
DID string `json:"did"`
+
Handle string `json:"handle,omitempty"`
+
CreatedAt time.Time `json:"createdAt"`
+
Stats *ProfileStats `json:"stats,omitempty"`
+
// Future fields (require additional infrastructure):
+
// DisplayName, Bio, Avatar, Banner (from PDS profile record)
+
// Viewer (requires user-to-user blocking infrastructure)
+
}
+265
internal/api/handlers/actor/get_comments.go
···
+
package actor
+
+
import (
+
"encoding/json"
+
"errors"
+
"log"
+
"net/http"
+
"strconv"
+
"strings"
+
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
+
)
+
+
// GetCommentsHandler handles actor comment retrieval
+
type GetCommentsHandler struct {
+
commentService comments.Service
+
userService users.UserService
+
voteService votes.Service
+
}
+
+
// NewGetCommentsHandler creates a new actor comments handler
+
func NewGetCommentsHandler(
+
commentService comments.Service,
+
userService users.UserService,
+
voteService votes.Service,
+
) *GetCommentsHandler {
+
return &GetCommentsHandler{
+
commentService: commentService,
+
userService: userService,
+
voteService: voteService,
+
}
+
}
+
+
// HandleGetComments retrieves comments by an actor (user)
+
// GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=...
+
func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse query parameters
+
req, err := h.parseRequest(r)
+
if err != nil {
+
// Check if it's an actor not found error (from handle resolution)
+
var actorNotFound *actorNotFoundError
+
if errors.As(err, &actorNotFound) {
+
writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found")
+
return
+
}
+
+
// Check if it's an infrastructure failure during resolution
+
// (database down, DNS failures, network errors, etc.)
+
var resolutionFailed *resolutionFailedError
+
if errors.As(err, &resolutionFailed) {
+
log.Printf("ERROR: Actor resolution infrastructure failure: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity")
+
return
+
}
+
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
return
+
}
+
+
// Get viewer DID for populating viewer state (optional)
+
viewerDID := middleware.GetUserDID(r)
+
if viewerDID != "" {
+
req.ViewerDID = &viewerDID
+
}
+
+
// Get actor comments from service
+
response, err := h.commentService.GetActorComments(r.Context(), req)
+
if err != nil {
+
handleCommentServiceError(w, err)
+
return
+
}
+
+
// Populate viewer vote state if authenticated
+
h.populateViewerVoteState(r, response)
+
+
// Pre-encode response to buffer before writing headers
+
// This ensures we can return a proper error if encoding fails
+
responseBytes, err := json.Marshal(response)
+
if err != nil {
+
log.Printf("ERROR: Failed to encode actor comments response: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response")
+
return
+
}
+
+
// Return comments
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if _, err := w.Write(responseBytes); err != nil {
+
log.Printf("ERROR: Failed to write actor comments response: %v", err)
+
}
+
}
+
+
// parseRequest parses query parameters into GetActorCommentsRequest
+
func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) {
+
req := &comments.GetActorCommentsRequest{}
+
+
// Required: actor (handle or DID)
+
actor := r.URL.Query().Get("actor")
+
if actor == "" {
+
return nil, &validationError{field: "actor", message: "actor parameter is required"}
+
}
+
// Validate actor length to prevent DoS via massive strings
+
// Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer)
+
// Max handle length is 253 chars (DNS limit)
+
const maxActorLength = 2048
+
if len(actor) > maxActorLength {
+
return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"}
+
}
+
+
// Resolve actor to DID if it's a handle
+
actorDID, err := h.resolveActor(r, actor)
+
if err != nil {
+
return nil, err
+
}
+
req.ActorDID = actorDID
+
+
// Optional: community (handle or DID)
+
req.Community = r.URL.Query().Get("community")
+
+
// Optional: limit (default: 50, max: 100)
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
limit, err := strconv.Atoi(limitStr)
+
if err != nil {
+
return nil, &validationError{field: "limit", message: "limit must be a valid integer"}
+
}
+
req.Limit = limit
+
}
+
+
// Optional: cursor
+
if cursor := r.URL.Query().Get("cursor"); cursor != "" {
+
req.Cursor = &cursor
+
}
+
+
return req, nil
+
}
+
+
// resolveActor converts an actor identifier (handle or DID) to a DID
+
func (h *GetCommentsHandler) resolveActor(r *http.Request, actor string) (string, error) {
+
// If it's already a DID, return it
+
if strings.HasPrefix(actor, "did:") {
+
return actor, nil
+
}
+
+
// It's a handle - resolve to DID using user service
+
did, err := h.userService.ResolveHandleToDID(r.Context(), actor)
+
if err != nil {
+
// Check for context errors (timeouts, cancellation) - these are infrastructure errors
+
if r.Context().Err() != nil {
+
log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err)
+
return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()}
+
}
+
+
// Check for common "not found" patterns in error message
+
errStr := err.Error()
+
isNotFound := strings.Contains(errStr, "not found") ||
+
strings.Contains(errStr, "no rows") ||
+
strings.Contains(errStr, "unable to resolve")
+
+
if isNotFound {
+
return "", &actorNotFoundError{actor: actor}
+
}
+
+
// For other errors (network, database, DNS failures), return infrastructure error
+
// This ensures users see "internal error" not "actor not found" for real problems
+
log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err)
+
return "", &resolutionFailedError{actor: actor, cause: err}
+
}
+
+
return did, nil
+
}
+
+
// populateViewerVoteState enriches comment views with the authenticated user's vote state
+
func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) {
+
if h.voteService == nil || response == nil || len(response.Comments) == 0 {
+
return
+
}
+
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
return
+
}
+
+
userDID := middleware.GetUserDID(r)
+
if userDID == "" {
+
return
+
}
+
+
// Ensure vote cache is populated from PDS
+
if err := h.voteService.EnsureCachePopulated(r.Context(), session); err != nil {
+
log.Printf("Warning: failed to populate vote cache for actor comments: %v", err)
+
return
+
}
+
+
// Collect comment URIs to batch lookup
+
commentURIs := make([]string, 0, len(response.Comments))
+
for _, comment := range response.Comments {
+
if comment != nil {
+
commentURIs = append(commentURIs, comment.URI)
+
}
+
}
+
+
// Get viewer votes for all comments
+
viewerVotes := h.voteService.GetViewerVotesForSubjects(userDID, commentURIs)
+
+
// Populate viewer state on each comment
+
for _, comment := range response.Comments {
+
if comment != nil {
+
if vote, exists := viewerVotes[comment.URI]; exists {
+
comment.Viewer = &comments.CommentViewerState{
+
Vote: &vote.Direction,
+
VoteURI: &vote.URI,
+
}
+
}
+
}
+
}
+
}
+
+
// handleCommentServiceError maps service errors to HTTP responses
+
func handleCommentServiceError(w http.ResponseWriter, err error) {
+
if err == nil {
+
return
+
}
+
+
errStr := err.Error()
+
+
// Check for validation errors
+
if strings.Contains(errStr, "invalid request") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", errStr)
+
return
+
}
+
+
// Check for not found errors
+
if comments.IsNotFound(err) || strings.Contains(errStr, "not found") {
+
writeError(w, http.StatusNotFound, "NotFound", "Resource not found")
+
return
+
}
+
+
// Check for authorization errors
+
if errors.Is(err, comments.ErrNotAuthorized) {
+
writeError(w, http.StatusForbidden, "NotAuthorized", "Not authorized")
+
return
+
}
+
+
// Default to internal server error
+
log.Printf("ERROR: Comment service error: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred")
+
}
+
+
// validationError represents a validation error for a specific field
+
type validationError struct {
+
field string
+
message string
+
}
+
+
func (e *validationError) Error() string {
+
return e.message
+
}
+617
internal/api/handlers/actor/get_comments_test.go
···
+
package actor
+
+
import (
+
"context"
+
"encoding/json"
+
"errors"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"Coves/internal/core/comments"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
)
+
+
// mockCommentService implements a comment service interface for testing
+
type mockCommentService struct {
+
getActorCommentsFunc func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error)
+
}
+
+
func (m *mockCommentService) GetActorComments(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
if m.getActorCommentsFunc != nil {
+
return m.getActorCommentsFunc(ctx, req)
+
}
+
return &comments.GetActorCommentsResponse{
+
Comments: []*comments.CommentView{},
+
Cursor: nil,
+
}, nil
+
}
+
+
// Implement other Service methods as no-ops
+
func (m *mockCommentService) GetComments(ctx context.Context, req *comments.GetCommentsRequest) (*comments.GetCommentsResponse, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommentService) CreateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.CreateCommentRequest) (*comments.CreateCommentResponse, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommentService) UpdateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.UpdateCommentRequest) (*comments.UpdateCommentResponse, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommentService) DeleteComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.DeleteCommentRequest) error {
+
return nil
+
}
+
+
// mockUserServiceForComments implements users.UserService for testing getComments
+
type mockUserServiceForComments struct {
+
resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error)
+
}
+
+
func (m *mockUserServiceForComments) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) {
+
return nil, nil
+
}
+
+
func (m *mockUserServiceForComments) GetUserByDID(ctx context.Context, did string) (*users.User, error) {
+
return nil, nil
+
}
+
+
func (m *mockUserServiceForComments) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) {
+
return nil, nil
+
}
+
+
func (m *mockUserServiceForComments) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) {
+
return nil, nil
+
}
+
+
func (m *mockUserServiceForComments) ResolveHandleToDID(ctx context.Context, handle string) (string, error) {
+
if m.resolveHandleToDIDFunc != nil {
+
return m.resolveHandleToDIDFunc(ctx, handle)
+
}
+
return "did:plc:testuser", nil
+
}
+
+
func (m *mockUserServiceForComments) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) {
+
return nil, nil
+
}
+
+
func (m *mockUserServiceForComments) IndexUser(ctx context.Context, did, handle, pdsURL string) error {
+
return nil
+
}
+
+
func (m *mockUserServiceForComments) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) {
+
return nil, nil
+
}
+
+
// mockVoteServiceForComments implements votes.Service for testing getComments
+
type mockVoteServiceForComments struct{}
+
+
func (m *mockVoteServiceForComments) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {
+
return nil, nil
+
}
+
+
func (m *mockVoteServiceForComments) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error {
+
return nil
+
}
+
+
func (m *mockVoteServiceForComments) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error {
+
return nil
+
}
+
+
func (m *mockVoteServiceForComments) GetViewerVote(userDID, subjectURI string) *votes.CachedVote {
+
return nil
+
}
+
+
func (m *mockVoteServiceForComments) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {
+
return nil
+
}
+
+
func TestGetCommentsHandler_Success(t *testing.T) {
+
createdAt := time.Now().Format(time.RFC3339)
+
indexedAt := time.Now().Format(time.RFC3339)
+
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
return &comments.GetActorCommentsResponse{
+
Comments: []*comments.CommentView{
+
{
+
URI: "at://did:plc:testuser/social.coves.community.comment/abc123",
+
CID: "bafytest123",
+
Content: "Test comment content",
+
CreatedAt: createdAt,
+
IndexedAt: indexedAt,
+
Author: &posts.AuthorView{
+
DID: "did:plc:testuser",
+
Handle: "test.user",
+
},
+
Stats: &comments.CommentStats{
+
Upvotes: 5,
+
Downvotes: 1,
+
Score: 4,
+
ReplyCount: 2,
+
},
+
},
+
},
+
}, nil
+
},
+
}
+
mockUsers := &mockUserServiceForComments{}
+
mockVotes := &mockVoteServiceForComments{}
+
+
handler := NewGetCommentsHandler(mockComments, mockUsers, mockVotes)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
var response comments.GetActorCommentsResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if len(response.Comments) != 1 {
+
t.Errorf("Expected 1 comment in response, got %d", len(response.Comments))
+
}
+
+
if response.Comments[0].URI != "at://did:plc:testuser/social.coves.community.comment/abc123" {
+
t.Errorf("Expected correct comment URI, got '%s'", response.Comments[0].URI)
+
}
+
+
if response.Comments[0].Content != "Test comment content" {
+
t.Errorf("Expected correct comment content, got '%s'", response.Comments[0].Content)
+
}
+
}
+
+
func TestGetCommentsHandler_MissingActor(t *testing.T) {
+
handler := NewGetCommentsHandler(
+
&mockCommentService{},
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", rec.Code)
+
}
+
+
var response ErrorResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Error != "InvalidRequest" {
+
t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error)
+
}
+
}
+
+
func TestGetCommentsHandler_InvalidLimit(t *testing.T) {
+
handler := NewGetCommentsHandler(
+
&mockCommentService{},
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=abc", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", rec.Code)
+
}
+
+
var response ErrorResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Error != "InvalidRequest" {
+
t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error)
+
}
+
}
+
+
func TestGetCommentsHandler_ActorNotFound(t *testing.T) {
+
mockUsers := &mockUserServiceForComments{
+
resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) {
+
return "", posts.ErrActorNotFound
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
&mockCommentService{},
+
mockUsers,
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=nonexistent.user", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusNotFound {
+
t.Errorf("Expected status 404, got %d", rec.Code)
+
}
+
+
var response ErrorResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Error != "ActorNotFound" {
+
t.Errorf("Expected error 'ActorNotFound', got '%s'", response.Error)
+
}
+
}
+
+
func TestGetCommentsHandler_ActorLengthExceedsMax(t *testing.T) {
+
handler := NewGetCommentsHandler(
+
&mockCommentService{},
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
// Create an actor parameter that exceeds 2048 characters using valid URL characters
+
longActorBytes := make([]byte, 2100)
+
for i := range longActorBytes {
+
longActorBytes[i] = 'a'
+
}
+
longActor := "did:plc:" + string(longActorBytes)
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor="+longActor, nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", rec.Code)
+
}
+
}
+
+
func TestGetCommentsHandler_InvalidCursor(t *testing.T) {
+
// The handleCommentServiceError function checks for "invalid request" in error message
+
// to return a BadRequest. An invalid cursor error falls under this category.
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
return nil, errors.New("invalid request: invalid cursor format")
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=invalid", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", rec.Code)
+
}
+
+
var response ErrorResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Error != "InvalidRequest" {
+
t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error)
+
}
+
}
+
+
func TestGetCommentsHandler_MethodNotAllowed(t *testing.T) {
+
handler := NewGetCommentsHandler(
+
&mockCommentService{},
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getComments", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", rec.Code)
+
}
+
}
+
+
func TestGetCommentsHandler_HandleResolution(t *testing.T) {
+
resolvedDID := ""
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
resolvedDID = req.ActorDID
+
return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil
+
},
+
}
+
mockUsers := &mockUserServiceForComments{
+
resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) {
+
if handle == "test.user" {
+
return "did:plc:resolveduser123", nil
+
}
+
return "", posts.ErrActorNotFound
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
mockUsers,
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
if resolvedDID != "did:plc:resolveduser123" {
+
t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID)
+
}
+
}
+
+
func TestGetCommentsHandler_DIDPassThrough(t *testing.T) {
+
receivedDID := ""
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
receivedDID = req.ActorDID
+
return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
// When actor is already a DID, it should pass through without resolution
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:directuser", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
if receivedDID != "did:plc:directuser" {
+
t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID)
+
}
+
}
+
+
func TestGetCommentsHandler_EmptyCommentsArray(t *testing.T) {
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
return &comments.GetActorCommentsResponse{
+
Comments: []*comments.CommentView{},
+
}, nil
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:newuser", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
var response comments.GetActorCommentsResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Comments == nil {
+
t.Error("Expected comments array to be non-nil (empty array), got nil")
+
}
+
+
if len(response.Comments) != 0 {
+
t.Errorf("Expected 0 comments for new user, got %d", len(response.Comments))
+
}
+
}
+
+
func TestGetCommentsHandler_WithCursor(t *testing.T) {
+
receivedCursor := ""
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
if req.Cursor != nil {
+
receivedCursor = *req.Cursor
+
}
+
nextCursor := "page2cursor"
+
return &comments.GetActorCommentsResponse{
+
Comments: []*comments.CommentView{},
+
Cursor: &nextCursor,
+
}, nil
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=testcursor123", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
if receivedCursor != "testcursor123" {
+
t.Errorf("Expected cursor 'testcursor123', got '%s'", receivedCursor)
+
}
+
+
var response comments.GetActorCommentsResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Cursor == nil || *response.Cursor != "page2cursor" {
+
t.Error("Expected response to include next cursor")
+
}
+
}
+
+
func TestGetCommentsHandler_WithLimit(t *testing.T) {
+
receivedLimit := 0
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
receivedLimit = req.Limit
+
return &comments.GetActorCommentsResponse{
+
Comments: []*comments.CommentView{},
+
}, nil
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=25", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
if receivedLimit != 25 {
+
t.Errorf("Expected limit 25, got %d", receivedLimit)
+
}
+
}
+
+
func TestGetCommentsHandler_WithCommunityFilter(t *testing.T) {
+
receivedCommunity := ""
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
receivedCommunity = req.Community
+
return &comments.GetActorCommentsResponse{
+
Comments: []*comments.CommentView{},
+
}, nil
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&community=did:plc:community123", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", rec.Code)
+
}
+
+
if receivedCommunity != "did:plc:community123" {
+
t.Errorf("Expected community 'did:plc:community123', got '%s'", receivedCommunity)
+
}
+
}
+
+
func TestGetCommentsHandler_ServiceError_Returns500(t *testing.T) {
+
// Test that generic service errors (database failures, etc.) return 500
+
mockComments := &mockCommentService{
+
getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {
+
return nil, errors.New("database connection failed")
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
mockComments,
+
&mockUserServiceForComments{},
+
&mockVoteServiceForComments{},
+
)
+
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
if rec.Code != http.StatusInternalServerError {
+
t.Errorf("Expected status 500, got %d", rec.Code)
+
}
+
+
var response ErrorResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Error != "InternalServerError" {
+
t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error)
+
}
+
+
// Verify error message doesn't leak internal details
+
if response.Message == "database connection failed" {
+
t.Error("Error message should not leak internal error details")
+
}
+
}
+
+
func TestGetCommentsHandler_ResolutionFailedError_Returns500(t *testing.T) {
+
// Test that infrastructure failures during handle resolution return 500, not 400
+
mockUsers := &mockUserServiceForComments{
+
resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) {
+
// Simulate a database failure during resolution
+
return "", errors.New("connection refused")
+
},
+
}
+
+
handler := NewGetCommentsHandler(
+
&mockCommentService{},
+
mockUsers,
+
&mockVoteServiceForComments{},
+
)
+
+
// Use a handle (not a DID) to trigger resolution
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil)
+
rec := httptest.NewRecorder()
+
+
handler.HandleGetComments(rec, req)
+
+
// Infrastructure failures should return 500, not 400 or 404
+
if rec.Code != http.StatusInternalServerError {
+
t.Errorf("Expected status 500 for infrastructure failure, got %d", rec.Code)
+
}
+
+
var response ErrorResponse
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if response.Error != "InternalServerError" {
+
t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error)
+
}
+
}
+60
internal/atproto/lexicon/social/coves/actor/getComments.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.actor.getComments",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get a user's comments for their profile page.",
+
"parameters": {
+
"type": "params",
+
"required": ["actor"],
+
"properties": {
+
"actor": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the user"
+
},
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "Filter to comments in a specific community"
+
},
+
"limit": {
+
"type": "integer",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["comments"],
+
"properties": {
+
"comments": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.community.comment.defs#commentView"
+
}
+
},
+
"cursor": {
+
"type": "string"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotFound",
+
"description": "Actor not found"
+
}
+
]
+
}
+
}
+
}
+9
internal/core/comments/comment.go
···
Neg *bool `json:"neg,omitempty"`
Val string `json:"val"`
}
+
+
// ListByCommenterRequest defines the parameters for fetching a user's comments
+
// Used by social.coves.actor.getComments endpoint
+
type ListByCommenterRequest struct {
+
CommenterDID string // Required: DID of the commenter
+
CommunityDID *string // Optional: filter to comments in a specific community
+
Limit int // Max comments to return (1-100)
+
Cursor *string // Pagination cursor from previous response
+
}
+151
internal/db/postgres/comment_repo.go
···
return result, nil
}
+
// ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination
+
// Used for user profile comment history (social.coves.actor.getComments)
+
// Supports optional community filtering and returns next page cursor
+
// Uses chronological ordering (newest first) with composite key cursor for stable pagination
+
func (r *postgresCommentRepo) ListByCommenterWithCursor(ctx context.Context, req comments.ListByCommenterRequest) ([]*comments.Comment, *string, error) {
+
// Parse cursor for pagination
+
cursorFilter, cursorValues, err := r.parseCommenterCursor(req.Cursor)
+
if err != nil {
+
return nil, nil, fmt.Errorf("invalid cursor: %w", err)
+
}
+
+
// Build community filter if provided
+
// Parameter numbering: $1=commenterDID, $2=limit+1 (for pagination detection)
+
// Cursor values (if present) use $3 and $4, community DID comes after
+
var communityFilter string
+
var communityValue []interface{}
+
paramOffset := 2 + len(cursorValues) // Start after $1, $2, and any cursor params
+
if req.CommunityDID != nil && *req.CommunityDID != "" {
+
paramOffset++
+
communityFilter = fmt.Sprintf("AND c.root_uri IN (SELECT uri FROM posts WHERE community_did = $%d)", paramOffset)
+
communityValue = append(communityValue, *req.CommunityDID)
+
}
+
+
// Build complete query with JOINs and filters
+
// LEFT JOIN prevents data loss when user record hasn't been indexed yet
+
query := fmt.Sprintf(`
+
SELECT
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
COALESCE(u.handle, c.commenter_did) as author_handle
+
FROM comments c
+
LEFT JOIN users u ON c.commenter_did = u.did
+
WHERE c.commenter_did = $1
+
AND c.deleted_at IS NULL
+
%s
+
%s
+
ORDER BY c.created_at DESC, c.uri DESC
+
LIMIT $2
+
`, communityFilter, cursorFilter)
+
+
// Prepare query arguments
+
args := []interface{}{req.CommenterDID, req.Limit + 1} // +1 to detect next page
+
args = append(args, cursorValues...)
+
args = append(args, communityValue...)
+
+
// Execute query
+
rows, err := r.db.QueryContext(ctx, query, args...)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to query comments by commenter: %w", err)
+
}
+
defer func() {
+
if err := rows.Close(); err != nil {
+
log.Printf("Failed to close rows: %v", err)
+
}
+
}()
+
+
// Scan results
+
var result []*comments.Comment
+
for rows.Next() {
+
var comment comments.Comment
+
var langs pq.StringArray
+
var authorHandle string
+
+
err := rows.Scan(
+
&comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
+
&comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
+
&comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
+
&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,
+
&comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
+
&authorHandle,
+
)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to scan comment: %w", err)
+
}
+
+
comment.Langs = langs
+
comment.CommenterHandle = authorHandle
+
result = append(result, &comment)
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, nil, fmt.Errorf("error iterating comments: %w", err)
+
}
+
+
// Handle pagination cursor
+
var nextCursor *string
+
if len(result) > req.Limit && req.Limit > 0 {
+
result = result[:req.Limit]
+
lastComment := result[len(result)-1]
+
cursorStr := r.buildCommenterCursor(lastComment)
+
nextCursor = &cursorStr
+
}
+
+
return result, nextCursor, nil
+
}
+
+
// parseCommenterCursor decodes pagination cursor for commenter comments
+
// Cursor format: createdAt|uri (same as "new" sort for other comment queries)
+
//
+
// IMPORTANT: This function returns a filter string with hardcoded parameter numbers ($3, $4).
+
// The caller (ListByCommenterWithCursor) must ensure parameters are ordered as:
+
// $1=commenterDID, $2=limit+1, $3=createdAt, $4=uri, then community DID if present.
+
// If you modify the parameter order in the caller, you must update the filter here.
+
func (r *postgresCommentRepo) parseCommenterCursor(cursor *string) (string, []interface{}, error) {
+
if cursor == nil || *cursor == "" {
+
return "", nil, nil
+
}
+
+
// Validate cursor size to prevent DoS via massive base64 strings
+
const maxCursorSize = 1024
+
if len(*cursor) > maxCursorSize {
+
return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize)
+
}
+
+
// Decode base64 cursor
+
decoded, err := base64.URLEncoding.DecodeString(*cursor)
+
if err != nil {
+
return "", nil, fmt.Errorf("invalid cursor encoding")
+
}
+
+
// Parse cursor: createdAt|uri
+
parts := strings.Split(string(decoded), "|")
+
if len(parts) != 2 {
+
return "", nil, fmt.Errorf("invalid cursor format")
+
}
+
+
createdAt := parts[0]
+
uri := parts[1]
+
+
// Validate AT-URI format
+
if !strings.HasPrefix(uri, "at://") {
+
return "", nil, fmt.Errorf("invalid cursor URI")
+
}
+
+
filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))`
+
return filter, []interface{}{createdAt, uri}, nil
+
}
+
+
// buildCommenterCursor creates pagination cursor from last comment
+
// Uses createdAt|uri format for stable pagination
+
func (r *postgresCommentRepo) buildCommenterCursor(comment *comments.Comment) string {
+
cursorStr := fmt.Sprintf("%s|%s",
+
comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
+
comment.URI)
+
return base64.URLEncoding.EncodeToString([]byte(cursorStr))
+
}
+
// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination
// Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at)
// Uses cursor-based pagination with composite keys for consistent ordering
···
// If votes table doesn't exist yet, return empty map instead of error
// This allows the API to work before votes indexing is fully implemented
if strings.Contains(err.Error(), "does not exist") {
+
log.Printf("WARN: Votes table does not exist, returning empty vote state for %d comments", len(commentURIs))
return make(map[string]interface{}), nil
}
return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
+14 -56
internal/api/handlers/community/block.go
···
return
}
-
// Extract authenticated user DID and access token from request context (injected by auth middleware)
-
userDID := middleware.GetUserDID(r)
-
if userDID == "" {
+
// Get OAuth session from context (injected by auth middleware)
+
// The session contains the user's DID and credentials needed for DPoP authentication
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
return
}
-
userAccessToken := middleware.GetUserAccessToken(r)
-
if userAccessToken == "" {
-
writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token")
-
return
-
}
-
-
// Resolve community identifier (handle or DID) to DID
-
// This allows users to block by handle: @gaming.community.coves.social or !gaming@coves.social
-
communityDID, err := h.service.ResolveCommunityIdentifier(r.Context(), req.Community)
-
if err != nil {
-
if communities.IsNotFound(err) {
-
writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found")
-
return
-
}
-
if communities.IsValidationError(err) {
-
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
-
return
-
}
-
log.Printf("Failed to resolve community identifier %s: %v", req.Community, err)
-
writeError(w, http.StatusInternalServerError, "InternalError", "Failed to resolve community")
-
return
-
}
-
-
// Block via service (write-forward to PDS) using resolved DID
-
block, err := h.service.BlockCommunity(r.Context(), userDID, userAccessToken, communityDID)
+
// Block via service (write-forward to PDS with DPoP authentication)
+
// Service handles identifier resolution (DIDs, handles, scoped identifiers)
+
block, err := h.service.BlockCommunity(r.Context(), session, req.Community)
if err != nil {
handleServiceError(w, err)
return
···
return
}
-
// Extract authenticated user DID and access token from request context (injected by auth middleware)
-
userDID := middleware.GetUserDID(r)
-
if userDID == "" {
+
// Get OAuth session from context (injected by auth middleware)
+
// The session contains the user's DID and credentials needed for DPoP authentication
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
return
}
-
userAccessToken := middleware.GetUserAccessToken(r)
-
if userAccessToken == "" {
-
writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token")
-
return
-
}
-
-
// Resolve community identifier (handle or DID) to DID
-
// This allows users to unblock by handle: @gaming.community.coves.social or !gaming@coves.social
-
communityDID, err := h.service.ResolveCommunityIdentifier(r.Context(), req.Community)
-
if err != nil {
-
if communities.IsNotFound(err) {
-
writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found")
-
return
-
}
-
if communities.IsValidationError(err) {
-
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
-
return
-
}
-
log.Printf("Failed to resolve community identifier %s: %v", req.Community, err)
-
writeError(w, http.StatusInternalServerError, "InternalError", "Failed to resolve community")
-
return
-
}
-
-
// Unblock via service (delete record on PDS) using resolved DID
-
err = h.service.UnblockCommunity(r.Context(), userDID, userAccessToken, communityDID)
+
// Unblock via service (delete record on PDS with DPoP authentication)
+
// Service handles identifier resolution (DIDs, handles, scoped identifiers)
+
err := h.service.UnblockCommunity(r.Context(), session, req.Community)
if err != nil {
handleServiceError(w, err)
return
+5
internal/atproto/pds/errors.go
···
func IsAuthError(err error) bool {
return errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden)
}
+
+
// IsConflictError returns true if the error indicates a conflict (e.g., duplicate record).
+
func IsConflictError(err error) bool {
+
return errors.Is(err, ErrConflict)
+
}
+3 -5
tests/e2e/user_signup_test.go
···
}
var result struct {
-
DID string `json:"did"`
-
Profile struct {
-
Handle string `json:"handle"`
-
} `json:"profile"`
+
DID string `json:"did"`
+
Handle string `json:"handle"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", fmt.Errorf("failed to decode response: %w", err)
}
-
return result.DID, result.Profile.Handle, nil
+
return result.DID, result.Handle, nil
}
+5 -5
tests/integration/author_posts_e2e_test.go
···
// Setup services
resolver := identity.NewResolver(db, identity.DefaultConfig())
userService := users.NewUserService(userRepo, resolver, pdsURL)
-
communityService := communities.NewCommunityService(communityRepo, pdsURL, getTestInstanceDID(), "", nil)
+
communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil)
postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL)
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
···
resolver := identity.NewResolver(db, identity.DefaultConfig())
userService := users.NewUserService(userRepo, resolver, getTestPDSURL())
-
communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil)
+
communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil)
postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL())
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
···
resolver := identity.NewResolver(db, identity.DefaultConfig())
userService := users.NewUserService(userRepo, resolver, getTestPDSURL())
-
communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil)
+
communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil)
postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL())
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
···
// Setup services
resolver := identity.NewResolver(db, identity.DefaultConfig())
userService := users.NewUserService(userRepo, resolver, pdsURL)
-
communityService := communities.NewCommunityService(communityRepo, pdsURL, getTestInstanceDID(), "", nil)
+
communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil)
postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL)
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
···
resolver := identity.NewResolver(db, identity.DefaultConfig())
userService := users.NewUserService(userRepo, resolver, getTestPDSURL())
-
communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil)
+
communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil)
postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL())
voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())
+8 -4
tests/integration/community_identifier_resolution_test.go
···
instanceDID = "did:web:" + instanceDomain
}
-
service := communities.NewCommunityService(
+
service := communities.NewCommunityServiceWithPDSFactory(
repo,
pdsURL,
instanceDID,
instanceDomain,
provisioner,
+
nil,
)
// Create a test community to resolve
···
}
provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
-
service := communities.NewCommunityService(
+
service := communities.NewCommunityServiceWithPDSFactory(
repo,
pdsURL,
instanceDID,
instanceDomain,
provisioner,
+
nil,
)
tests := []struct {
···
}
provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
-
service := communities.NewCommunityService(
+
service := communities.NewCommunityServiceWithPDSFactory(
repo,
pdsURL,
instanceDID,
instanceDomain,
provisioner,
+
nil,
)
t.Run("DID error includes identifier", func(t *testing.T) {
···
}
provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
-
service := communities.NewCommunityService(
+
service := communities.NewCommunityServiceWithPDSFactory(
repo,
pdsURL,
instanceDID,
instanceDomain,
provisioner,
+
nil,
)
// Create a test community
+17
tests/integration/helpers.go
···
"Coves/internal/api/middleware"
"Coves/internal/atproto/oauth"
"Coves/internal/atproto/pds"
+
"Coves/internal/core/communities"
"Coves/internal/core/users"
"Coves/internal/core/votes"
"bytes"
···
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
}
}
+
+
// CommunityPasswordAuthPDSClientFactory creates a PDSClientFactory for communities that uses password-based Bearer auth.
+
// This is for E2E tests that use createSession instead of OAuth.
+
// The factory extracts the access token and host URL from the session data.
+
func CommunityPasswordAuthPDSClientFactory() communities.PDSClientFactory {
+
return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
}
+2 -1
tests/integration/post_creation_test.go
···
communityRepo := postgres.NewCommunityRepository(db)
// Note: Provisioner not needed for this test (we're not actually creating communities)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil, // provisioner
+
nil, // pdsClientFactory
)
postRepo := postgres.NewPostRepository(db)
+6 -3
tests/integration/post_handler_test.go
···
// Setup services
communityRepo := postgres.NewCommunityRepository(db)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
postRepo := postgres.NewPostRepository(db)
···
// Setup services
communityRepo := postgres.NewCommunityRepository(db)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
postRepo := postgres.NewPostRepository(db)
···
// Setup services
communityRepo := postgres.NewCommunityRepository(db)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
postRepo := postgres.NewPostRepository(db)
+8 -4
tests/integration/post_unfurl_test.go
···
unfurl.WithCacheTTL(24*time.Hour),
)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
postService := posts.NewPostService(
···
identityResolver := identity.NewResolver(db, identityConfig)
userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
// Create post service WITHOUT unfurl service
···
unfurl.WithCacheTTL(24*time.Hour),
)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
postService := posts.NewPostService(
···
unfurl.WithTimeout(30*time.Second),
)
-
communityService := communities.NewCommunityService(
+
communityService := communities.NewCommunityServiceWithPDSFactory(
communityRepo,
"http://localhost:3001",
"did:web:test.coves.social",
"test.coves.social",
nil,
+
nil,
)
postService := posts.NewPostService(
+520
internal/api/handlers/community/block_test.go
···
+
package community
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"bytes"
+
"context"
+
"encoding/json"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// blockTestService implements communities.Service for block handler tests
+
type blockTestService struct {
+
blockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error)
+
unblockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error
+
}
+
+
func (m *blockTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
+
return nil, 0, nil
+
}
+
+
func (m *blockTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {
+
return nil
+
}
+
+
func (m *blockTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {
+
if m.blockFunc != nil {
+
return m.blockFunc(ctx, session, communityIdentifier)
+
}
+
userDID := ""
+
if session != nil {
+
userDID = session.AccountDID.String()
+
}
+
return &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: "did:plc:community123",
+
RecordURI: "at://did:plc:user/social.coves.community.block/abc123",
+
RecordCID: "bafytest123",
+
BlockedAt: time.Now(),
+
}, nil
+
}
+
+
func (m *blockTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {
+
if m.unblockFunc != nil {
+
return m.unblockFunc(ctx, session, communityIdentifier)
+
}
+
return nil
+
}
+
+
func (m *blockTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {
+
return false, nil
+
}
+
+
func (m *blockTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) {
+
return nil, nil
+
}
+
+
func (m *blockTestService) ValidateHandle(handle string) error {
+
return nil
+
}
+
+
func (m *blockTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
+
return identifier, nil
+
}
+
+
func (m *blockTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) {
+
return community, nil
+
}
+
+
func (m *blockTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
// createBlockTestOAuthSession creates a mock OAuth session for block handler tests
+
func createBlockTestOAuthSession(did string) *oauth.ClientSessionData {
+
parsedDID, _ := syntax.ParseDID(did)
+
return &oauth.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: "test-session",
+
HostURL: "http://localhost:3001",
+
AccessToken: "test-access-token",
+
}
+
}
+
+
func TestBlockHandler_Block_Success(t *testing.T) {
+
tests := []struct {
+
name string
+
community string
+
expectedCommunity string
+
}{
+
{
+
name: "block with DID",
+
community: "did:plc:community123",
+
expectedCommunity: "did:plc:community123",
+
},
+
{
+
name: "block with canonical handle",
+
community: "c-worldnews.coves.social",
+
expectedCommunity: "c-worldnews.coves.social",
+
},
+
{
+
name: "block with scoped identifier",
+
community: "!worldnews@coves.social",
+
expectedCommunity: "!worldnews@coves.social",
+
},
+
{
+
name: "block with at-identifier",
+
community: "@c-tech.coves.social",
+
expectedCommunity: "@c-tech.coves.social",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
var receivedIdentifier string
+
mockService := &blockTestService{
+
blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {
+
receivedIdentifier = communityIdentifier
+
userDID := ""
+
if session != nil {
+
userDID = session.AccountDID.String()
+
}
+
return &communities.CommunityBlock{
+
UserDID: userDID,
+
CommunityDID: "did:plc:resolved",
+
RecordURI: "at://did:plc:user/social.coves.community.block/abc123",
+
RecordCID: "bafytest123",
+
BlockedAt: time.Now(),
+
}, nil
+
},
+
}
+
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": tc.community,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject OAuth session into context
+
session := createBlockTestOAuthSession("did:plc:testuser")
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleBlock(w, req)
+
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
// Verify the community identifier was passed through correctly
+
if receivedIdentifier != tc.expectedCommunity {
+
t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier)
+
}
+
+
// Verify response structure
+
var resp struct {
+
Block struct {
+
RecordURI string `json:"recordUri"`
+
RecordCID string `json:"recordCid"`
+
} `json:"block"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
if resp.Block.RecordURI == "" || resp.Block.RecordCID == "" {
+
t.Errorf("Expected recordUri and recordCid in response, got %+v", resp)
+
}
+
})
+
}
+
}
+
+
func TestBlockHandler_Block_RequiresOAuthSession(t *testing.T) {
+
mockService := &blockTestService{}
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// No OAuth session in context
+
+
w := httptest.NewRecorder()
+
handler.HandleBlock(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d", w.Code)
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+
+
func TestBlockHandler_Block_RequiresCommunity(t *testing.T) {
+
mockService := &blockTestService{}
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
session := createBlockTestOAuthSession("did:plc:testuser")
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleBlock(w, req)
+
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", w.Code)
+
}
+
}
+
+
func TestBlockHandler_Block_ServiceErrors(t *testing.T) {
+
tests := []struct {
+
name string
+
serviceErr error
+
expectedStatus int
+
expectedError string
+
}{
+
{
+
name: "community not found",
+
serviceErr: communities.ErrCommunityNotFound,
+
expectedStatus: http.StatusNotFound,
+
expectedError: "NotFound",
+
},
+
{
+
name: "validation error",
+
serviceErr: communities.NewValidationError("community", "invalid format"),
+
expectedStatus: http.StatusBadRequest,
+
expectedError: "InvalidRequest",
+
},
+
{
+
name: "already blocked",
+
serviceErr: communities.ErrBlockAlreadyExists,
+
expectedStatus: http.StatusConflict,
+
expectedError: "AlreadyExists",
+
},
+
{
+
name: "unauthorized",
+
serviceErr: communities.ErrUnauthorized,
+
expectedStatus: http.StatusForbidden,
+
expectedError: "Forbidden",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
mockService := &blockTestService{
+
blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {
+
return nil, tc.serviceErr
+
},
+
}
+
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
session := createBlockTestOAuthSession("did:plc:testuser")
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleBlock(w, req)
+
+
if w.Code != tc.expectedStatus {
+
t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String())
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != tc.expectedError {
+
t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error)
+
}
+
})
+
}
+
}
+
+
func TestBlockHandler_Unblock_Success(t *testing.T) {
+
tests := []struct {
+
name string
+
community string
+
expectedCommunity string
+
}{
+
{
+
name: "unblock with DID",
+
community: "did:plc:community123",
+
expectedCommunity: "did:plc:community123",
+
},
+
{
+
name: "unblock with canonical handle",
+
community: "c-worldnews.coves.social",
+
expectedCommunity: "c-worldnews.coves.social",
+
},
+
{
+
name: "unblock with scoped identifier",
+
community: "!worldnews@coves.social",
+
expectedCommunity: "!worldnews@coves.social",
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
var receivedIdentifier string
+
mockService := &blockTestService{
+
unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {
+
receivedIdentifier = communityIdentifier
+
return nil
+
},
+
}
+
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": tc.community,
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
session := createBlockTestOAuthSession("did:plc:testuser")
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleUnblock(w, req)
+
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
if receivedIdentifier != tc.expectedCommunity {
+
t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier)
+
}
+
+
var resp struct {
+
Success bool `json:"success"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
if !resp.Success {
+
t.Errorf("Expected success: true in response")
+
}
+
})
+
}
+
}
+
+
func TestBlockHandler_Unblock_RequiresOAuthSession(t *testing.T) {
+
mockService := &blockTestService{}
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// No OAuth session in context
+
+
w := httptest.NewRecorder()
+
handler.HandleUnblock(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d", w.Code)
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+
+
func TestBlockHandler_Unblock_BlockNotFound(t *testing.T) {
+
mockService := &blockTestService{
+
unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {
+
return communities.ErrBlockNotFound
+
},
+
}
+
+
handler := NewBlockHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
session := createBlockTestOAuthSession("did:plc:testuser")
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleUnblock(w, req)
+
+
if w.Code != http.StatusNotFound {
+
t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
}
+
+
func TestBlockHandler_MethodNotAllowed(t *testing.T) {
+
mockService := &blockTestService{}
+
handler := NewBlockHandler(mockService)
+
+
// Test GET on block endpoint
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.blockCommunity", nil)
+
w := httptest.NewRecorder()
+
handler.HandleBlock(w, req)
+
+
if w.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", w.Code)
+
}
+
+
// Test GET on unblock endpoint
+
req = httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.unblockCommunity", nil)
+
w = httptest.NewRecorder()
+
handler.HandleUnblock(w, req)
+
+
if w.Code != http.StatusMethodNotAllowed {
+
t.Errorf("Expected status 405, got %d", w.Code)
+
}
+
}
+
+
func TestBlockHandler_InvalidJSON(t *testing.T) {
+
mockService := &blockTestService{}
+
handler := NewBlockHandler(mockService)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBufferString("invalid json"))
+
req.Header.Set("Content-Type", "application/json")
+
+
session := createBlockTestOAuthSession("did:plc:testuser")
+
ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session)
+
req = req.WithContext(ctx)
+
+
w := httptest.NewRecorder()
+
handler.HandleBlock(w, req)
+
+
if w.Code != http.StatusBadRequest {
+
t.Errorf("Expected status 400, got %d", w.Code)
+
}
+
}
+12 -1
internal/api/handlers/community/errors.go
···
package community
import (
+
"Coves/internal/atproto/pds"
"Coves/internal/core/communities"
"encoding/json"
+
"errors"
"log"
"net/http"
)
···
writeError(w, http.StatusForbidden, "Forbidden", "You do not have permission to perform this action")
case err == communities.ErrMemberBanned:
writeError(w, http.StatusForbidden, "Blocked", "You are blocked from this community")
+
// PDS-specific errors (from DPoP authentication or PDS API calls)
+
case errors.Is(err, pds.ErrBadRequest):
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request to PDS")
+
case errors.Is(err, pds.ErrNotFound):
+
writeError(w, http.StatusNotFound, "NotFound", "Record not found on PDS")
+
case errors.Is(err, pds.ErrConflict):
+
writeError(w, http.StatusConflict, "Conflict", "Record was modified by another operation")
+
case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden):
+
// PDS auth errors should prompt re-authentication
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required or session expired")
default:
// Internal server error - log the actual error for debugging
-
// TODO: Use proper logger instead of log package
log.Printf("XRPC handler error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
}
+32
internal/api/handlers/community/subscribe_test.go
···
}
}
+
func TestUnsubscribeHandler_RequiresOAuthSession(t *testing.T) {
+
mockService := &subscribeTestService{}
+
handler := NewSubscribeHandler(mockService)
+
+
reqBody := map[string]interface{}{
+
"community": "did:plc:test",
+
}
+
bodyBytes, _ := json.Marshal(reqBody)
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// No OAuth session in context
+
+
w := httptest.NewRecorder()
+
handler.HandleUnsubscribe(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d", w.Code)
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+
// Ensure unused import is used
var _ = errors.New
+7 -23
internal/core/communities/service.go
···
repo Repository
provisioner *PDSAccountProvisioner
-
// OAuth client/store for user PDS authentication (DPoP-based)
+
// OAuth client for user PDS authentication (DPoP-based)
oauthClient *oauthclient.OAuthClient
-
oauthStore oauth.ClientAuthStore
pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth.
// Token refresh concurrency control
···
pdsURL, instanceDID, instanceDomain string,
provisioner *PDSAccountProvisioner,
oauthClient *oauthclient.OAuthClient,
-
oauthStore oauth.ClientAuthStore,
) Service {
// SECURITY: Basic validation that did:web domain matches configured instanceDomain
// This catches honest configuration mistakes but NOT malicious code modifications
···
instanceDomain: instanceDomain,
provisioner: provisioner,
oauthClient: oauthClient,
-
oauthStore: oauthStore,
refreshMutexes: make(map[string]*sync.Mutex),
}
}
···
// Production path: use OAuth with DPoP
if s.oauthClient == nil || s.oauthClient.ClientApp == nil {
+
log.Printf("[OAUTH_ERROR] getPDSClient called but OAuth client is not configured - check server initialization")
return nil, fmt.Errorf("OAuth client not configured")
}
···
// Resolve community identifier to DID
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("subscribe: %w", err)
}
// Verify community exists
···
// Resolve community identifier
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
if err != nil {
-
return err
+
return fmt.Errorf("unsubscribe: %w", err)
}
// Get the subscription from AppView to find the record key
···
// Resolve community identifier (also verifies community exists)
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("block: %w", err)
}
// Create PDS client for this session (DPoP authentication)
pdsClient, err := s.getPDSClient(ctx, session)
if err != nil {
-
return nil, fmt.Errorf("failed to create PDS client: %w", err)
+
return nil, fmt.Errorf("block: failed to create PDS client: %w", err)
}
// Generate TID for record key
···
// Resolve community identifier
communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier)
if err != nil {
-
return err
+
return fmt.Errorf("unblock: %w", err)
}
// Get the block from AppView to find the record key
···
return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
-
// deleteRecordOnPDSAs deletes a record with a specific access token (for user-scoped deletions)
-
func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error {
-
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))
-
-
payload := map[string]interface{}{
-
"repo": repoDID,
-
"collection": collection,
-
"rkey": rkey,
-
}
-
-
_, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken)
-
return err
-
}
-
// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication)
func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) {
jsonData, err := json.Marshal(payload)
+1 -1
cmd/server/main.go
···
// Register XRPC routes
routes.RegisterUserRoutes(r, userService)
-
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, allowedCommunityCreators)
+
routes.RegisterCommunityRoutes(r, communityService, communityRepo, authMiddleware, allowedCommunityCreators)
log.Println("Community XRPC endpoints registered with OAuth authentication")
routes.RegisterPostRoutes(r, postService, dualAuth)
+7 -1
internal/api/handlers/community/list.go
···
package community
import (
+
"Coves/internal/api/handlers/common"
"Coves/internal/core/communities"
"encoding/json"
"net/http"
···
// ListHandler handles listing communities
type ListHandler struct {
service communities.Service
+
repo communities.Repository
}
// NewListHandler creates a new list handler
-
func NewListHandler(service communities.Service) *ListHandler {
+
func NewListHandler(service communities.Service, repo communities.Repository) *ListHandler {
return &ListHandler{
service: service,
+
repo: repo,
}
}
···
return
}
+
// Populate viewer state if authenticated
+
common.PopulateCommunityViewerState(r.Context(), r, h.repo, results)
+
// Build response
var cursor string
if len(results) == limit {
+4
internal/core/comments/comment_service_test.go
···
return nil, nil
}
+
func (m *mockCommunityRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) {
+
return map[string]bool{}, nil
+
}
+
func (m *mockCommunityRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) {
return nil, nil
}
+48
internal/db/postgres/community_repo_subscriptions.go
···
return result, nil
}
+
+
// GetSubscribedCommunityDIDs returns a map of community DIDs that the user is subscribed to
+
// This is optimized for batch lookups when populating viewer state
+
func (r *postgresCommunityRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) {
+
if len(communityDIDs) == 0 {
+
return map[string]bool{}, nil
+
}
+
+
// Build query with placeholders for IN clause
+
placeholders := make([]string, len(communityDIDs))
+
args := make([]interface{}, len(communityDIDs)+1)
+
args[0] = userDID
+
for i, did := range communityDIDs {
+
placeholders[i] = fmt.Sprintf("$%d", i+2)
+
args[i+1] = did
+
}
+
+
query := fmt.Sprintf(`
+
SELECT community_did
+
FROM community_subscriptions
+
WHERE user_did = $1 AND community_did IN (%s)`,
+
strings.Join(placeholders, ", "))
+
+
rows, err := r.db.QueryContext(ctx, query, args...)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get subscribed communities: %w", err)
+
}
+
defer func() {
+
if closeErr := rows.Close(); closeErr != nil {
+
log.Printf("Failed to close rows: %v", closeErr)
+
}
+
}()
+
+
result := make(map[string]bool)
+
for rows.Next() {
+
var communityDID string
+
if err := rows.Scan(&communityDID); err != nil {
+
return nil, fmt.Errorf("failed to scan community DID: %w", err)
+
}
+
result[communityDID] = true
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating subscribed communities: %w", err)
+
}
+
+
return result, nil
+
}
+268
tests/integration/community_list_viewer_state_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/api/handlers/community"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
+
"context"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/go-chi/chi/v5"
+
)
+
+
// TestCommunityList_ViewerState tests that the list communities endpoint
+
// correctly populates viewer.subscribed field for authenticated users
+
func TestCommunityList_ViewerState(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
repo := postgres.NewCommunityRepository(db)
+
ctx := context.Background()
+
+
// Create test communities
+
baseSuffix := time.Now().UnixNano()
+
communityDIDs := make([]string, 3)
+
for i := 0; i < 3; i++ {
+
uniqueSuffix := fmt.Sprintf("%d%d", baseSuffix, i)
+
communityDID := generateTestDID(uniqueSuffix)
+
communityDIDs[i] = communityDID
+
comm := &communities.Community{
+
DID: communityDID,
+
Handle: fmt.Sprintf("c-viewer-test-%d-%d.coves.local", baseSuffix, i),
+
Name: fmt.Sprintf("viewer-test-%d", i),
+
DisplayName: fmt.Sprintf("Viewer Test Community %d", i),
+
OwnerDID: "did:web:coves.local",
+
CreatedByDID: "did:plc:testcreator",
+
HostedByDID: "did:web:coves.local",
+
Visibility: "public",
+
CreatedAt: time.Now(),
+
UpdatedAt: time.Now(),
+
}
+
if _, err := repo.Create(ctx, comm); err != nil {
+
t.Fatalf("Failed to create community %d: %v", i, err)
+
}
+
}
+
+
// Create a test user and subscribe them to community 0 and 2
+
testUserDID := fmt.Sprintf("did:plc:viewertestuser%d", baseSuffix)
+
+
sub1 := &communities.Subscription{
+
UserDID: testUserDID,
+
CommunityDID: communityDIDs[0],
+
ContentVisibility: 3,
+
SubscribedAt: time.Now(),
+
}
+
if _, err := repo.Subscribe(ctx, sub1); err != nil {
+
t.Fatalf("Failed to subscribe to community 0: %v", err)
+
}
+
+
sub2 := &communities.Subscription{
+
UserDID: testUserDID,
+
CommunityDID: communityDIDs[2],
+
ContentVisibility: 3,
+
SubscribedAt: time.Now(),
+
}
+
if _, err := repo.Subscribe(ctx, sub2); err != nil {
+
t.Fatalf("Failed to subscribe to community 2: %v", err)
+
}
+
+
// Create mock service that returns our communities
+
mockService := &mockCommunityService{
+
repo: repo,
+
}
+
+
// Create handler with real repo for viewer state population
+
listHandler := community.NewListHandler(mockService, repo)
+
+
t.Run("authenticated user sees viewer.subscribed correctly", func(t *testing.T) {
+
// Setup router with middleware that injects user DID
+
r := chi.NewRouter()
+
+
// Use test middleware that sets user DID in context
+
r.Use(func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+
ctx := middleware.SetTestUserDID(req.Context(), testUserDID)
+
next.ServeHTTP(w, req.WithContext(ctx))
+
})
+
})
+
r.Get("/xrpc/social.coves.community.list", listHandler.HandleList)
+
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.community.list?limit=50", nil)
+
rec := httptest.NewRecorder()
+
+
r.ServeHTTP(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Fatalf("Expected status 200, got %d: %s", rec.Code, rec.Body.String())
+
}
+
+
var response struct {
+
Communities []struct {
+
DID string `json:"did"`
+
Viewer *struct {
+
Subscribed *bool `json:"subscribed"`
+
} `json:"viewer"`
+
} `json:"communities"`
+
}
+
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
// Check that viewer state is populated correctly
+
subscriptionMap := map[string]bool{
+
communityDIDs[0]: true,
+
communityDIDs[1]: false,
+
communityDIDs[2]: true,
+
}
+
+
for _, comm := range response.Communities {
+
expectedSubscribed, inTestSet := subscriptionMap[comm.DID]
+
if !inTestSet {
+
continue // Skip communities not in our test set
+
}
+
+
if comm.Viewer == nil {
+
t.Errorf("Community %s has nil Viewer, expected populated", comm.DID)
+
continue
+
}
+
+
if comm.Viewer.Subscribed == nil {
+
t.Errorf("Community %s has nil Viewer.Subscribed, expected populated", comm.DID)
+
continue
+
}
+
+
if *comm.Viewer.Subscribed != expectedSubscribed {
+
t.Errorf("Community %s: expected subscribed=%v, got %v",
+
comm.DID, expectedSubscribed, *comm.Viewer.Subscribed)
+
}
+
}
+
})
+
+
t.Run("unauthenticated request has nil viewer state", func(t *testing.T) {
+
// Setup router WITHOUT middleware that sets user DID
+
r := chi.NewRouter()
+
r.Get("/xrpc/social.coves.community.list", listHandler.HandleList)
+
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.community.list?limit=50", nil)
+
rec := httptest.NewRecorder()
+
+
r.ServeHTTP(rec, req)
+
+
if rec.Code != http.StatusOK {
+
t.Fatalf("Expected status 200, got %d: %s", rec.Code, rec.Body.String())
+
}
+
+
var response struct {
+
Communities []struct {
+
DID string `json:"did"`
+
Viewer *struct {
+
Subscribed *bool `json:"subscribed"`
+
} `json:"viewer"`
+
} `json:"communities"`
+
}
+
+
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
// For unauthenticated requests, viewer should be nil for all communities
+
for _, comm := range response.Communities {
+
if comm.Viewer != nil {
+
t.Errorf("Community %s has non-nil Viewer for unauthenticated request", comm.DID)
+
}
+
}
+
})
+
}
+
+
// mockCommunityService implements communities.Service for testing
+
type mockCommunityService struct {
+
repo communities.Repository
+
}
+
+
func (m *mockCommunityService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+
return m.repo.List(ctx, req)
+
}
+
+
func (m *mockCommunityService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
+
return nil, 0, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {
+
return fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {
+
return fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {
+
return false, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) {
+
return nil, fmt.Errorf("not implemented")
+
}
+
+
func (m *mockCommunityService) ValidateHandle(handle string) error {
+
return nil
+
}
+
+
func (m *mockCommunityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
+
return identifier, nil
+
}
+
+
func (m *mockCommunityService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) {
+
return community, nil
+
}
+
+
func (m *mockCommunityService) GetByDID(ctx context.Context, did string) (*communities.Community, error) {
+
return m.repo.GetByDID(ctx, did)
+
}
+117
tests/integration/community_repo_test.go
···
})
}
+
func TestCommunityRepository_GetSubscribedCommunityDIDs(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
repo := postgres.NewCommunityRepository(db)
+
ctx := context.Background()
+
+
// Create test communities
+
baseSuffix := time.Now().UnixNano()
+
communityDIDs := make([]string, 3)
+
for i := 0; i < 3; i++ {
+
uniqueSuffix := fmt.Sprintf("%d%d", baseSuffix, i)
+
communityDID := generateTestDID(uniqueSuffix)
+
communityDIDs[i] = communityDID
+
community := &communities.Community{
+
DID: communityDID,
+
Handle: fmt.Sprintf("!batch-sub-test-%d-%d@coves.local", baseSuffix, i),
+
Name: fmt.Sprintf("batch-sub-test-%d", i),
+
OwnerDID: "did:web:coves.local",
+
CreatedByDID: "did:plc:user123",
+
HostedByDID: "did:web:coves.local",
+
Visibility: "public",
+
CreatedAt: time.Now(),
+
UpdatedAt: time.Now(),
+
}
+
if _, err := repo.Create(ctx, community); err != nil {
+
t.Fatalf("Failed to create community %d: %v", i, err)
+
}
+
}
+
+
userDID := fmt.Sprintf("did:plc:batchsubuser%d", baseSuffix)
+
+
t.Run("returns empty map when user has no subscriptions", func(t *testing.T) {
+
result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs)
+
if err != nil {
+
t.Fatalf("Failed to get subscribed community DIDs: %v", err)
+
}
+
+
if len(result) != 0 {
+
t.Errorf("Expected empty map, got %d entries", len(result))
+
}
+
})
+
+
t.Run("returns subscribed communities only", func(t *testing.T) {
+
// Subscribe to first and third community
+
sub1 := &communities.Subscription{
+
UserDID: userDID,
+
CommunityDID: communityDIDs[0],
+
ContentVisibility: 3,
+
SubscribedAt: time.Now(),
+
}
+
if _, err := repo.Subscribe(ctx, sub1); err != nil {
+
t.Fatalf("Failed to subscribe to community 0: %v", err)
+
}
+
+
sub3 := &communities.Subscription{
+
UserDID: userDID,
+
CommunityDID: communityDIDs[2],
+
ContentVisibility: 3,
+
SubscribedAt: time.Now(),
+
}
+
if _, err := repo.Subscribe(ctx, sub3); err != nil {
+
t.Fatalf("Failed to subscribe to community 2: %v", err)
+
}
+
+
result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs)
+
if err != nil {
+
t.Fatalf("Failed to get subscribed community DIDs: %v", err)
+
}
+
+
if len(result) != 2 {
+
t.Errorf("Expected 2 subscribed communities, got %d", len(result))
+
}
+
+
if !result[communityDIDs[0]] {
+
t.Errorf("Expected community 0 to be subscribed")
+
}
+
if result[communityDIDs[1]] {
+
t.Errorf("Expected community 1 to NOT be subscribed")
+
}
+
if !result[communityDIDs[2]] {
+
t.Errorf("Expected community 2 to be subscribed")
+
}
+
})
+
+
t.Run("returns empty map for empty community DIDs slice", func(t *testing.T) {
+
result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, []string{})
+
if err != nil {
+
t.Fatalf("Failed to get subscribed community DIDs: %v", err)
+
}
+
+
if len(result) != 0 {
+
t.Errorf("Expected empty map for empty input, got %d entries", len(result))
+
}
+
})
+
+
t.Run("handles non-existent community DIDs gracefully", func(t *testing.T) {
+
nonExistentDIDs := []string{
+
"did:plc:nonexistent1",
+
"did:plc:nonexistent2",
+
}
+
+
result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, nonExistentDIDs)
+
if err != nil {
+
t.Fatalf("Failed to get subscribed community DIDs: %v", err)
+
}
+
+
if len(result) != 0 {
+
t.Errorf("Expected empty map for non-existent DIDs, got %d entries", len(result))
+
}
+
})
+
}
+
// TODO: Implement search functionality before re-enabling this test
// func TestCommunityRepository_Search(t *testing.T) {
// db := setupTestDB(t)
+2 -1
internal/api/routes/oauth.go
···
logoutLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
// OAuth metadata endpoints - public, no extra rate limiting (use global limit)
-
r.Get("/oauth/client-metadata.json", handler.HandleClientMetadata)
+
// Serve at root /oauth-client-metadata.json so OAuth screens show clean brand domain
+
r.Get("/oauth-client-metadata.json", handler.HandleClientMetadata)
r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata)
// OAuth flow endpoints - stricter rate limiting for authentication attempts
+1 -1
internal/atproto/oauth/handlers.go
···
}
// HandleClientMetadata serves the OAuth client metadata document
-
// GET /oauth/client-metadata.json
+
// GET /oauth-client-metadata.json
func (h *OAuthHandler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
metadata := h.client.ClientMetadata()