A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+13535 -2500
.beads
aggregators
docs
internal
api
atproto
core
db
validation
scripts
static
tests
+23
internal/db/migrations/008_add_content_visibility_to_subscriptions.sql
···
+
-- +goose Up
+
-- Add content_visibility column to community_subscriptions table
+
-- This implements the feed slider (1-5 scale) from DOMAIN_KNOWLEDGE.md
+
-- 1 = Only show the best/most popular content from this community
+
-- 5 = Show all content from this community
+
-- Default = 3 (balanced)
+
ALTER TABLE community_subscriptions
+
ADD COLUMN content_visibility INTEGER NOT NULL DEFAULT 3
+
CHECK (content_visibility >= 1 AND content_visibility <= 5);
+
+
-- Index for feed generation queries (filter by visibility level)
+
CREATE INDEX idx_subscriptions_visibility ON community_subscriptions(content_visibility);
+
+
-- Composite index for user feed queries (user_did + visibility level)
+
CREATE INDEX idx_subscriptions_user_visibility ON community_subscriptions(user_did, content_visibility);
+
+
COMMENT ON COLUMN community_subscriptions.content_visibility IS 'Feed slider: 1=only best content, 5=all content (see social.coves.community.subscription lexicon)';
+
+
-- +goose Down
+
-- Remove content_visibility column and indexes
+
DROP INDEX IF EXISTS idx_subscriptions_user_visibility;
+
DROP INDEX IF EXISTS idx_subscriptions_visibility;
+
ALTER TABLE community_subscriptions DROP COLUMN content_visibility;
+136
internal/atproto/jetstream/community_jetstream_connector.go
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
+
"github.com/gorilla/websocket"
+
)
+
+
// CommunityJetstreamConnector handles WebSocket connection to Jetstream for community events
+
type CommunityJetstreamConnector struct {
+
consumer *CommunityEventConsumer
+
wsURL string
+
}
+
+
// NewCommunityJetstreamConnector creates a new Jetstream WebSocket connector for community events
+
func NewCommunityJetstreamConnector(consumer *CommunityEventConsumer, wsURL string) *CommunityJetstreamConnector {
+
return &CommunityJetstreamConnector{
+
consumer: consumer,
+
wsURL: wsURL,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *CommunityJetstreamConnector) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream community consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream community consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream community connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *CommunityJetstreamConnector) connect(ctx context.Context) error {
+
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() {
+
if closeErr := conn.Close(); closeErr != nil {
+
log.Printf("Failed to close WebSocket connection: %v", closeErr)
+
}
+
}()
+
+
log.Println("Connected to Jetstream (community consumer)")
+
+
// Set read deadline to detect connection issues
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline in pong handler: %v", err)
+
}
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
var closeOnce sync.Once // Ensure done channel is only closed once
+
+
// Goroutine to send pings
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+
log.Printf("Ping error: %v", err)
+
closeOnce.Do(func() { close(done) })
+
return
+
}
+
case <-done:
+
return
+
case <-ctx.Done():
+
return
+
}
+
}
+
}()
+
+
// Read messages
+
for {
+
select {
+
case <-ctx.Done():
+
return ctx.Err()
+
case <-done:
+
return fmt.Errorf("connection closed")
+
default:
+
_, message, err := conn.ReadMessage()
+
if err != nil {
+
closeOnce.Do(func() { close(done) })
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Reset read deadline on successful read
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
if err := c.handleEvent(ctx, message); err != nil {
+
log.Printf("Error handling community event: %v", err)
+
// Continue processing other events
+
}
+
}
+
}
+
}
+
+
// handleEvent processes a single Jetstream event
+
func (c *CommunityJetstreamConnector) handleEvent(ctx context.Context, data []byte) error {
+
var event JetstreamEvent
+
if err := json.Unmarshal(data, &event); err != nil {
+
return fmt.Errorf("failed to parse event: %w", err)
+
}
+
+
// Pass to consumer's HandleEvent method
+
return c.consumer.HandleEvent(ctx, &event)
+
}
-6
tests/lexicon-test-data/actor/subscription-invalid-visibility.json
···
-
{
-
"$type": "social.coves.actor.subscription",
-
"community": "did:plc:programmingcommunity",
-
"createdAt": "2024-06-01T08:00:00Z",
-
"contentVisibility": 10
-
}
-6
tests/lexicon-test-data/actor/subscription-valid.json
···
-
{
-
"$type": "social.coves.actor.subscription",
-
"community": "did:plc:programmingcommunity",
-
"createdAt": "2024-06-01T08:00:00Z",
-
"contentVisibility": 3
-
}
+6
tests/lexicon-test-data/community/subscription-invalid-visibility.json
···
+
{
+
"$type": "social.coves.community.subscription",
+
"subject": "did:plc:test123",
+
"createdAt": "2025-01-15T12:00:00Z",
+
"contentVisibility": 10
+
}
+6
tests/lexicon-test-data/community/subscription-valid.json
···
+
{
+
"$type": "social.coves.community.subscription",
+
"subject": "did:plc:test123",
+
"createdAt": "2025-01-15T12:00:00Z",
+
"contentVisibility": 3
+
}
+27
internal/atproto/lexicon/social/coves/community/block.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.block",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Record declaring a block relationship against a community. Blocks are public.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["subject", "createdAt"],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the community being blocked"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the block was created"
+
}
+
}
+
}
+
}
+
}
+
}
+99
internal/core/communities/token_refresh.go
···
+
package communities
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"strings"
+
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/xrpc"
+
)
+
+
// 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) {
+
if pdsURL == "" {
+
return "", "", fmt.Errorf("PDS URL is required")
+
}
+
if refreshToken == "" {
+
return "", "", fmt.Errorf("refresh token is required")
+
}
+
+
// Create XRPC client with auth credentials
+
// The refresh endpoint requires authentication with the refresh token
+
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
+
},
+
}
+
+
// Call com.atproto.server.refreshSession
+
output, err := atproto.ServerRefreshSession(ctx, client)
+
if err != nil {
+
// Check for expired refresh token (401 Unauthorized)
+
// Try typed error first (more reliable), fallback to string check
+
var xrpcErr *xrpc.Error
+
if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 401 {
+
return "", "", fmt.Errorf("refresh token expired or invalid (needs password re-auth)")
+
}
+
+
// Fallback: string-based detection (in case error isn't wrapped as xrpc.Error)
+
errStr := err.Error()
+
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Unauthorized") {
+
return "", "", fmt.Errorf("refresh token expired or invalid (needs password re-auth)")
+
}
+
+
return "", "", fmt.Errorf("failed to refresh session: %w", err)
+
}
+
+
// Validate response
+
if output.AccessJwt == "" || output.RefreshJwt == "" {
+
return "", "", fmt.Errorf("refresh response missing tokens")
+
}
+
+
return output.AccessJwt, output.RefreshJwt, nil
+
}
+
+
// reauthenticateWithPassword creates a new session using stored credentials
+
// This is the fallback when refresh tokens expire (after ~2 months)
+
// Uses com.atproto.server.createSession endpoint via Indigo SDK
+
func reauthenticateWithPassword(ctx context.Context, pdsURL, email, password string) (accessToken, refreshToken string, err error) {
+
if pdsURL == "" {
+
return "", "", fmt.Errorf("PDS URL is required")
+
}
+
if email == "" {
+
return "", "", fmt.Errorf("email is required")
+
}
+
if password == "" {
+
return "", "", fmt.Errorf("password is required")
+
}
+
+
// Create unauthenticated XRPC client
+
client := &xrpc.Client{
+
Host: pdsURL,
+
}
+
+
// Prepare createSession input
+
// The identifier can be either email or handle
+
input := &atproto.ServerCreateSession_Input{
+
Identifier: email,
+
Password: password,
+
}
+
+
// Call com.atproto.server.createSession
+
output, err := atproto.ServerCreateSession(ctx, client, input)
+
if err != nil {
+
return "", "", fmt.Errorf("failed to create session: %w", err)
+
}
+
+
// Validate response
+
if output.AccessJwt == "" || output.RefreshJwt == "" {
+
return "", "", fmt.Errorf("createSession response missing tokens")
+
}
+
+
return output.AccessJwt, output.RefreshJwt, nil
+
}
+66
internal/core/communities/token_utils.go
···
+
package communities
+
+
import (
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"strings"
+
"time"
+
)
+
+
// parseJWTExpiration extracts the expiration time from a JWT access token
+
// This function does NOT verify the signature - it only parses the exp claim
+
// atproto access tokens use standard JWT format with 'exp' claim (Unix timestamp)
+
func parseJWTExpiration(token string) (time.Time, error) {
+
// Remove "Bearer " prefix if present
+
token = strings.TrimPrefix(token, "Bearer ")
+
token = strings.TrimSpace(token)
+
+
// JWT format: header.payload.signature
+
parts := strings.Split(token, ".")
+
if len(parts) != 3 {
+
return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
+
}
+
+
// Decode payload (second part) - use RawURLEncoding (no padding)
+
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+
if err != nil {
+
return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
+
}
+
+
// Extract exp claim (Unix timestamp)
+
var claims struct {
+
Exp int64 `json:"exp"` // Expiration time (seconds since Unix epoch)
+
}
+
if err := json.Unmarshal(payload, &claims); err != nil {
+
return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
+
}
+
+
if claims.Exp == 0 {
+
return time.Time{}, fmt.Errorf("JWT missing 'exp' claim")
+
}
+
+
// Convert Unix timestamp to time.Time
+
return time.Unix(claims.Exp, 0), nil
+
}
+
+
// NeedsRefresh checks if an access token should be refreshed
+
// Returns true if the token expires within the next 5 minutes (or is already expired)
+
// Uses a 5-minute buffer to ensure we refresh before actual expiration
+
func NeedsRefresh(accessToken string) (bool, error) {
+
if accessToken == "" {
+
return false, fmt.Errorf("access token is empty")
+
}
+
+
expiration, err := parseJWTExpiration(accessToken)
+
if err != nil {
+
return false, fmt.Errorf("failed to parse token expiration: %w", err)
+
}
+
+
// Refresh if token expires within 5 minutes
+
// This prevents service interruptions from expired tokens
+
bufferTime := 5 * time.Minute
+
expiresWithinBuffer := time.Now().Add(bufferTime).After(expiration)
+
+
return expiresWithinBuffer, nil
+
}
+3 -2
internal/atproto/lexicon/social/coves/community/create.json
···
"defs": {
"main": {
"type": "procedure",
-
"description": "Create a new community",
+
"description": "Create a new community. Requires authentication.",
"input": {
"encoding": "application/json",
"schema": {
···
},
"visibility": {
"type": "string",
-
"enum": ["public", "unlisted", "private"],
+
"knownValues": ["public", "unlisted", "private"],
"default": "public",
+
"maxLength": 64,
"description": "Community visibility level"
},
"allowExternalDiscovery": {
+3 -2
internal/atproto/lexicon/social/coves/community/update.json
···
"defs": {
"main": {
"type": "procedure",
-
"description": "Update community profile",
+
"description": "Update community profile. Requires authentication and moderator/admin permissions.",
"input": {
"encoding": "application/json",
"schema": {
···
},
"visibility": {
"type": "string",
-
"enum": ["public", "unlisted", "private"],
+
"knownValues": ["public", "unlisted", "private"],
+
"maxLength": 64,
"description": "Community visibility level"
},
"allowExternalDiscovery": {
+5 -3
internal/atproto/lexicon/social/coves/community/moderator.json
···
},
"role": {
"type": "string",
-
"enum": ["moderator", "admin"],
+
"knownValues": ["moderator", "admin"],
+
"maxLength": 64,
"description": "Level of moderation privileges"
},
"permissions": {
···
"description": "Specific permissions granted",
"items": {
"type": "string",
-
"enum": [
+
"knownValues": [
"remove_posts",
"remove_comments",
"ban_users",
···
"manage_wiki",
"manage_moderators",
"manage_settings"
-
]
+
],
+
"maxLength": 64
}
},
"createdAt": {
+1 -1
internal/atproto/lexicon/social/coves/community/subscribe.json
···
"defs": {
"main": {
"type": "procedure",
-
"description": "Subscribe to a community to see its posts in your feed",
+
"description": "Subscribe to a community to see its posts in your feed. Requires authentication.",
"input": {
"encoding": "application/json",
"schema": {
+1 -1
internal/atproto/lexicon/social/coves/community/unsubscribe.json
···
"defs": {
"main": {
"type": "procedure",
-
"description": "Unsubscribe from a community",
+
"description": "Unsubscribe from a community. Requires authentication.",
"input": {
"encoding": "application/json",
"schema": {
-6
tests/lexicon-test-data/actor/membership-invalid-reputation.json
···
-
{
-
"$type": "social.coves.actor.membership",
-
"community": "did:plc:examplecommunity123",
-
"createdAt": "2024-01-15T10:30:00Z",
-
"reputation": -50
-
}
-6
tests/lexicon-test-data/actor/membership-valid.json
···
-
{
-
"$type": "social.coves.actor.membership",
-
"community": "did:plc:examplecommunity123",
-
"reputation": 150,
-
"createdAt": "2024-01-15T10:30:00Z"
-
}
-38
internal/atproto/lexicon/social/coves/actor/membership.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.membership",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "Membership in a community",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["community", "reputation", "createdAt"],
-
"properties": {
-
"community": {
-
"type": "string",
-
"format": "at-identifier",
-
"description": "DID or handle of the community"
-
},
-
"reputation": {
-
"type": "integer",
-
"minimum": 0,
-
"default": 0,
-
"description": "Reputation score within the community"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the user's membership started"
-
},
-
"endedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the membership ended (null if current)"
-
}
-
}
-
}
-
}
-
}
-
}
+68
internal/db/migrations/010_migrate_community_handles_to_singular.sql
···
+
-- +goose Up
+
-- +goose StatementBegin
+
+
-- Migration: Rename community handles from .communities. to .community.
+
-- This aligns with AT Protocol lexicon naming conventions (singular form)
+
--
+
-- Background:
+
-- All atProto record types use singular form (app.bsky.feed.post, app.bsky.graph.follow)
+
-- This migration updates existing community handles to follow the same pattern
+
--
+
-- Examples:
+
-- gardening.communities.coves.social -> gardening.community.coves.social
+
-- gaming.communities.coves.social -> gaming.community.coves.social
+
--
+
-- Safety: Uses REPLACE which only affects handles containing '.communities.'
+
-- Rollback: Available via down migration below
+
+
-- Update community handles in the communities table
+
UPDATE communities
+
SET handle = REPLACE(handle, '.communities.', '.community.')
+
WHERE handle LIKE '%.communities.%';
+
+
-- Verify the migration (optional - can be commented out in production)
+
-- This will fail if any .communities. handles remain
+
DO $$
+
DECLARE
+
old_format_count INTEGER;
+
BEGIN
+
SELECT COUNT(*) INTO old_format_count
+
FROM communities
+
WHERE handle LIKE '%.communities.%';
+
+
IF old_format_count > 0 THEN
+
RAISE EXCEPTION 'Migration incomplete: % communities still have .communities. format', old_format_count;
+
END IF;
+
+
RAISE NOTICE 'Migration successful: All community handles updated to .community. format';
+
END $$;
+
+
-- +goose StatementEnd
+
+
-- +goose Down
+
-- +goose StatementBegin
+
+
-- Rollback: Revert handles from .community. back to .communities.
+
-- Only use this if you need to rollback the naming convention change
+
+
UPDATE communities
+
SET handle = REPLACE(handle, '.community.', '.communities.')
+
WHERE handle LIKE '%.community.%';
+
+
-- Verify the rollback
+
DO $$
+
DECLARE
+
new_format_count INTEGER;
+
BEGIN
+
SELECT COUNT(*) INTO new_format_count
+
FROM communities
+
WHERE handle LIKE '%.community.%';
+
+
IF new_format_count > 0 THEN
+
RAISE EXCEPTION 'Rollback incomplete: % communities still have .community. format', new_format_count;
+
END IF;
+
+
RAISE NOTICE 'Rollback successful: All community handles reverted to .communities. format';
+
END $$;
+
+
-- +goose StatementEnd
+1 -1
tests/lexicon-test-data/community/profile-valid.json
···
{
"$type": "social.coves.community.profile",
-
"handle": "programming.communities.coves.social",
+
"handle": "programming.community.coves.social",
"name": "programming",
"displayName": "Programming Community",
"description": "A community for programmers",
+9 -7
internal/core/communities/pds_provisioning.go
···
// CommunityPDSAccount represents PDS account credentials for a community
type CommunityPDSAccount struct {
DID string // Community's DID (owns the repository)
-
Handle string // Community's handle (e.g., gaming.communities.coves.social)
+
Handle string // Community's handle (e.g., gaming.community.coves.social)
Email string // System email for PDS account
Password string // Cleartext password (MUST be encrypted before database storage)
AccessToken string // JWT for making API calls as the community
···
}
// 1. Generate unique handle for the community
-
// Format: {name}.communities.{instance-domain}
-
// Example: "gaming.communities.coves.social"
-
handle := fmt.Sprintf("%s.communities.%s", strings.ToLower(communityName), p.instanceDomain)
+
// 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)
// 2. Generate system email for PDS account management
// This email is used for account operations, not for user communication
-
email := fmt.Sprintf("community-%s@communities.%s", strings.ToLower(communityName), p.instanceDomain)
+
email := fmt.Sprintf("community-%s@community.%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
···
// The repository layer handles encryption using pgp_sym_encrypt()
return &CommunityPDSAccount{
DID: output.Did, // The community's DID (PDS-generated)
-
Handle: output.Handle, // e.g., gaming.communities.coves.social
-
Email: email, // community-gaming@communities.coves.social
+
Handle: output.Handle, // e.g., gaming.community.coves.social
+
Email: email, // community-gaming@community.coves.social
Password: password, // Cleartext - will be encrypted by repository
AccessToken: output.AccessJwt, // JWT for making API calls
RefreshToken: output.RefreshJwt, // For refreshing sessions
+51
internal/db/migrations/011_create_posts_table.sql
···
+
-- +goose Up
+
-- Create posts table for AppView indexing
+
-- Posts are indexed from the firehose after being written to community repositories
+
CREATE TABLE posts (
+
id BIGSERIAL PRIMARY KEY,
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://community_did/social.coves.post.record/rkey)
+
cid TEXT NOT NULL, -- Content ID
+
rkey TEXT NOT NULL, -- Record key (TID)
+
author_did TEXT NOT NULL, -- Author's DID (from record metadata)
+
community_did TEXT NOT NULL, -- Community DID (from AT-URI repo field)
+
+
-- Content (all nullable per lexicon)
+
title TEXT, -- Post title
+
content TEXT, -- Post content/body
+
content_facets JSONB, -- Rich text facets (app.bsky.richtext.facet)
+
embed JSONB, -- Embedded content (images, video, external, record)
+
content_labels TEXT[], -- Self-applied labels (nsfw, spoiler, violence)
+
+
-- Timestamps
+
created_at TIMESTAMPTZ NOT NULL, -- Author's timestamp from record
+
edited_at TIMESTAMPTZ, -- Last edit timestamp (future)
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView
+
deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events)
+
+
-- Stats (denormalized for performance)
+
upvote_count INT NOT NULL DEFAULT 0,
+
downvote_count INT NOT NULL DEFAULT 0,
+
score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count (for sorting)
+
comment_count INT NOT NULL DEFAULT 0,
+
+
-- Foreign keys
+
CONSTRAINT fk_author FOREIGN KEY (author_did) REFERENCES users(did) ON DELETE CASCADE,
+
CONSTRAINT fk_community FOREIGN KEY (community_did) REFERENCES communities(did) ON DELETE CASCADE
+
);
+
+
-- Indexes for common query patterns
+
CREATE INDEX idx_posts_community_created ON posts(community_did, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_posts_community_score ON posts(community_did, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+
CREATE INDEX idx_posts_author ON posts(author_did, created_at DESC);
+
CREATE INDEX idx_posts_uri ON posts(uri);
+
+
-- Index for full-text search on content (future)
+
-- CREATE INDEX idx_posts_content_search ON posts USING gin(to_tsvector('english', content)) WHERE deleted_at IS NULL;
+
+
-- Comment on table
+
COMMENT ON TABLE posts IS 'Posts indexed from community repositories via Jetstream firehose consumer';
+
COMMENT ON COLUMN posts.uri IS 'AT-URI in format: at://community_did/social.coves.post.record/rkey';
+
COMMENT ON COLUMN posts.score IS 'Computed as upvote_count - downvote_count for ranking algorithms';
+
+
-- +goose Down
+
DROP TABLE IF EXISTS posts CASCADE;
+125
internal/atproto/jetstream/post_jetstream_connector.go
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
+
"github.com/gorilla/websocket"
+
)
+
+
// PostJetstreamConnector handles WebSocket connection to Jetstream for post events
+
type PostJetstreamConnector struct {
+
consumer *PostEventConsumer
+
wsURL string
+
}
+
+
// NewPostJetstreamConnector creates a new Jetstream WebSocket connector for post events
+
func NewPostJetstreamConnector(consumer *PostEventConsumer, wsURL string) *PostJetstreamConnector {
+
return &PostJetstreamConnector{
+
consumer: consumer,
+
wsURL: wsURL,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *PostJetstreamConnector) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream post consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream post consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream post connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *PostJetstreamConnector) connect(ctx context.Context) error {
+
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() {
+
if closeErr := conn.Close(); closeErr != nil {
+
log.Printf("Failed to close WebSocket connection: %v", closeErr)
+
}
+
}()
+
+
log.Println("Connected to Jetstream (post consumer)")
+
+
// Set read deadline to detect connection issues
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline in pong handler: %v", err)
+
}
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
var closeOnce sync.Once // Ensure done channel is only closed once
+
+
// Ping goroutine
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+
log.Printf("Failed to send ping: %v", err)
+
closeOnce.Do(func() { close(done) })
+
return
+
}
+
case <-done:
+
return
+
}
+
}
+
}()
+
+
// Read loop
+
for {
+
select {
+
case <-done:
+
return fmt.Errorf("connection closed by ping failure")
+
default:
+
}
+
+
_, message, err := conn.ReadMessage()
+
if err != nil {
+
closeOnce.Do(func() { close(done) })
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Parse Jetstream event
+
var event JetstreamEvent
+
if err := json.Unmarshal(message, &event); err != nil {
+
log.Printf("Failed to parse Jetstream event: %v", err)
+
continue
+
}
+
+
// Process event through consumer
+
if err := c.consumer.HandleEvent(ctx, &event); err != nil {
+
log.Printf("Failed to handle post event: %v", err)
+
// Continue processing other events even if one fails
+
}
+
}
+
}
+38
internal/core/communityFeeds/errors.go
···
+
package communityFeeds
+
+
import (
+
"errors"
+
"fmt"
+
)
+
+
var (
+
// ErrCommunityNotFound is returned when the community doesn't exist
+
ErrCommunityNotFound = errors.New("community not found")
+
+
// ErrInvalidCursor is returned when the pagination cursor is invalid
+
ErrInvalidCursor = errors.New("invalid pagination cursor")
+
)
+
+
// ValidationError represents an input validation error
+
type ValidationError struct {
+
Field string
+
Message string
+
}
+
+
func (e *ValidationError) Error() string {
+
return fmt.Sprintf("validation error: %s: %s", e.Field, e.Message)
+
}
+
+
// NewValidationError creates a new validation error
+
func NewValidationError(field, message string) error {
+
return &ValidationError{
+
Field: field,
+
Message: message,
+
}
+
}
+
+
// IsValidationError checks if an error is a validation error
+
func IsValidationError(err error) bool {
+
var ve *ValidationError
+
return errors.As(err, &ve)
+
}
+25
internal/core/communityFeeds/interfaces.go
···
+
package communityFeeds
+
+
import "context"
+
+
// Service defines the business logic interface for feeds
+
type Service interface {
+
// GetCommunityFeed returns posts from a specific community with sorting
+
// Supports hot/top/new algorithms, pagination, and viewer state
+
GetCommunityFeed(ctx context.Context, req GetCommunityFeedRequest) (*FeedResponse, error)
+
+
// Future methods (Beta):
+
// GetTimeline(ctx context.Context, req GetTimelineRequest) (*FeedResponse, error)
+
// GetAuthorFeed(ctx context.Context, authorDID string, limit int, cursor *string) (*FeedResponse, error)
+
}
+
+
// Repository defines the data access interface for feeds
+
type Repository interface {
+
// GetCommunityFeed retrieves posts from a community with sorting and pagination
+
// Returns hydrated PostView objects (single query with JOINs)
+
GetCommunityFeed(ctx context.Context, req GetCommunityFeedRequest) ([]*FeedViewPost, *string, error)
+
+
// Future methods (Beta):
+
// GetTimeline(ctx context.Context, userDID string, limit int, cursor *string) ([]*FeedViewPost, *string, error)
+
// GetAuthorFeed(ctx context.Context, authorDID string, limit int, cursor *string) ([]*FeedViewPost, *string, error)
+
}
+23
internal/api/routes/communityFeed.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/communityFeed"
+
"Coves/internal/core/communityFeeds"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterCommunityFeedRoutes registers feed-related XRPC endpoints
+
func RegisterCommunityFeedRoutes(
+
r chi.Router,
+
feedService communityFeeds.Service,
+
) {
+
// Create handlers
+
getCommunityHandler := communityFeed.NewGetCommunityHandler(feedService)
+
+
// GET /xrpc/social.coves.communityFeed.getCommunity
+
// Public endpoint - basic community sorting only for Alpha
+
// TODO(feed-generator): Add OptionalAuth middleware when implementing viewer-specific state
+
// (blocks, upvotes, saves, etc.) in feed generator skeleton
+
r.Get("/xrpc/social.coves.communityFeed.getCommunity", getCommunityHandler.HandleGetCommunity)
+
}
+54
internal/atproto/lexicon/social/coves/aggregator/authorization.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.authorization",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Authorization for an aggregator to post to a community with specific configuration. Published in the community's repository by moderators. Similar to social.coves.actor.subscription.",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": ["aggregatorDid", "communityDid", "enabled", "createdAt", "createdBy"],
+
"properties": {
+
"aggregatorDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the authorized aggregator"
+
},
+
"communityDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the community granting access (must match repo DID)"
+
},
+
"enabled": {
+
"type": "boolean",
+
"description": "Whether this aggregator is currently active. Can be toggled without deleting the record."
+
},
+
"config": {
+
"type": "unknown",
+
"description": "Aggregator-specific configuration. Must conform to the aggregator's configSchema."
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"createdBy": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of moderator who authorized this aggregator"
+
},
+
"disabledAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When this authorization was disabled (if enabled=false)"
+
},
+
"disabledBy": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of moderator who disabled this aggregator"
+
}
+
}
+
}
+
}
+
}
+
}
+209
internal/atproto/lexicon/social/coves/aggregator/defs.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.defs",
+
"defs": {
+
"aggregatorView": {
+
"type": "object",
+
"description": "Detailed view of an aggregator service",
+
"required": ["did", "displayName", "createdAt"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator service"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640,
+
"description": "Human-readable name (e.g., 'RSS News Aggregator')"
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 300,
+
"maxLength": 3000,
+
"description": "Description of what this aggregator does"
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to avatar image"
+
},
+
"configSchema": {
+
"type": "unknown",
+
"description": "JSON Schema describing config options for this aggregator"
+
},
+
"sourceUrl": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to aggregator's source code (for transparency)"
+
},
+
"maintainer": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of person/organization maintaining this aggregator"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"recordUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the service declaration record"
+
}
+
}
+
},
+
"aggregatorViewDetailed": {
+
"type": "object",
+
"description": "Detailed view of an aggregator with stats",
+
"required": ["did", "displayName", "createdAt", "stats"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 300,
+
"maxLength": 3000
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri"
+
},
+
"configSchema": {
+
"type": "unknown"
+
},
+
"sourceUrl": {
+
"type": "string",
+
"format": "uri"
+
},
+
"maintainer": {
+
"type": "string",
+
"format": "did"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"recordUri": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"stats": {
+
"type": "ref",
+
"ref": "#aggregatorStats"
+
}
+
}
+
},
+
"aggregatorStats": {
+
"type": "object",
+
"description": "Statistics about an aggregator's usage",
+
"required": ["communitiesUsing", "postsCreated"],
+
"properties": {
+
"communitiesUsing": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of communities that have authorized this aggregator"
+
},
+
"postsCreated": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of posts created by this aggregator"
+
}
+
}
+
},
+
"authorizationView": {
+
"type": "object",
+
"description": "View of an aggregator authorization for a community",
+
"required": ["aggregatorDid", "communityDid", "enabled", "createdAt"],
+
"properties": {
+
"aggregatorDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the authorized aggregator"
+
},
+
"communityDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the community"
+
},
+
"communityHandle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Handle of the community"
+
},
+
"communityName": {
+
"type": "string",
+
"description": "Display name of the community"
+
},
+
"enabled": {
+
"type": "boolean",
+
"description": "Whether this aggregator is currently active"
+
},
+
"config": {
+
"type": "unknown",
+
"description": "Aggregator-specific configuration"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"createdBy": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of moderator who authorized this aggregator"
+
},
+
"disabledAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When this authorization was disabled (if enabled=false)"
+
},
+
"disabledBy": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of moderator who disabled this aggregator"
+
},
+
"recordUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the authorization record"
+
}
+
}
+
},
+
"communityAuthView": {
+
"type": "object",
+
"description": "Aggregator's view of authorization for a community (used by aggregators querying their authorizations)",
+
"required": ["aggregator", "enabled", "createdAt"],
+
"properties": {
+
"aggregator": {
+
"type": "ref",
+
"ref": "#aggregatorView",
+
"description": "The aggregator service details"
+
},
+
"enabled": {
+
"type": "boolean",
+
"description": "Whether this authorization is currently active"
+
},
+
"config": {
+
"type": "unknown",
+
"description": "Community-specific configuration for this aggregator"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"recordUri": {
+
"type": "string",
+
"format": "at-uri"
+
}
+
}
+
}
+
}
+
}
+67
internal/atproto/lexicon/social/coves/aggregator/disable.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.disable",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Disable an aggregator for a community. Updates the authorization record to set enabled=false. Requires moderator permissions.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["community", "aggregatorDid"],
+
"properties": {
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the community"
+
},
+
"aggregatorDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator to disable"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the updated authorization record"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the updated authorization record"
+
},
+
"disabledAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the aggregator was disabled"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotAuthorized",
+
"description": "Caller is not a moderator of this community"
+
},
+
{
+
"name": "AuthorizationNotFound",
+
"description": "Aggregator is not enabled for this community"
+
},
+
{
+
"name": "AlreadyDisabled",
+
"description": "Aggregator is already disabled"
+
}
+
]
+
}
+
}
+
}
+75
internal/atproto/lexicon/social/coves/aggregator/enable.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.enable",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Enable an aggregator for a community. Creates an authorization record in the community's repository. Requires moderator permissions.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["community", "aggregatorDid"],
+
"properties": {
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the community"
+
},
+
"aggregatorDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator to enable"
+
},
+
"config": {
+
"type": "unknown",
+
"description": "Aggregator-specific configuration. Must conform to the aggregator's configSchema."
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid", "authorization"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the created authorization record"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the created authorization record"
+
},
+
"authorization": {
+
"type": "ref",
+
"ref": "social.coves.aggregator.defs#authorizationView",
+
"description": "The created authorization details"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotAuthorized",
+
"description": "Caller is not a moderator of this community"
+
},
+
{
+
"name": "AggregatorNotFound",
+
"description": "Aggregator DID does not exist or has no service declaration"
+
},
+
{
+
"name": "InvalidConfig",
+
"description": "Config does not match aggregator's configSchema"
+
},
+
{
+
"name": "AlreadyEnabled",
+
"description": "Aggregator is already enabled for this community"
+
}
+
]
+
}
+
}
+
}
+64
internal/atproto/lexicon/social/coves/aggregator/getAuthorizations.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.getAuthorizations",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get list of communities that have authorized a specific aggregator. Used by aggregators to query which communities they can post to. Authentication optional.",
+
"parameters": {
+
"type": "params",
+
"required": ["aggregatorDid"],
+
"properties": {
+
"aggregatorDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator"
+
},
+
"enabledOnly": {
+
"type": "boolean",
+
"default": true,
+
"description": "Only return enabled authorizations"
+
},
+
"limit": {
+
"type": "integer",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50,
+
"description": "Maximum number of authorizations to return"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["authorizations"],
+
"properties": {
+
"authorizations": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.aggregator.defs#communityAuthView"
+
},
+
"description": "Array of community authorizations for this aggregator"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor for next page"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "AggregatorNotFound",
+
"description": "Aggregator DID does not exist or has no service declaration"
+
}
+
]
+
}
+
}
+
}
+50
internal/atproto/lexicon/social/coves/aggregator/getServices.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.getServices",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get information about aggregator services. Can fetch one or multiple aggregators by DID. Authentication optional.",
+
"parameters": {
+
"type": "params",
+
"required": ["dids"],
+
"properties": {
+
"dids": {
+
"type": "array",
+
"items": {
+
"type": "string",
+
"format": "did"
+
},
+
"maxLength": 25,
+
"description": "Array of aggregator DIDs to fetch"
+
},
+
"detailed": {
+
"type": "boolean",
+
"default": false,
+
"description": "Include usage statistics in response"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["views"],
+
"properties": {
+
"views": {
+
"type": "array",
+
"items": {
+
"type": "union",
+
"refs": [
+
"social.coves.aggregator.defs#aggregatorView",
+
"social.coves.aggregator.defs#aggregatorViewDetailed"
+
]
+
},
+
"description": "Array of aggregator views. Returns aggregatorView if detailed=false, aggregatorViewDetailed if detailed=true."
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+64
internal/atproto/lexicon/social/coves/aggregator/listForCommunity.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.listForCommunity",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "List all aggregators authorized for a specific community. Used by community settings UI to show enabled/disabled aggregators. Authentication optional.",
+
"parameters": {
+
"type": "params",
+
"required": ["community"],
+
"properties": {
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the community"
+
},
+
"enabledOnly": {
+
"type": "boolean",
+
"default": false,
+
"description": "Only return enabled aggregators"
+
},
+
"limit": {
+
"type": "integer",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50,
+
"description": "Maximum number of aggregators to return"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["aggregators"],
+
"properties": {
+
"aggregators": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.aggregator.defs#authorizationView"
+
},
+
"description": "Array of aggregator authorizations for this community"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor for next page"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommunityNotFound",
+
"description": "Community not found"
+
}
+
]
+
}
+
}
+
}
+58
internal/atproto/lexicon/social/coves/aggregator/service.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.service",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Declaration of an aggregator service that can post to communities. Published in the aggregator's own repository. Similar to app.bsky.feed.generator and app.bsky.labeler.service.",
+
"key": "literal:self",
+
"record": {
+
"type": "object",
+
"required": ["did", "displayName", "createdAt"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator service (must match repo DID)"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640,
+
"description": "Human-readable name (e.g., 'RSS News Aggregator')"
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 300,
+
"maxLength": 3000,
+
"description": "Description of what this aggregator does"
+
},
+
"avatar": {
+
"type": "blob",
+
"accept": ["image/png", "image/jpeg", "image/webp"],
+
"maxSize": 1000000,
+
"description": "Avatar image for bot identity"
+
},
+
"configSchema": {
+
"type": "unknown",
+
"description": "JSON Schema describing config options for this aggregator. Communities use this to know what configuration fields are available."
+
},
+
"sourceUrl": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to aggregator's source code (for transparency)"
+
},
+
"maintainer": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of person/organization maintaining this aggregator"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+71
internal/atproto/lexicon/social/coves/aggregator/updateConfig.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.updateConfig",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Update configuration for an enabled aggregator. Updates the authorization record's config field. Requires moderator permissions.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["community", "aggregatorDid", "config"],
+
"properties": {
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the community"
+
},
+
"aggregatorDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator"
+
},
+
"config": {
+
"type": "unknown",
+
"description": "New aggregator-specific configuration. Must conform to the aggregator's configSchema."
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid", "authorization"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the updated authorization record"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the updated authorization record"
+
},
+
"authorization": {
+
"type": "ref",
+
"ref": "social.coves.aggregator.defs#authorizationView",
+
"description": "The updated authorization details"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotAuthorized",
+
"description": "Caller is not a moderator of this community"
+
},
+
{
+
"name": "AuthorizationNotFound",
+
"description": "Aggregator is not enabled for this community"
+
},
+
{
+
"name": "InvalidConfig",
+
"description": "Config does not match aggregator's configSchema"
+
}
+
]
+
}
+
}
+
}
+214
internal/db/migrations/012_create_aggregators_tables.sql
···
+
-- +goose Up
+
-- Create aggregators tables for indexing aggregator service declarations and authorizations
+
-- These records are indexed from Jetstream firehose consumer
+
+
-- ============================================================================
+
-- Table: aggregators
+
-- Purpose: Index aggregator service declarations from social.coves.aggregator.service records
+
-- Source: Aggregator's own repository (at://aggregator_did/social.coves.aggregator.service/self)
+
-- ============================================================================
+
CREATE TABLE aggregators (
+
-- Primary identity
+
did TEXT PRIMARY KEY, -- Aggregator's DID (must match repo DID)
+
+
-- Service metadata (from lexicon)
+
display_name TEXT NOT NULL, -- Human-readable name
+
description TEXT, -- What this aggregator does
+
config_schema JSONB, -- JSON Schema for community config validation
+
avatar_url TEXT, -- Avatar image URL (extracted from blob)
+
source_url TEXT, -- URL to source code (transparency)
+
maintainer_did TEXT, -- DID of maintainer
+
+
-- atProto record metadata
+
record_uri TEXT NOT NULL UNIQUE, -- AT-URI of service declaration record
+
record_cid TEXT NOT NULL, -- CID of current record version
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When the aggregator service was created (from lexicon createdAt field)
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed/updated by AppView
+
+
-- Cached stats (updated by aggregator_posts table triggers/queries)
+
communities_using INTEGER NOT NULL DEFAULT 0, -- Count of communities with enabled authorizations
+
posts_created BIGINT NOT NULL DEFAULT 0 -- Total posts created by this aggregator
+
);
+
+
-- Indexes for discovery and lookups
+
CREATE INDEX idx_aggregators_created_at ON aggregators(created_at DESC);
+
CREATE INDEX idx_aggregators_indexed_at ON aggregators(indexed_at DESC);
+
CREATE INDEX idx_aggregators_maintainer ON aggregators(maintainer_did);
+
+
-- Comments
+
COMMENT ON TABLE aggregators IS 'Aggregator service declarations indexed from social.coves.aggregator.service records';
+
COMMENT ON COLUMN aggregators.did IS 'DID of the aggregator service (matches repo DID)';
+
COMMENT ON COLUMN aggregators.config_schema IS 'JSON Schema defining what config options communities can set';
+
COMMENT ON COLUMN aggregators.created_at IS 'When the aggregator service was created (from lexicon record createdAt field)';
+
COMMENT ON COLUMN aggregators.communities_using IS 'Cached count of communities with enabled=true authorizations';
+
+
+
-- ============================================================================
+
-- Table: aggregator_authorizations
+
-- Purpose: Index community authorization records for aggregators
+
-- Source: Community's repository (at://community_did/social.coves.aggregator.authorization/rkey)
+
-- ============================================================================
+
CREATE TABLE aggregator_authorizations (
+
id BIGSERIAL PRIMARY KEY,
+
+
-- Authorization identity
+
aggregator_did TEXT NOT NULL, -- DID of authorized aggregator
+
community_did TEXT NOT NULL, -- DID of community granting access
+
+
-- Authorization state
+
enabled BOOLEAN NOT NULL DEFAULT true, -- Whether aggregator is currently active
+
config JSONB, -- Community-specific config (validated against aggregator's schema)
+
+
-- Audit trail (from lexicon)
+
created_at TIMESTAMPTZ NOT NULL, -- When authorization was created
+
created_by TEXT NOT NULL, -- DID of moderator who authorized (set by API, not client)
+
disabled_at TIMESTAMPTZ, -- When authorization was disabled (if enabled=false)
+
disabled_by TEXT, -- DID of moderator who disabled
+
+
-- atProto record metadata
+
record_uri TEXT NOT NULL UNIQUE, -- AT-URI of authorization record
+
record_cid TEXT NOT NULL, -- CID of current record version
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed/updated by AppView
+
+
-- Constraints
+
UNIQUE(aggregator_did, community_did), -- One authorization per aggregator per community
+
CONSTRAINT fk_aggregator FOREIGN KEY (aggregator_did) REFERENCES aggregators(did) ON DELETE CASCADE,
+
CONSTRAINT fk_community FOREIGN KEY (community_did) REFERENCES communities(did) ON DELETE CASCADE
+
);
+
+
-- Indexes for authorization checks (CRITICAL PATH - used on every aggregator post)
+
CREATE INDEX idx_aggregator_auth_agg_enabled ON aggregator_authorizations(aggregator_did, enabled) WHERE enabled = true;
+
CREATE INDEX idx_aggregator_auth_comm_enabled ON aggregator_authorizations(community_did, enabled) WHERE enabled = true;
+
CREATE INDEX idx_aggregator_auth_lookup ON aggregator_authorizations(aggregator_did, community_did, enabled);
+
+
-- Indexes for listing/discovery
+
CREATE INDEX idx_aggregator_auth_agg_did ON aggregator_authorizations(aggregator_did, created_at DESC);
+
CREATE INDEX idx_aggregator_auth_comm_did ON aggregator_authorizations(community_did, created_at DESC);
+
+
-- Comments
+
COMMENT ON TABLE aggregator_authorizations IS 'Community authorizations for aggregators indexed from social.coves.aggregator.authorization records';
+
COMMENT ON COLUMN aggregator_authorizations.config IS 'Community-specific config, validated against aggregators.config_schema';
+
COMMENT ON INDEX idx_aggregator_auth_lookup IS 'CRITICAL: Fast lookup for post creation authorization checks';
+
+
+
-- ============================================================================
+
-- Table: aggregator_posts
+
-- Purpose: Track posts created by aggregators for rate limiting and stats
+
-- Note: This is AppView-only data, not from lexicon records
+
-- ============================================================================
+
CREATE TABLE aggregator_posts (
+
id BIGSERIAL PRIMARY KEY,
+
+
-- Post identity
+
aggregator_did TEXT NOT NULL, -- DID of aggregator that created the post
+
community_did TEXT NOT NULL, -- DID of community post was created in
+
post_uri TEXT NOT NULL, -- AT-URI of the post record
+
post_cid TEXT NOT NULL, -- CID of the post
+
+
-- Timestamp
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When post was created
+
+
-- Constraints
+
UNIQUE(post_uri), -- Each post tracked once
+
CONSTRAINT fk_aggregator_posts_agg FOREIGN KEY (aggregator_did) REFERENCES aggregators(did) ON DELETE CASCADE,
+
CONSTRAINT fk_aggregator_posts_comm FOREIGN KEY (community_did) REFERENCES communities(did) ON DELETE CASCADE
+
);
+
+
-- Indexes for rate limiting queries (CRITICAL PATH - used on every aggregator post)
+
CREATE INDEX idx_aggregator_posts_rate_limit ON aggregator_posts(aggregator_did, community_did, created_at DESC);
+
+
-- Indexes for stats
+
CREATE INDEX idx_aggregator_posts_agg_did ON aggregator_posts(aggregator_did, created_at DESC);
+
CREATE INDEX idx_aggregator_posts_comm_did ON aggregator_posts(community_did, created_at DESC);
+
+
-- Comments
+
COMMENT ON TABLE aggregator_posts IS 'AppView-only tracking of posts created by aggregators for rate limiting and stats';
+
COMMENT ON INDEX idx_aggregator_posts_rate_limit IS 'CRITICAL: Fast rate limit checks (posts in last hour per community)';
+
+
+
-- ============================================================================
+
-- Trigger: Update aggregator stats when authorizations change
+
-- Purpose: Keep aggregators.communities_using count accurate
+
-- ============================================================================
+
-- +goose StatementBegin
+
CREATE OR REPLACE FUNCTION update_aggregator_communities_count()
+
RETURNS TRIGGER AS $$
+
BEGIN
+
-- Recalculate communities_using count for affected aggregator
+
IF TG_OP = 'DELETE' THEN
+
UPDATE aggregators
+
SET communities_using = (
+
SELECT COUNT(*)
+
FROM aggregator_authorizations
+
WHERE aggregator_did = OLD.aggregator_did
+
AND enabled = true
+
)
+
WHERE did = OLD.aggregator_did;
+
RETURN OLD;
+
ELSE
+
UPDATE aggregators
+
SET communities_using = (
+
SELECT COUNT(*)
+
FROM aggregator_authorizations
+
WHERE aggregator_did = NEW.aggregator_did
+
AND enabled = true
+
)
+
WHERE did = NEW.aggregator_did;
+
RETURN NEW;
+
END IF;
+
END;
+
$$ LANGUAGE plpgsql;
+
-- +goose StatementEnd
+
+
CREATE TRIGGER trigger_update_aggregator_communities_count
+
AFTER INSERT OR UPDATE OR DELETE ON aggregator_authorizations
+
FOR EACH ROW
+
EXECUTE FUNCTION update_aggregator_communities_count();
+
+
COMMENT ON FUNCTION update_aggregator_communities_count IS 'Maintains aggregators.communities_using count when authorizations change';
+
+
+
-- ============================================================================
+
-- Trigger: Update aggregator stats when posts are created
+
-- Purpose: Keep aggregators.posts_created count accurate
+
-- ============================================================================
+
-- +goose StatementBegin
+
CREATE OR REPLACE FUNCTION update_aggregator_posts_count()
+
RETURNS TRIGGER AS $$
+
BEGIN
+
IF TG_OP = 'INSERT' THEN
+
UPDATE aggregators
+
SET posts_created = posts_created + 1
+
WHERE did = NEW.aggregator_did;
+
RETURN NEW;
+
ELSIF TG_OP = 'DELETE' THEN
+
UPDATE aggregators
+
SET posts_created = posts_created - 1
+
WHERE did = OLD.aggregator_did;
+
RETURN OLD;
+
END IF;
+
END;
+
$$ LANGUAGE plpgsql;
+
-- +goose StatementEnd
+
+
CREATE TRIGGER trigger_update_aggregator_posts_count
+
AFTER INSERT OR DELETE ON aggregator_posts
+
FOR EACH ROW
+
EXECUTE FUNCTION update_aggregator_posts_count();
+
+
COMMENT ON FUNCTION update_aggregator_posts_count IS 'Maintains aggregators.posts_created count when posts are tracked';
+
+
+
-- +goose Down
+
-- Drop triggers first
+
DROP TRIGGER IF EXISTS trigger_update_aggregator_posts_count ON aggregator_posts;
+
DROP TRIGGER IF EXISTS trigger_update_aggregator_communities_count ON aggregator_authorizations;
+
+
-- Drop functions
+
DROP FUNCTION IF EXISTS update_aggregator_posts_count();
+
DROP FUNCTION IF EXISTS update_aggregator_communities_count();
+
+
-- Drop tables in reverse order (respects foreign keys)
+
DROP TABLE IF EXISTS aggregator_posts CASCADE;
+
DROP TABLE IF EXISTS aggregator_authorizations CASCADE;
+
DROP TABLE IF EXISTS aggregators CASCADE;
+136
internal/atproto/jetstream/aggregator_jetstream_connector.go
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
+
"github.com/gorilla/websocket"
+
)
+
+
// AggregatorJetstreamConnector handles WebSocket connection to Jetstream for aggregator events
+
type AggregatorJetstreamConnector struct {
+
consumer *AggregatorEventConsumer
+
wsURL string
+
}
+
+
// NewAggregatorJetstreamConnector creates a new Jetstream WebSocket connector for aggregator events
+
func NewAggregatorJetstreamConnector(consumer *AggregatorEventConsumer, wsURL string) *AggregatorJetstreamConnector {
+
return &AggregatorJetstreamConnector{
+
consumer: consumer,
+
wsURL: wsURL,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *AggregatorJetstreamConnector) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream aggregator consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream aggregator consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream aggregator connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *AggregatorJetstreamConnector) connect(ctx context.Context) error {
+
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() {
+
if closeErr := conn.Close(); closeErr != nil {
+
log.Printf("Failed to close WebSocket connection: %v", closeErr)
+
}
+
}()
+
+
log.Println("Connected to Jetstream (aggregator consumer)")
+
+
// Set read deadline to detect connection issues
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline in pong handler: %v", err)
+
}
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
var closeOnce sync.Once // Ensure done channel is only closed once
+
+
// Goroutine to send pings
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+
log.Printf("Ping error: %v", err)
+
closeOnce.Do(func() { close(done) })
+
return
+
}
+
case <-done:
+
return
+
case <-ctx.Done():
+
return
+
}
+
}
+
}()
+
+
// Read messages
+
for {
+
select {
+
case <-ctx.Done():
+
return ctx.Err()
+
case <-done:
+
return fmt.Errorf("connection closed")
+
default:
+
_, message, err := conn.ReadMessage()
+
if err != nil {
+
closeOnce.Do(func() { close(done) })
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Reset read deadline on successful read
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
if err := c.handleEvent(ctx, message); err != nil {
+
log.Printf("Error handling aggregator event: %v", err)
+
// Continue processing other events
+
}
+
}
+
}
+
}
+
+
// handleEvent processes a single Jetstream event
+
func (c *AggregatorJetstreamConnector) handleEvent(ctx context.Context, data []byte) error {
+
var event JetstreamEvent
+
if err := json.Unmarshal(data, &event); err != nil {
+
return fmt.Errorf("failed to parse event: %w", err)
+
}
+
+
// Pass to consumer's HandleEvent method
+
return c.consumer.HandleEvent(ctx, &event)
+
}
+41
aggregators/kagi-news/.gitignore
···
+
# Environment and config
+
.env
+
config.yaml
+
venv/
+
+
# State files
+
data/*.json
+
data/world.xml
+
+
# Python
+
__pycache__/
+
*.py[cod]
+
*$py.class
+
*.so
+
.Python
+
build/
+
develop-eggs/
+
dist/
+
downloads/
+
eggs/
+
.eggs/
+
lib/
+
lib64/
+
parts/
+
sdist/
+
var/
+
wheels/
+
*.egg-info/
+
.installed.cfg
+
*.egg
+
+
# Testing
+
.pytest_cache/
+
.coverage
+
htmlcov/
+
+
# IDE
+
.vscode/
+
.idea/
+
*.swp
+
*.swo
+3
aggregators/kagi-news/src/__init__.py
···
+
"""Kagi News RSS Aggregator for Coves."""
+
+
__version__ = "0.1.0"
+165
aggregators/kagi-news/src/config.py
···
+
"""
+
Configuration Loader for Kagi News Aggregator.
+
+
Loads and validates configuration from YAML files.
+
"""
+
import os
+
import logging
+
from pathlib import Path
+
from typing import Dict, Any
+
import yaml
+
from urllib.parse import urlparse
+
+
from src.models import AggregatorConfig, FeedConfig
+
+
logger = logging.getLogger(__name__)
+
+
+
class ConfigError(Exception):
+
"""Configuration error."""
+
pass
+
+
+
class ConfigLoader:
+
"""
+
Loads and validates aggregator configuration.
+
+
Supports:
+
- Loading from YAML file
+
- Environment variable overrides
+
- Validation of required fields
+
- URL validation
+
"""
+
+
def __init__(self, config_path: Path):
+
"""
+
Initialize config loader.
+
+
Args:
+
config_path: Path to config.yaml file
+
"""
+
self.config_path = Path(config_path)
+
+
def load(self) -> AggregatorConfig:
+
"""
+
Load and validate configuration.
+
+
Returns:
+
AggregatorConfig object
+
+
Raises:
+
ConfigError: If config is invalid or missing
+
"""
+
# Check file exists
+
if not self.config_path.exists():
+
raise ConfigError(f"Configuration file not found: {self.config_path}")
+
+
# Load YAML
+
try:
+
with open(self.config_path, 'r') as f:
+
config_data = yaml.safe_load(f)
+
except yaml.YAMLError as e:
+
raise ConfigError(f"Failed to parse YAML: {e}")
+
+
if not config_data:
+
raise ConfigError("Configuration file is empty")
+
+
# Validate and parse
+
try:
+
return self._parse_config(config_data)
+
except Exception as e:
+
raise ConfigError(f"Invalid configuration: {e}")
+
+
def _parse_config(self, data: Dict[str, Any]) -> AggregatorConfig:
+
"""
+
Parse and validate configuration data.
+
+
Args:
+
data: Parsed YAML data
+
+
Returns:
+
AggregatorConfig object
+
+
Raises:
+
ConfigError: If validation fails
+
"""
+
# Get coves_api_url (with env override)
+
coves_api_url = os.getenv('COVES_API_URL', data.get('coves_api_url'))
+
if not coves_api_url:
+
raise ConfigError("Missing required field: coves_api_url")
+
+
# Validate URL
+
if not self._is_valid_url(coves_api_url):
+
raise ConfigError(f"Invalid URL for coves_api_url: {coves_api_url}")
+
+
# Get log level (default to info)
+
log_level = data.get('log_level', 'info')
+
+
# Parse feeds
+
feeds_data = data.get('feeds', [])
+
if not feeds_data:
+
raise ConfigError("Configuration must include at least one feed")
+
+
feeds = []
+
for feed_data in feeds_data:
+
feed = self._parse_feed(feed_data)
+
feeds.append(feed)
+
+
logger.info(f"Loaded configuration with {len(feeds)} feeds ({sum(1 for f in feeds if f.enabled)} enabled)")
+
+
return AggregatorConfig(
+
coves_api_url=coves_api_url,
+
feeds=feeds,
+
log_level=log_level
+
)
+
+
def _parse_feed(self, data: Dict[str, Any]) -> FeedConfig:
+
"""
+
Parse and validate a single feed configuration.
+
+
Args:
+
data: Feed configuration data
+
+
Returns:
+
FeedConfig object
+
+
Raises:
+
ConfigError: If validation fails
+
"""
+
# Required fields
+
required_fields = ['name', 'url', 'community_handle']
+
for field in required_fields:
+
if field not in data:
+
raise ConfigError(f"Missing required field in feed config: {field}")
+
+
name = data['name']
+
url = data['url']
+
community_handle = data['community_handle']
+
enabled = data.get('enabled', True) # Default to True
+
+
# Validate URL
+
if not self._is_valid_url(url):
+
raise ConfigError(f"Invalid URL for feed '{name}': {url}")
+
+
return FeedConfig(
+
name=name,
+
url=url,
+
community_handle=community_handle,
+
enabled=enabled
+
)
+
+
def _is_valid_url(self, url: str) -> bool:
+
"""
+
Validate URL format.
+
+
Args:
+
url: URL to validate
+
+
Returns:
+
True if valid, False otherwise
+
"""
+
try:
+
result = urlparse(url)
+
return all([result.scheme, result.netloc])
+
except Exception:
+
return False
+71
aggregators/kagi-news/src/rss_fetcher.py
···
+
"""
+
RSS feed fetcher with retry logic and error handling.
+
"""
+
import time
+
import logging
+
import requests
+
import feedparser
+
from typing import Optional
+
+
logger = logging.getLogger(__name__)
+
+
+
class RSSFetcher:
+
"""Fetches RSS feeds with retry logic."""
+
+
def __init__(self, timeout: int = 30, max_retries: int = 3):
+
"""
+
Initialize RSS fetcher.
+
+
Args:
+
timeout: Request timeout in seconds
+
max_retries: Maximum number of retry attempts
+
"""
+
self.timeout = timeout
+
self.max_retries = max_retries
+
+
def fetch_feed(self, url: str) -> feedparser.FeedParserDict:
+
"""
+
Fetch and parse an RSS feed.
+
+
Args:
+
url: RSS feed URL
+
+
Returns:
+
Parsed feed object
+
+
Raises:
+
ValueError: If URL is empty
+
requests.RequestException: If all retry attempts fail
+
"""
+
if not url:
+
raise ValueError("URL cannot be empty")
+
+
last_error = None
+
+
for attempt in range(self.max_retries):
+
try:
+
logger.info(f"Fetching feed from {url} (attempt {attempt + 1}/{self.max_retries})")
+
+
response = requests.get(url, timeout=self.timeout)
+
response.raise_for_status()
+
+
# Parse with feedparser
+
feed = feedparser.parse(response.content)
+
+
logger.info(f"Successfully fetched feed: {feed.feed.get('title', 'Unknown')}")
+
return feed
+
+
except requests.RequestException as e:
+
last_error = e
+
logger.warning(f"Fetch attempt {attempt + 1} failed: {e}")
+
+
if attempt < self.max_retries - 1:
+
# Exponential backoff
+
sleep_time = 2 ** attempt
+
logger.info(f"Retrying in {sleep_time} seconds...")
+
time.sleep(sleep_time)
+
+
# All retries exhausted
+
logger.error(f"Failed to fetch feed after {self.max_retries} attempts")
+
raise last_error
+213
aggregators/kagi-news/src/state_manager.py
···
+
"""
+
State Manager for tracking posted stories.
+
+
Handles deduplication by tracking which stories have already been posted.
+
Uses JSON file for persistence.
+
"""
+
import json
+
import logging
+
from pathlib import Path
+
from datetime import datetime, timedelta
+
from typing import Optional, Dict, List
+
+
logger = logging.getLogger(__name__)
+
+
+
class StateManager:
+
"""
+
Manages aggregator state for deduplication.
+
+
Tracks:
+
- Posted GUIDs per feed (with timestamps)
+
- Last successful run timestamp per feed
+
- Automatic cleanup of old entries
+
"""
+
+
def __init__(self, state_file: Path, max_guids_per_feed: int = 100, max_age_days: int = 30):
+
"""
+
Initialize state manager.
+
+
Args:
+
state_file: Path to JSON state file
+
max_guids_per_feed: Maximum GUIDs to keep per feed (default: 100)
+
max_age_days: Maximum age in days for GUIDs (default: 30)
+
"""
+
self.state_file = Path(state_file)
+
self.max_guids_per_feed = max_guids_per_feed
+
self.max_age_days = max_age_days
+
self.state = self._load_state()
+
+
def _load_state(self) -> Dict:
+
"""Load state from file, or create new state if file doesn't exist."""
+
if not self.state_file.exists():
+
logger.info(f"Creating new state file at {self.state_file}")
+
state = {'feeds': {}}
+
self._save_state(state)
+
return state
+
+
try:
+
with open(self.state_file, 'r') as f:
+
state = json.load(f)
+
logger.info(f"Loaded state from {self.state_file}")
+
return state
+
except json.JSONDecodeError as e:
+
logger.error(f"Failed to load state file: {e}. Creating new state.")
+
state = {'feeds': {}}
+
self._save_state(state)
+
return state
+
+
def _save_state(self, state: Optional[Dict] = None):
+
"""Save state to file."""
+
if state is None:
+
state = self.state
+
+
# Ensure parent directory exists
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
+
+
with open(self.state_file, 'w') as f:
+
json.dump(state, f, indent=2)
+
+
def _ensure_feed_exists(self, feed_url: str):
+
"""Ensure feed entry exists in state."""
+
if feed_url not in self.state['feeds']:
+
self.state['feeds'][feed_url] = {
+
'posted_guids': [],
+
'last_successful_run': None
+
}
+
+
def is_posted(self, feed_url: str, guid: str) -> bool:
+
"""
+
Check if a story has already been posted.
+
+
Args:
+
feed_url: RSS feed URL
+
guid: Story GUID
+
+
Returns:
+
True if already posted, False otherwise
+
"""
+
self._ensure_feed_exists(feed_url)
+
+
posted_guids = self.state['feeds'][feed_url]['posted_guids']
+
return any(entry['guid'] == guid for entry in posted_guids)
+
+
def mark_posted(self, feed_url: str, guid: str, post_uri: str):
+
"""
+
Mark a story as posted.
+
+
Args:
+
feed_url: RSS feed URL
+
guid: Story GUID
+
post_uri: AT Proto URI of created post
+
"""
+
self._ensure_feed_exists(feed_url)
+
+
# Add to posted list
+
entry = {
+
'guid': guid,
+
'post_uri': post_uri,
+
'posted_at': datetime.now().isoformat()
+
}
+
self.state['feeds'][feed_url]['posted_guids'].append(entry)
+
+
# Auto-cleanup to keep state file manageable
+
self.cleanup_old_entries(feed_url)
+
+
# Save state
+
self._save_state()
+
+
logger.info(f"Marked as posted: {guid} -> {post_uri}")
+
+
def get_last_run(self, feed_url: str) -> Optional[datetime]:
+
"""
+
Get last successful run timestamp for a feed.
+
+
Args:
+
feed_url: RSS feed URL
+
+
Returns:
+
Datetime of last run, or None if never run
+
"""
+
self._ensure_feed_exists(feed_url)
+
+
timestamp_str = self.state['feeds'][feed_url]['last_successful_run']
+
if timestamp_str is None:
+
return None
+
+
return datetime.fromisoformat(timestamp_str)
+
+
def update_last_run(self, feed_url: str, timestamp: datetime):
+
"""
+
Update last successful run timestamp.
+
+
Args:
+
feed_url: RSS feed URL
+
timestamp: Timestamp of successful run
+
"""
+
self._ensure_feed_exists(feed_url)
+
+
self.state['feeds'][feed_url]['last_successful_run'] = timestamp.isoformat()
+
self._save_state()
+
+
logger.info(f"Updated last run for {feed_url}: {timestamp}")
+
+
def cleanup_old_entries(self, feed_url: str):
+
"""
+
Remove old entries from state.
+
+
Removes entries that are:
+
- Older than max_age_days
+
- Beyond max_guids_per_feed limit (keeps most recent)
+
+
Args:
+
feed_url: RSS feed URL
+
"""
+
self._ensure_feed_exists(feed_url)
+
+
posted_guids = self.state['feeds'][feed_url]['posted_guids']
+
+
# Filter out entries older than max_age_days
+
cutoff_date = datetime.now() - timedelta(days=self.max_age_days)
+
filtered = [
+
entry for entry in posted_guids
+
if datetime.fromisoformat(entry['posted_at']) > cutoff_date
+
]
+
+
# Keep only most recent max_guids_per_feed entries
+
# Sort by posted_at (most recent first)
+
filtered.sort(key=lambda x: x['posted_at'], reverse=True)
+
filtered = filtered[:self.max_guids_per_feed]
+
+
# Update state
+
old_count = len(posted_guids)
+
new_count = len(filtered)
+
self.state['feeds'][feed_url]['posted_guids'] = filtered
+
+
if old_count != new_count:
+
logger.info(f"Cleaned up {old_count - new_count} old entries for {feed_url}")
+
+
def get_posted_count(self, feed_url: str) -> int:
+
"""
+
Get count of posted items for a feed.
+
+
Args:
+
feed_url: RSS feed URL
+
+
Returns:
+
Number of posted items
+
"""
+
self._ensure_feed_exists(feed_url)
+
return len(self.state['feeds'][feed_url]['posted_guids'])
+
+
def get_all_posted_guids(self, feed_url: str) -> List[str]:
+
"""
+
Get all posted GUIDs for a feed.
+
+
Args:
+
feed_url: RSS feed URL
+
+
Returns:
+
List of GUIDs
+
"""
+
self._ensure_feed_exists(feed_url)
+
return [entry['guid'] for entry in self.state['feeds'][feed_url]['posted_guids']]
+1
aggregators/kagi-news/tests/__init__.py
···
+
"""Test suite for Kagi News aggregator."""
+12
aggregators/kagi-news/tests/fixtures/sample_rss_item.xml
···
+
<?xml version='1.0' encoding='UTF-8'?>
+
<!-- Sample RSS item from Kagi News - includes quote, highlights, perspectives, sources -->
+
<item>
+
<title>Trump to meet Xi in South Korea on Oct 30</title>
+
<link>https://kite.kagi.com/96cf948f-8a1b-4281-9ba4-8a9e1ad7b3c6/world/10</link>
+
<description>&lt;p&gt;The White House confirmed President Trump will hold a bilateral meeting with Chinese President Xi Jinping in South Korea on October 30, at the end of an Asia trip that includes Malaysia and Japan . The administration said the meeting will take place Thursday morning local time, and Mr Trump indicated his first question to Xi would concern fentanyl and other bilateral issues . The talks come amid heightened trade tensions after Beijing expanded export curbs on rare-earth minerals and following Mr Trump's recent threat of additional tariffs on Chinese goods, making the meeting a focal point for discussions on trade, technology supply chains and energy .&lt;/p&gt;&lt;img src='https://kagiproxy.com/img/Q2SRXQtwTYBIiQeI0FG-X6taF_wHSJaXDiFUzju2kbCWGuOYIFUX--8L0BqE4VKxpbOJY3ylFPJkDpfSnyQYZ1qdOLXbphHTnsOK4jb7gqC4KCn5nf3ANbWCuaFD5ZUSijiK0k7wOLP2fyX6tynu2mPtXlCbotLo2lTrEswZl4-No2AI4mI4lkResfnRdp-YjpoEfCOHkNfbN1-0cNcHt9T2dmgBSXrQ2w' alt='News image associated with coverage of President Trump&amp;#x27;s Asia trip and planned meeting with President Xi' /&gt;&lt;br /&gt;&lt;h3&gt;Highlights:&lt;/h3&gt;&lt;ul&gt;&lt;li&gt;Itinerary details: The Asia swing begins in Malaysia, continues to Japan and ends with the bilateral meeting in South Korea on Thursday morning local time, White House press secretary Karoline Leavitt said at a briefing .&lt;/li&gt;&lt;li&gt;APEC context: US officials indicated the leaders will meet on the sidelines of the Asia-Pacific Economic Cooperation gathering, shaping expectations for short, high-level talks rather than a lengthy summit .&lt;/li&gt;&lt;li&gt;Tariff escalation: President Trump recently threatened an additional 100% tariff on Chinese goods starting in November, a step he has described as unsustainable but that has heightened urgency for talks .&lt;/li&gt;&lt;li&gt;Rare-earth impact: Beijing's expanded curbs on rare-earth exports have exposed supply vulnerabilities because US high-tech firms rely heavily on those materials, raising strategic and economic stakes for the meeting .&lt;/li&gt;&lt;/ul&gt;&lt;blockquote&gt;Work out a lot of our doubts and questions - President Trump&lt;/blockquote&gt;&lt;h3&gt;Perspectives:&lt;/h3&gt;&lt;ul&gt;&lt;li&gt;President Trump: He said his first question to President Xi would be about fentanyl and indicated he hoped to resolve bilateral doubts and questions in the talks. (&lt;a href='https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing'&gt;The Straits Times&lt;/a&gt;)&lt;/li&gt;&lt;li&gt;White House (press secretary): Karoline Leavitt confirmed the bilateral meeting will occur Thursday morning local time during a White House briefing. (&lt;a href='https://www.scmp.com/news/us/diplomacy/article/3330131/donald-trump-meet-chinas-xi-jinping-next-thursday-south-korea-crunch-talks'&gt;South China Morning Post&lt;/a&gt;)&lt;/li&gt;&lt;li&gt;Beijing/Chinese authorities: Officials have defended tighter export controls on rare-earths, a move described in reporting as not explicitly targeting the US though it has raised tensions. (&lt;a href='https://www.rt.com/news/626890-white-house-announces-trump-xi-meeting/'&gt;RT&lt;/a&gt;)&lt;/li&gt;&lt;/ul&gt;&lt;h3&gt;Sources:&lt;/h3&gt;&lt;ul&gt;&lt;li&gt;&lt;a href='https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing'&gt;Trump to meet Xi in South Korea on Oct 30 as part of Asia swing&lt;/a&gt; - straitstimes.com&lt;/li&gt;&lt;li&gt;&lt;a href='https://www.scmp.com/news/us/diplomacy/article/3330131/donald-trump-meet-chinas-xi-jinping-next-thursday-south-korea-crunch-talks'&gt;Trump to meet Xi in South Korea next Thursday as part of key Asia trip&lt;/a&gt; - scmp.com&lt;/li&gt;&lt;li&gt;&lt;a href='https://www.rt.com/news/626890-white-house-announces-trump-xi-meeting/'&gt;White House announces Trump-Xi meeting&lt;/a&gt; - rt.com&lt;/li&gt;&lt;li&gt;&lt;a href='https://www.thehindu.com/news/international/trump-to-meet-xi-in-south-korea-as-part-of-asia-swing/article70195667.ece'&gt;Trump to meet Xi in South Korea as part of Asia swing&lt;/a&gt; - thehindu.com&lt;/li&gt;&lt;li&gt;&lt;a href='https://www.aljazeera.com/news/2025/10/24/white-house-confirms-trump-to-meet-xi-in-south-korea-as-part-of-asia-tour'&gt;White House confirms Trump to meet Xi in South Korea as part of Asia tour&lt;/a&gt; - aljazeera.com&lt;/li&gt;&lt;/ul&gt;</description>
+
<guid isPermaLink="true">https://kite.kagi.com/96cf948f-8a1b-4281-9ba4-8a9e1ad7b3c6/world/10</guid>
+
<category>World</category>
+
<category>World/Diplomacy</category>
+
<category>Diplomacy</category>
+
<pubDate>Thu, 23 Oct 2025 20:56:00 +0000</pubDate>
+
</item>
+246
aggregators/kagi-news/tests/test_config.py
···
+
"""
+
Tests for Configuration Loader.
+
+
Tests loading and validating aggregator configuration.
+
"""
+
import pytest
+
import tempfile
+
from pathlib import Path
+
+
from src.config import ConfigLoader, ConfigError
+
from src.models import AggregatorConfig, FeedConfig
+
+
+
@pytest.fixture
+
def valid_config_yaml():
+
"""Valid configuration YAML."""
+
return """
+
coves_api_url: "https://api.coves.social"
+
+
feeds:
+
- name: "World News"
+
url: "https://news.kagi.com/world.xml"
+
community_handle: "world-news.coves.social"
+
enabled: true
+
+
- name: "Tech News"
+
url: "https://news.kagi.com/tech.xml"
+
community_handle: "tech.coves.social"
+
enabled: true
+
+
- name: "Science News"
+
url: "https://news.kagi.com/science.xml"
+
community_handle: "science.coves.social"
+
enabled: false
+
+
log_level: "info"
+
"""
+
+
+
@pytest.fixture
+
def temp_config_file(valid_config_yaml):
+
"""Create a temporary config file."""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(valid_config_yaml)
+
temp_path = Path(f.name)
+
yield temp_path
+
# Cleanup
+
if temp_path.exists():
+
temp_path.unlink()
+
+
+
class TestConfigLoader:
+
"""Test suite for ConfigLoader."""
+
+
def test_load_valid_config(self, temp_config_file):
+
"""Test loading valid configuration."""
+
loader = ConfigLoader(temp_config_file)
+
config = loader.load()
+
+
assert isinstance(config, AggregatorConfig)
+
assert config.coves_api_url == "https://api.coves.social"
+
assert config.log_level == "info"
+
assert len(config.feeds) == 3
+
+
def test_parse_feed_configs(self, temp_config_file):
+
"""Test parsing feed configurations."""
+
loader = ConfigLoader(temp_config_file)
+
config = loader.load()
+
+
# Check first feed
+
feed1 = config.feeds[0]
+
assert isinstance(feed1, FeedConfig)
+
assert feed1.name == "World News"
+
assert feed1.url == "https://news.kagi.com/world.xml"
+
assert feed1.community_handle == "world-news.coves.social"
+
assert feed1.enabled is True
+
+
# Check disabled feed
+
feed3 = config.feeds[2]
+
assert feed3.name == "Science News"
+
assert feed3.enabled is False
+
+
def test_get_enabled_feeds_only(self, temp_config_file):
+
"""Test getting only enabled feeds."""
+
loader = ConfigLoader(temp_config_file)
+
config = loader.load()
+
+
enabled_feeds = [f for f in config.feeds if f.enabled]
+
assert len(enabled_feeds) == 2
+
assert all(f.enabled for f in enabled_feeds)
+
+
def test_missing_config_file_raises_error(self):
+
"""Test that missing config file raises error."""
+
with pytest.raises(ConfigError, match="not found"):
+
loader = ConfigLoader(Path("nonexistent.yaml"))
+
loader.load()
+
+
def test_invalid_yaml_raises_error(self):
+
"""Test that invalid YAML raises error."""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write("invalid: yaml: content: [[[")
+
temp_path = Path(f.name)
+
+
try:
+
with pytest.raises(ConfigError, match="Failed to parse"):
+
loader = ConfigLoader(temp_path)
+
loader.load()
+
finally:
+
temp_path.unlink()
+
+
def test_missing_required_field_raises_error(self):
+
"""Test that missing required fields raise error."""
+
invalid_yaml = """
+
feeds:
+
- name: "Test"
+
url: "https://test.xml"
+
# Missing community_handle!
+
"""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(invalid_yaml)
+
temp_path = Path(f.name)
+
+
try:
+
with pytest.raises(ConfigError, match="Missing required field"):
+
loader = ConfigLoader(temp_path)
+
loader.load()
+
finally:
+
temp_path.unlink()
+
+
def test_missing_coves_api_url_raises_error(self):
+
"""Test that missing coves_api_url raises error."""
+
invalid_yaml = """
+
feeds:
+
- name: "Test"
+
url: "https://test.xml"
+
community_handle: "test.coves.social"
+
"""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(invalid_yaml)
+
temp_path = Path(f.name)
+
+
try:
+
with pytest.raises(ConfigError, match="coves_api_url"):
+
loader = ConfigLoader(temp_path)
+
loader.load()
+
finally:
+
temp_path.unlink()
+
+
def test_default_log_level(self):
+
"""Test that log_level defaults to 'info' if not specified."""
+
minimal_yaml = """
+
coves_api_url: "https://api.coves.social"
+
feeds:
+
- name: "Test"
+
url: "https://test.xml"
+
community_handle: "test.coves.social"
+
"""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(minimal_yaml)
+
temp_path = Path(f.name)
+
+
try:
+
loader = ConfigLoader(temp_path)
+
config = loader.load()
+
assert config.log_level == "info"
+
finally:
+
temp_path.unlink()
+
+
def test_default_enabled_true(self):
+
"""Test that feed enabled defaults to True if not specified."""
+
yaml_content = """
+
coves_api_url: "https://api.coves.social"
+
feeds:
+
- name: "Test"
+
url: "https://test.xml"
+
community_handle: "test.coves.social"
+
# No 'enabled' field
+
"""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(yaml_content)
+
temp_path = Path(f.name)
+
+
try:
+
loader = ConfigLoader(temp_path)
+
config = loader.load()
+
assert config.feeds[0].enabled is True
+
finally:
+
temp_path.unlink()
+
+
def test_invalid_url_format_raises_error(self):
+
"""Test that invalid URLs raise error."""
+
invalid_yaml = """
+
coves_api_url: "https://api.coves.social"
+
feeds:
+
- name: "Test"
+
url: "not-a-valid-url"
+
community_handle: "test.coves.social"
+
"""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(invalid_yaml)
+
temp_path = Path(f.name)
+
+
try:
+
with pytest.raises(ConfigError, match="Invalid URL"):
+
loader = ConfigLoader(temp_path)
+
loader.load()
+
finally:
+
temp_path.unlink()
+
+
def test_empty_feeds_list_raises_error(self):
+
"""Test that empty feeds list raises error."""
+
invalid_yaml = """
+
coves_api_url: "https://api.coves.social"
+
feeds: []
+
"""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yaml') as f:
+
f.write(invalid_yaml)
+
temp_path = Path(f.name)
+
+
try:
+
with pytest.raises(ConfigError, match="at least one feed"):
+
loader = ConfigLoader(temp_path)
+
loader.load()
+
finally:
+
temp_path.unlink()
+
+
def test_load_from_env_override(self, temp_config_file, monkeypatch):
+
"""Test that environment variables can override config values."""
+
# Set environment variable
+
monkeypatch.setenv("COVES_API_URL", "https://test.coves.social")
+
+
loader = ConfigLoader(temp_config_file)
+
config = loader.load()
+
+
# Should use env var instead of config file
+
assert config.coves_api_url == "https://test.coves.social"
+
+
def test_get_feed_by_url(self, temp_config_file):
+
"""Test helper to get feed config by URL."""
+
loader = ConfigLoader(temp_config_file)
+
config = loader.load()
+
+
feed = next((f for f in config.feeds if f.url == "https://news.kagi.com/tech.xml"), None)
+
assert feed is not None
+
assert feed.name == "Tech News"
+
assert feed.community_handle == "tech.coves.social"
+122
aggregators/kagi-news/tests/test_html_parser.py
···
+
"""
+
Tests for Kagi HTML description parser.
+
"""
+
import pytest
+
from pathlib import Path
+
from datetime import datetime
+
import html
+
+
from src.html_parser import KagiHTMLParser
+
from src.models import KagiStory, Perspective, Quote, Source
+
+
+
@pytest.fixture
+
def sample_html_description():
+
"""Load sample HTML from RSS item fixture."""
+
# This is the escaped HTML from the RSS description field
+
html_content = """<p>The White House confirmed President Trump will hold a bilateral meeting with Chinese President Xi Jinping in South Korea on October 30, at the end of an Asia trip that includes Malaysia and Japan . The administration said the meeting will take place Thursday morning local time, and Mr Trump indicated his first question to Xi would concern fentanyl and other bilateral issues . The talks come amid heightened trade tensions after Beijing expanded export curbs on rare-earth minerals and following Mr Trump's recent threat of additional tariffs on Chinese goods, making the meeting a focal point for discussions on trade, technology supply chains and energy .</p><img src='https://kagiproxy.com/img/Q2SRXQtwTYBIiQeI0FG-X6taF_wHSJaXDiFUzju2kbCWGuOYIFUX--8L0BqE4VKxpbOJY3ylFPJkDpfSnyQYZ1qdOLXbphHTnsOK4jb7gqC4KCn5nf3ANbWCuaFD5ZUSijiK0k7wOLP2fyX6tynu2mPtXlCbotLo2lTrEswZl4-No2AI4mI4lkResfnRdp-YjpoEfCOHkNfbN1-0cNcHt9T2dmgBSXrQ2w' alt='News image associated with coverage of President Trump&#x27;s Asia trip and planned meeting with President Xi' /><br /><h3>Highlights:</h3><ul><li>Itinerary details: The Asia swing begins in Malaysia, continues to Japan and ends with the bilateral meeting in South Korea on Thursday morning local time, White House press secretary Karoline Leavitt said at a briefing .</li><li>APEC context: US officials indicated the leaders will meet on the sidelines of the Asia-Pacific Economic Cooperation gathering, shaping expectations for short, high-level talks rather than a lengthy summit .</li></ul><blockquote>Work out a lot of our doubts and questions - President Trump</blockquote><h3>Perspectives:</h3><ul><li>President Trump: He said his first question to President Xi would be about fentanyl and indicated he hoped to resolve bilateral doubts and questions in the talks. (<a href='https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing'>The Straits Times</a>)</li><li>White House (press secretary): Karoline Leavitt confirmed the bilateral meeting will occur Thursday morning local time during a White House briefing. (<a href='https://www.scmp.com/news/us/diplomacy/article/3330131/donald-trump-meet-chinas-xi-jinping-next-thursday-south-korea-crunch-talks'>South China Morning Post</a>)</li></ul><h3>Sources:</h3><ul><li><a href='https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing'>Trump to meet Xi in South Korea on Oct 30 as part of Asia swing</a> - straitstimes.com</li><li><a href='https://www.scmp.com/news/us/diplomacy/article/3330131/donald-trump-meet-chinas-xi-jinping-next-thursday-south-korea-crunch-talks'>Trump to meet Xi in South Korea next Thursday as part of key Asia trip</a> - scmp.com</li></ul>"""
+
return html_content
+
+
+
class TestKagiHTMLParser:
+
"""Test suite for Kagi HTML parser."""
+
+
def test_parse_summary(self, sample_html_description):
+
"""Test extracting summary paragraph."""
+
parser = KagiHTMLParser()
+
result = parser.parse(sample_html_description)
+
+
assert result['summary'].startswith("The White House confirmed President Trump")
+
assert "bilateral meeting with Chinese President Xi Jinping" in result['summary']
+
+
def test_parse_image_url(self, sample_html_description):
+
"""Test extracting image URL and alt text."""
+
parser = KagiHTMLParser()
+
result = parser.parse(sample_html_description)
+
+
assert result['image_url'] is not None
+
assert result['image_url'].startswith("https://kagiproxy.com/img/")
+
assert result['image_alt'] is not None
+
assert "Trump" in result['image_alt']
+
+
def test_parse_highlights(self, sample_html_description):
+
"""Test extracting highlights list."""
+
parser = KagiHTMLParser()
+
result = parser.parse(sample_html_description)
+
+
assert len(result['highlights']) == 2
+
assert "Itinerary details" in result['highlights'][0]
+
assert "APEC context" in result['highlights'][1]
+
+
def test_parse_quote(self, sample_html_description):
+
"""Test extracting blockquote."""
+
parser = KagiHTMLParser()
+
result = parser.parse(sample_html_description)
+
+
assert result['quote'] is not None
+
assert result['quote']['text'] == "Work out a lot of our doubts and questions"
+
assert result['quote']['attribution'] == "President Trump"
+
+
def test_parse_perspectives(self, sample_html_description):
+
"""Test extracting perspectives list."""
+
parser = KagiHTMLParser()
+
result = parser.parse(sample_html_description)
+
+
assert len(result['perspectives']) == 2
+
+
# First perspective
+
assert result['perspectives'][0]['actor'] == "President Trump"
+
assert "fentanyl" in result['perspectives'][0]['description']
+
assert result['perspectives'][0]['source_url'] == "https://www.straitstimes.com/world/united-states/trump-to-meet-xi-in-south-korea-on-oct-30-as-part-of-asia-swing"
+
+
# Second perspective
+
assert "White House" in result['perspectives'][1]['actor']
+
+
def test_parse_sources(self, sample_html_description):
+
"""Test extracting sources list."""
+
parser = KagiHTMLParser()
+
result = parser.parse(sample_html_description)
+
+
assert len(result['sources']) >= 2
+
+
# Check first source
+
assert result['sources'][0]['title'] == "Trump to meet Xi in South Korea on Oct 30 as part of Asia swing"
+
assert result['sources'][0]['url'].startswith("https://www.straitstimes.com")
+
assert result['sources'][0]['domain'] == "straitstimes.com"
+
+
def test_parse_missing_sections(self):
+
"""Test parsing HTML with missing sections."""
+
html_minimal = "<p>Just a summary, no other sections.</p>"
+
+
parser = KagiHTMLParser()
+
result = parser.parse(html_minimal)
+
+
assert result['summary'] == "Just a summary, no other sections."
+
assert result['highlights'] == []
+
assert result['perspectives'] == []
+
assert result['sources'] == []
+
assert result['quote'] is None
+
assert result['image_url'] is None
+
+
def test_parse_to_kagi_story(self, sample_html_description):
+
"""Test converting parsed HTML to KagiStory object."""
+
parser = KagiHTMLParser()
+
+
# Simulate full RSS item data
+
story = parser.parse_to_story(
+
title="Trump to meet Xi in South Korea on Oct 30",
+
link="https://kite.kagi.com/test/world/10",
+
guid="https://kite.kagi.com/test/world/10",
+
pub_date=datetime(2025, 10, 23, 20, 56, 0),
+
categories=["World", "World/Diplomacy"],
+
html_description=sample_html_description
+
)
+
+
assert isinstance(story, KagiStory)
+
assert story.title == "Trump to meet Xi in South Korea on Oct 30"
+
assert story.link == "https://kite.kagi.com/test/world/10"
+
assert len(story.highlights) == 2
+
assert len(story.perspectives) == 2
+
assert len(story.sources) >= 2
+
assert story.quote is not None
+
assert story.image_url is not None
+299
aggregators/kagi-news/tests/test_richtext_formatter.py
···
+
"""
+
Tests for Rich Text Formatter.
+
+
Tests conversion of KagiStory to Coves rich text format with facets.
+
"""
+
import pytest
+
from datetime import datetime
+
+
from src.richtext_formatter import RichTextFormatter
+
from src.models import KagiStory, Perspective, Quote, Source
+
+
+
@pytest.fixture
+
def sample_story():
+
"""Create a sample KagiStory for testing."""
+
return KagiStory(
+
title="Trump to meet Xi in South Korea",
+
link="https://kite.kagi.com/test/world/10",
+
guid="https://kite.kagi.com/test/world/10",
+
pub_date=datetime(2025, 10, 23, 20, 56, 0),
+
categories=["World", "World/Diplomacy"],
+
summary="The White House confirmed President Trump will hold a bilateral meeting with Chinese President Xi Jinping in South Korea on October 30.",
+
highlights=[
+
"Itinerary details: The Asia swing begins in Malaysia, continues to Japan.",
+
"APEC context: US officials indicated the leaders will meet on the sidelines."
+
],
+
perspectives=[
+
Perspective(
+
actor="President Trump",
+
description="He said his first question to President Xi would be about fentanyl.",
+
source_url="https://www.straitstimes.com/world/test"
+
),
+
Perspective(
+
actor="White House (press secretary)",
+
description="Karoline Leavitt confirmed the bilateral meeting.",
+
source_url="https://www.scmp.com/news/test"
+
)
+
],
+
quote=Quote(
+
text="Work out a lot of our doubts and questions",
+
attribution="President Trump"
+
),
+
sources=[
+
Source(
+
title="Trump to meet Xi in South Korea",
+
url="https://www.straitstimes.com/world/test",
+
domain="straitstimes.com"
+
),
+
Source(
+
title="Trump meeting Xi next Thursday",
+
url="https://www.scmp.com/news/test",
+
domain="scmp.com"
+
)
+
],
+
image_url="https://kagiproxy.com/img/test123",
+
image_alt="Test image"
+
)
+
+
+
class TestRichTextFormatter:
+
"""Test suite for RichTextFormatter."""
+
+
def test_format_full_returns_content_and_facets(self, sample_story):
+
"""Test that format_full returns content and facets."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
assert 'content' in result
+
assert 'facets' in result
+
assert isinstance(result['content'], str)
+
assert isinstance(result['facets'], list)
+
+
def test_content_structure(self, sample_story):
+
"""Test that content has correct structure."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
content = result['content']
+
+
# Check all sections are present
+
assert sample_story.summary in content
+
assert "Highlights:" in content
+
assert "Perspectives:" in content
+
assert "Sources:" in content
+
assert sample_story.quote.text in content
+
assert "๐Ÿ“ฐ Story aggregated by Kagi News" in content
+
+
def test_facets_for_bold_headers(self, sample_story):
+
"""Test that section headers have bold facets."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
# Find bold facets
+
bold_facets = [
+
f for f in result['facets']
+
if any(feat.get('$type') == 'social.coves.richtext.facet#bold'
+
for feat in f['features'])
+
]
+
+
assert len(bold_facets) > 0
+
+
# Check that "Highlights:" is bolded
+
content = result['content']
+
highlights_pos = content.find("Highlights:")
+
+
# Should have a bold facet covering "Highlights:"
+
has_highlights_bold = any(
+
f['index']['byteStart'] <= highlights_pos and
+
f['index']['byteEnd'] >= highlights_pos + len("Highlights:")
+
for f in bold_facets
+
)
+
assert has_highlights_bold
+
+
def test_facets_for_italic_quote(self, sample_story):
+
"""Test that quotes have italic facets."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
# Find italic facets
+
italic_facets = [
+
f for f in result['facets']
+
if any(feat.get('$type') == 'social.coves.richtext.facet#italic'
+
for feat in f['features'])
+
]
+
+
assert len(italic_facets) > 0
+
+
# The quote text is wrapped with quotes, so search for that
+
content = result['content']
+
quote_with_quotes = f'"{sample_story.quote.text}"'
+
quote_char_pos = content.find(quote_with_quotes)
+
+
# Convert character position to byte position
+
quote_byte_start = len(content[:quote_char_pos].encode('utf-8'))
+
quote_byte_end = len(content[:quote_char_pos + len(quote_with_quotes)].encode('utf-8'))
+
+
has_quote_italic = any(
+
f['index']['byteStart'] <= quote_byte_start and
+
f['index']['byteEnd'] >= quote_byte_end
+
for f in italic_facets
+
)
+
assert has_quote_italic
+
+
def test_facets_for_links(self, sample_story):
+
"""Test that URLs have link facets."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
# Find link facets
+
link_facets = [
+
f for f in result['facets']
+
if any(feat.get('$type') == 'social.coves.richtext.facet#link'
+
for feat in f['features'])
+
]
+
+
# Should have links for: 2 sources + 2 perspectives + 1 Kagi News link = 5 minimum
+
assert len(link_facets) >= 5
+
+
# Check that first source URL has a link facet
+
source_urls = [s.url for s in sample_story.sources]
+
for url in source_urls:
+
has_link = any(
+
any(feat.get('uri') == url for feat in f['features'])
+
for f in link_facets
+
)
+
assert has_link, f"Missing link facet for {url}"
+
+
def test_utf8_byte_positions(self):
+
"""Test UTF-8 byte position calculation with multi-byte characters."""
+
# Create story with emoji and non-ASCII characters
+
story = KagiStory(
+
title="Test ๐Ÿ‘‹ Story",
+
link="https://test.com",
+
guid="https://test.com",
+
pub_date=datetime.now(),
+
categories=["Test"],
+
summary="Hello ไธ–็•Œ this is a test with emoji ๐ŸŽ‰",
+
highlights=["Test highlight"],
+
perspectives=[],
+
quote=None,
+
sources=[],
+
)
+
+
formatter = RichTextFormatter()
+
result = formatter.format_full(story)
+
+
# Verify content contains the emoji
+
assert "๐Ÿ‘‹" in result['content'] or "๐ŸŽ‰" in result['content']
+
+
# Verify all facet byte positions are valid
+
content_bytes = result['content'].encode('utf-8')
+
for facet in result['facets']:
+
start = facet['index']['byteStart']
+
end = facet['index']['byteEnd']
+
+
# Positions should be within bounds
+
assert 0 <= start < len(content_bytes)
+
assert start < end <= len(content_bytes)
+
+
def test_format_story_without_optional_fields(self):
+
"""Test formatting story with missing optional fields."""
+
minimal_story = KagiStory(
+
title="Minimal Story",
+
link="https://test.com",
+
guid="https://test.com",
+
pub_date=datetime.now(),
+
categories=["Test"],
+
summary="Just a summary.",
+
highlights=[], # Empty
+
perspectives=[], # Empty
+
quote=None, # Missing
+
sources=[], # Empty
+
)
+
+
formatter = RichTextFormatter()
+
result = formatter.format_full(minimal_story)
+
+
# Should still have content and facets
+
assert result['content']
+
assert result['facets']
+
+
# Should have summary
+
assert "Just a summary." in result['content']
+
+
# Should NOT have empty sections
+
assert "Highlights:" not in result['content']
+
assert "Perspectives:" not in result['content']
+
+
def test_perspective_actor_is_bolded(self, sample_story):
+
"""Test that perspective actor names are bolded."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
content = result['content']
+
bold_facets = [
+
f for f in result['facets']
+
if any(feat.get('$type') == 'social.coves.richtext.facet#bold'
+
for feat in f['features'])
+
]
+
+
# Find "President Trump:" in perspectives section
+
actor = "President Trump:"
+
perspectives_start = content.find("Perspectives:")
+
actor_char_pos = content.find(actor, perspectives_start)
+
+
if actor_char_pos != -1: # If found in perspectives
+
# Convert character position to byte position
+
actor_byte_start = len(content[:actor_char_pos].encode('utf-8'))
+
actor_byte_end = len(content[:actor_char_pos + len(actor)].encode('utf-8'))
+
+
has_actor_bold = any(
+
f['index']['byteStart'] <= actor_byte_start and
+
f['index']['byteEnd'] >= actor_byte_end
+
for f in bold_facets
+
)
+
assert has_actor_bold
+
+
def test_kagi_attribution_link(self, sample_story):
+
"""Test that Kagi News attribution has a link to the story."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
# Should have link to Kagi story
+
link_facets = [
+
f for f in result['facets']
+
if any(feat.get('$type') == 'social.coves.richtext.facet#link'
+
for feat in f['features'])
+
]
+
+
# Find link to the Kagi story URL
+
kagi_link = any(
+
any(feat.get('uri') == sample_story.link for feat in f['features'])
+
for f in link_facets
+
)
+
assert kagi_link, "Missing link to Kagi story in attribution"
+
+
def test_facets_do_not_overlap(self, sample_story):
+
"""Test that facets with same feature type don't overlap."""
+
formatter = RichTextFormatter()
+
result = formatter.format_full(sample_story)
+
+
# Group facets by type
+
facets_by_type = {}
+
for facet in result['facets']:
+
for feature in facet['features']:
+
ftype = feature['$type']
+
if ftype not in facets_by_type:
+
facets_by_type[ftype] = []
+
facets_by_type[ftype].append(facet)
+
+
# Check for overlaps within each type
+
for ftype, facets in facets_by_type.items():
+
for i, f1 in enumerate(facets):
+
for f2 in facets[i+1:]:
+
start1, end1 = f1['index']['byteStart'], f1['index']['byteEnd']
+
start2, end2 = f2['index']['byteStart'], f2['index']['byteEnd']
+
+
# Check if they overlap
+
overlaps = (start1 < end2 and start2 < end1)
+
assert not overlaps, f"Overlapping facets of type {ftype}: {f1} and {f2}"
+91
aggregators/kagi-news/tests/test_rss_fetcher.py
···
+
"""
+
Tests for RSS feed fetching functionality.
+
"""
+
import pytest
+
import responses
+
from pathlib import Path
+
+
from src.rss_fetcher import RSSFetcher
+
+
+
@pytest.fixture
+
def sample_rss_feed():
+
"""Load sample RSS feed from fixtures."""
+
fixture_path = Path(__file__).parent / "fixtures" / "world.xml"
+
# For now, use a minimal test feed
+
return """<?xml version='1.0' encoding='UTF-8'?>
+
<rss version="2.0">
+
<channel>
+
<title>Kagi News - World</title>
+
<item>
+
<title>Test Story</title>
+
<link>https://kite.kagi.com/test/world/1</link>
+
<guid>https://kite.kagi.com/test/world/1</guid>
+
<pubDate>Fri, 24 Oct 2025 12:00:00 +0000</pubDate>
+
<category>World</category>
+
</item>
+
</channel>
+
</rss>"""
+
+
+
class TestRSSFetcher:
+
"""Test suite for RSSFetcher."""
+
+
@responses.activate
+
def test_fetch_feed_success(self, sample_rss_feed):
+
"""Test successful RSS feed fetch."""
+
url = "https://news.kagi.com/world.xml"
+
responses.add(responses.GET, url, body=sample_rss_feed, status=200)
+
+
fetcher = RSSFetcher()
+
feed = fetcher.fetch_feed(url)
+
+
assert feed is not None
+
assert feed.feed.title == "Kagi News - World"
+
assert len(feed.entries) == 1
+
assert feed.entries[0].title == "Test Story"
+
+
@responses.activate
+
def test_fetch_feed_timeout(self):
+
"""Test fetch with timeout."""
+
url = "https://news.kagi.com/world.xml"
+
responses.add(responses.GET, url, body="timeout", status=408)
+
+
fetcher = RSSFetcher(timeout=5)
+
+
with pytest.raises(Exception): # Should raise on timeout
+
fetcher.fetch_feed(url)
+
+
@responses.activate
+
def test_fetch_feed_with_retry(self, sample_rss_feed):
+
"""Test fetch with retry on failure then success."""
+
url = "https://news.kagi.com/world.xml"
+
+
# First call fails, second succeeds
+
responses.add(responses.GET, url, body="error", status=500)
+
responses.add(responses.GET, url, body=sample_rss_feed, status=200)
+
+
fetcher = RSSFetcher(max_retries=2)
+
feed = fetcher.fetch_feed(url)
+
+
assert feed is not None
+
assert len(feed.entries) == 1
+
+
@responses.activate
+
def test_fetch_feed_invalid_xml(self):
+
"""Test handling of invalid XML."""
+
url = "https://news.kagi.com/world.xml"
+
responses.add(responses.GET, url, body="Not valid XML!", status=200)
+
+
fetcher = RSSFetcher()
+
feed = fetcher.fetch_feed(url)
+
+
# feedparser is lenient, but should have bozo flag set
+
assert feed.bozo == 1 # feedparser uses 1 for True
+
+
def test_fetch_feed_requires_url(self):
+
"""Test that fetch_feed requires a URL."""
+
fetcher = RSSFetcher()
+
+
with pytest.raises((ValueError, TypeError)):
+
fetcher.fetch_feed("")
+227
aggregators/kagi-news/tests/test_state_manager.py
···
+
"""
+
Tests for State Manager.
+
+
Tests deduplication state tracking and persistence.
+
"""
+
import pytest
+
import json
+
import tempfile
+
from pathlib import Path
+
from datetime import datetime, timedelta
+
+
from src.state_manager import StateManager
+
+
+
@pytest.fixture
+
def temp_state_file():
+
"""Create a temporary state file for testing."""
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
+
temp_path = Path(f.name)
+
yield temp_path
+
# Cleanup
+
if temp_path.exists():
+
temp_path.unlink()
+
+
+
class TestStateManager:
+
"""Test suite for StateManager."""
+
+
def test_initialize_new_state_file(self, temp_state_file):
+
"""Test initializing a new state file."""
+
manager = StateManager(temp_state_file)
+
+
# Should create an empty state
+
assert temp_state_file.exists()
+
state = json.loads(temp_state_file.read_text())
+
assert 'feeds' in state
+
assert state['feeds'] == {}
+
+
def test_is_posted_returns_false_for_new_guid(self, temp_state_file):
+
"""Test that is_posted returns False for new GUIDs."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
guid = "https://kite.kagi.com/test/world/1"
+
+
assert not manager.is_posted(feed_url, guid)
+
+
def test_mark_posted_stores_guid(self, temp_state_file):
+
"""Test that mark_posted stores GUIDs."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
guid = "https://kite.kagi.com/test/world/1"
+
post_uri = "at://did:plc:test/social.coves.post/abc123"
+
+
manager.mark_posted(feed_url, guid, post_uri)
+
+
# Should now return True
+
assert manager.is_posted(feed_url, guid)
+
+
def test_state_persists_across_instances(self, temp_state_file):
+
"""Test that state persists when creating new instances."""
+
feed_url = "https://news.kagi.com/world.xml"
+
guid = "https://kite.kagi.com/test/world/1"
+
post_uri = "at://did:plc:test/social.coves.post/abc123"
+
+
# First instance marks as posted
+
manager1 = StateManager(temp_state_file)
+
manager1.mark_posted(feed_url, guid, post_uri)
+
+
# Second instance should see the same state
+
manager2 = StateManager(temp_state_file)
+
assert manager2.is_posted(feed_url, guid)
+
+
def test_track_last_run_timestamp(self, temp_state_file):
+
"""Test tracking last successful run timestamp."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
timestamp = datetime.now()
+
+
manager.update_last_run(feed_url, timestamp)
+
+
retrieved = manager.get_last_run(feed_url)
+
assert retrieved is not None
+
# Compare timestamps (allow small difference due to serialization)
+
assert abs((retrieved - timestamp).total_seconds()) < 1
+
+
def test_get_last_run_returns_none_for_new_feed(self, temp_state_file):
+
"""Test that get_last_run returns None for new feeds."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
+
assert manager.get_last_run(feed_url) is None
+
+
def test_cleanup_old_guids(self, temp_state_file):
+
"""Test cleanup of old GUIDs (> 30 days)."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
+
# Add recent GUID
+
recent_guid = "https://kite.kagi.com/test/world/1"
+
manager.mark_posted(feed_url, recent_guid, "at://test/1")
+
+
# Manually add old GUID (> 30 days)
+
old_timestamp = (datetime.now() - timedelta(days=31)).isoformat()
+
state_data = json.loads(temp_state_file.read_text())
+
state_data['feeds'][feed_url]['posted_guids'].append({
+
'guid': 'https://kite.kagi.com/test/world/old',
+
'post_uri': 'at://test/old',
+
'posted_at': old_timestamp
+
})
+
temp_state_file.write_text(json.dumps(state_data, indent=2))
+
+
# Reload and cleanup
+
manager = StateManager(temp_state_file)
+
manager.cleanup_old_entries(feed_url)
+
+
# Recent GUID should still be there
+
assert manager.is_posted(feed_url, recent_guid)
+
+
# Old GUID should be removed
+
assert not manager.is_posted(feed_url, 'https://kite.kagi.com/test/world/old')
+
+
def test_limit_guids_to_100_per_feed(self, temp_state_file):
+
"""Test that only last 100 GUIDs are kept per feed."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
+
# Add 150 GUIDs
+
for i in range(150):
+
guid = f"https://kite.kagi.com/test/world/{i}"
+
manager.mark_posted(feed_url, guid, f"at://test/{i}")
+
+
# Cleanup (should limit to 100)
+
manager.cleanup_old_entries(feed_url)
+
+
# Reload state
+
manager = StateManager(temp_state_file)
+
+
# Should have exactly 100 entries (most recent)
+
state_data = json.loads(temp_state_file.read_text())
+
assert len(state_data['feeds'][feed_url]['posted_guids']) == 100
+
+
# Oldest entries should be removed
+
assert not manager.is_posted(feed_url, "https://kite.kagi.com/test/world/0")
+
assert not manager.is_posted(feed_url, "https://kite.kagi.com/test/world/49")
+
+
# Recent entries should still be there
+
assert manager.is_posted(feed_url, "https://kite.kagi.com/test/world/149")
+
assert manager.is_posted(feed_url, "https://kite.kagi.com/test/world/100")
+
+
def test_multiple_feeds_tracked_separately(self, temp_state_file):
+
"""Test that multiple feeds are tracked independently."""
+
manager = StateManager(temp_state_file)
+
+
feed1 = "https://news.kagi.com/world.xml"
+
feed2 = "https://news.kagi.com/tech.xml"
+
guid1 = "https://kite.kagi.com/test/world/1"
+
guid2 = "https://kite.kagi.com/test/tech/1"
+
+
manager.mark_posted(feed1, guid1, "at://test/1")
+
manager.mark_posted(feed2, guid2, "at://test/2")
+
+
# Each feed should only know about its own GUIDs
+
assert manager.is_posted(feed1, guid1)
+
assert not manager.is_posted(feed1, guid2)
+
+
assert manager.is_posted(feed2, guid2)
+
assert not manager.is_posted(feed2, guid1)
+
+
def test_get_posted_count(self, temp_state_file):
+
"""Test getting count of posted items per feed."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
+
# Initially 0
+
assert manager.get_posted_count(feed_url) == 0
+
+
# Add 5 items
+
for i in range(5):
+
manager.mark_posted(feed_url, f"guid-{i}", f"post-{i}")
+
+
assert manager.get_posted_count(feed_url) == 5
+
+
def test_state_file_format_is_valid_json(self, temp_state_file):
+
"""Test that state file is always valid JSON."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
+
manager.mark_posted(feed_url, "test-guid", "test-post-uri")
+
manager.update_last_run(feed_url, datetime.now())
+
+
# Should be valid JSON
+
with open(temp_state_file) as f:
+
state = json.load(f)
+
+
assert 'feeds' in state
+
assert feed_url in state['feeds']
+
assert 'posted_guids' in state['feeds'][feed_url]
+
assert 'last_successful_run' in state['feeds'][feed_url]
+
+
def test_automatic_cleanup_on_mark_posted(self, temp_state_file):
+
"""Test that cleanup happens automatically when marking posted."""
+
manager = StateManager(temp_state_file)
+
feed_url = "https://news.kagi.com/world.xml"
+
+
# Add old entry manually
+
old_timestamp = (datetime.now() - timedelta(days=31)).isoformat()
+
state_data = {
+
'feeds': {
+
feed_url: {
+
'posted_guids': [{
+
'guid': 'old-guid',
+
'post_uri': 'old-uri',
+
'posted_at': old_timestamp
+
}],
+
'last_successful_run': None
+
}
+
}
+
}
+
temp_state_file.write_text(json.dumps(state_data, indent=2))
+
+
# Reload and add new entry (should trigger cleanup)
+
manager = StateManager(temp_state_file)
+
manager.mark_posted(feed_url, "new-guid", "new-uri")
+
+
# Old entry should be gone
+
assert not manager.is_posted(feed_url, "old-guid")
+
assert manager.is_posted(feed_url, "new-guid")
+6
aggregators/kagi-news/.env.example
···
+
# Aggregator Identity (pre-created account credentials)
+
AGGREGATOR_HANDLE=kagi-news.local.coves.dev
+
AGGREGATOR_PASSWORD=your-secure-password-here
+
+
# Optional: Override Coves API URL (defaults to config.yaml)
+
# COVES_API_URL=http://localhost:3001
+29
aggregators/kagi-news/config.example.yaml
···
+
# Kagi News RSS Aggregator Configuration
+
+
# Coves API endpoint
+
coves_api_url: "https://api.coves.social"
+
+
# Feed-to-community mappings
+
feeds:
+
- name: "World News"
+
url: "https://news.kagi.com/world.xml"
+
community_handle: "world-news.coves.social"
+
enabled: true
+
+
- name: "Tech News"
+
url: "https://news.kagi.com/tech.xml"
+
community_handle: "tech.coves.social"
+
enabled: true
+
+
- name: "Business News"
+
url: "https://news.kagi.com/business.xml"
+
community_handle: "business.coves.social"
+
enabled: false
+
+
- name: "Science News"
+
url: "https://news.kagi.com/science.xml"
+
community_handle: "science.coves.social"
+
enabled: false
+
+
# Logging configuration
+
log_level: "info" # debug, info, warning, error
+5
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
+
+
# Blank line required at end of crontab
+
+12
aggregators/kagi-news/pytest.ini
···
+
[pytest]
+
testpaths = tests
+
python_files = test_*.py
+
python_classes = Test*
+
python_functions = test_*
+
addopts =
+
-v
+
--strict-markers
+
--tb=short
+
--cov=src
+
--cov-report=term-missing
+
--cov-report=html
+17
aggregators/kagi-news/requirements.txt
···
+
# Core dependencies
+
feedparser==6.0.11
+
beautifulsoup4==4.12.3
+
requests==2.31.0
+
atproto==0.0.55
+
pyyaml==6.0.1
+
+
# Testing
+
pytest==8.1.1
+
pytest-cov==5.0.0
+
responses==0.25.0
+
+
# Development
+
black==24.3.0
+
mypy==1.9.0
+
types-PyYAML==6.0.12.12
+
types-requests==2.31.0.20240311
+40
docs/PRD_COMMUNITIES.md
···
---
+
### Blob Upload Proxy System
+
**Status:** Design documented, implementation TODO
+
**Priority:** CRITICAL for Beta - Required for image/video posts in communities
+
+
**Problem:** Users on external PDSs cannot directly upload blobs to community-owned PDS repositories because they lack authentication credentials for the community's PDS.
+
+
**Solution:** Coves AppView acts as an authenticated proxy for blob uploads:
+
+
**Flow:**
+
1. User uploads blob to Coves AppView via `social.coves.blob.uploadForCommunity`
+
2. AppView validates user can post to community (not banned, community accessible)
+
3. AppView uses community's PDS credentials to upload blob via `com.atproto.repo.uploadBlob`
+
4. AppView returns CID to user
+
5. User creates post record referencing the CID
+
6. Post and blob both live in community's PDS
+
+
**Implementation Checklist:**
+
- [ ] Handler: `social.coves.blob.uploadForCommunity` endpoint
+
- [ ] Validation: Check user authorization to post in community
+
- [ ] Credential Management: Reuse community token refresh logic
+
- [ ] Upload Proxy: Forward blob to community's PDS with community credentials
+
- [ ] Security: Size limits, content-type validation, rate limiting
+
- [ ] Testing: E2E test with federated user uploading to community
+
+
**Why This Approach:**
+
- โœ… Works with federated users (any PDS)
+
- โœ… Reuses existing community credential infrastructure
+
- โœ… Matches V2 architecture (AppView orchestrates, communities own data)
+
- โœ… Blobs stored on correct PDS (community's repository)
+
- โŒ AppView becomes upload intermediary (bandwidth cost)
+
+
**Alternative Considered:** Direct user uploads to community PDS
+
- Rejected: Would require creating temporary user accounts on every community PDS (complex, insecure)
+
+
**See:** Design discussion in context of ATProto blob architecture
+
+
---
+
### Posts in Communities
**Status:** Lexicon designed, implementation TODO
**Priority:** HIGHEST for Beta 1
···
**Without posts, communities exist but can't be used!**
+
**Depends on:** Blob Upload Proxy System (for image/video posts)
+
---
## ๐Ÿ“ Beta Features (Lower Priority)
+30
internal/api/routes/discover.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/discover"
+
discoverCore "Coves/internal/core/discover"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterDiscoverRoutes registers discover-related XRPC endpoints
+
//
+
// SECURITY & RATE LIMITING:
+
// - Discover feed is PUBLIC (no authentication required)
+
// - Protected by global rate limiter: 100 requests/minute per IP (main.go:84)
+
// - Query timeout enforced via context (prevents long-running queries)
+
// - Result limit capped at 50 posts per request (validated in service layer)
+
// - No caching currently implemented (future: 30-60s cache for hot feed)
+
func RegisterDiscoverRoutes(
+
r chi.Router,
+
discoverService discoverCore.Service,
+
) {
+
// Create handlers
+
getDiscoverHandler := discover.NewGetDiscoverHandler(discoverService)
+
+
// GET /xrpc/social.coves.feed.getDiscover
+
// Public endpoint - no authentication required
+
// Shows posts from ALL communities (not personalized)
+
// Rate limited: 100 req/min per IP via global middleware
+
r.Get("/xrpc/social.coves.feed.getDiscover", getDiscoverHandler.HandleGetDiscover)
+
}
+23
internal/api/routes/timeline.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/timeline"
+
"Coves/internal/api/middleware"
+
timelineCore "Coves/internal/core/timeline"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterTimelineRoutes registers timeline-related XRPC endpoints
+
func RegisterTimelineRoutes(
+
r chi.Router,
+
timelineService timelineCore.Service,
+
authMiddleware *middleware.AtProtoAuthMiddleware,
+
) {
+
// Create handlers
+
getTimelineHandler := timeline.NewGetTimelineHandler(timelineService)
+
+
// GET /xrpc/social.coves.feed.getTimeline
+
// Requires authentication - user must be logged in to see their timeline
+
r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.feed.getTimeline", getTimelineHandler.HandleGetTimeline)
+
}
+71
internal/core/discover/service.go
···
+
package discover
+
+
import (
+
"context"
+
"fmt"
+
)
+
+
type discoverService struct {
+
repo Repository
+
}
+
+
// NewDiscoverService creates a new discover service
+
func NewDiscoverService(repo Repository) Service {
+
return &discoverService{
+
repo: repo,
+
}
+
}
+
+
// GetDiscover retrieves posts from all communities (public feed)
+
func (s *discoverService) GetDiscover(ctx context.Context, req GetDiscoverRequest) (*DiscoverResponse, error) {
+
// Validate request
+
if err := s.validateRequest(&req); err != nil {
+
return nil, err
+
}
+
+
// Fetch discover feed from repository (all posts from all communities)
+
feedPosts, cursor, err := s.repo.GetDiscover(ctx, req)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get discover feed: %w", err)
+
}
+
+
// Return discover response
+
return &DiscoverResponse{
+
Feed: feedPosts,
+
Cursor: cursor,
+
}, nil
+
}
+
+
// validateRequest validates the discover request parameters
+
func (s *discoverService) validateRequest(req *GetDiscoverRequest) error {
+
// Validate and set defaults for sort
+
if req.Sort == "" {
+
req.Sort = "hot"
+
}
+
validSorts := map[string]bool{"hot": true, "top": true, "new": true}
+
if !validSorts[req.Sort] {
+
return NewValidationError("sort", "sort must be one of: hot, top, new")
+
}
+
+
// Validate and set defaults for limit
+
if req.Limit <= 0 {
+
req.Limit = 15
+
}
+
if req.Limit > 50 {
+
return NewValidationError("limit", "limit must not exceed 50")
+
}
+
+
// Validate and set defaults for timeframe (only used with top sort)
+
if req.Sort == "top" && req.Timeframe == "" {
+
req.Timeframe = "day"
+
}
+
validTimeframes := map[string]bool{
+
"hour": true, "day": true, "week": true,
+
"month": true, "year": true, "all": true,
+
}
+
if req.Timeframe != "" && !validTimeframes[req.Timeframe] {
+
return NewValidationError("timeframe", "timeframe must be one of: hour, day, week, month, year, all")
+
}
+
+
return nil
+
}
+76
internal/core/timeline/service.go
···
+
package timeline
+
+
import (
+
"context"
+
"fmt"
+
)
+
+
type timelineService struct {
+
repo Repository
+
}
+
+
// NewTimelineService creates a new timeline service
+
func NewTimelineService(repo Repository) Service {
+
return &timelineService{
+
repo: repo,
+
}
+
}
+
+
// GetTimeline retrieves posts from all communities the user subscribes to
+
func (s *timelineService) GetTimeline(ctx context.Context, req GetTimelineRequest) (*TimelineResponse, error) {
+
// 1. Validate request
+
if err := s.validateRequest(&req); err != nil {
+
return nil, err
+
}
+
+
// 2. UserDID must be set (from auth middleware)
+
if req.UserDID == "" {
+
return nil, ErrUnauthorized
+
}
+
+
// 3. Fetch timeline from repository (hydrated posts from subscribed communities)
+
feedPosts, cursor, err := s.repo.GetTimeline(ctx, req)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get timeline: %w", err)
+
}
+
+
// 4. Return timeline response
+
return &TimelineResponse{
+
Feed: feedPosts,
+
Cursor: cursor,
+
}, nil
+
}
+
+
// validateRequest validates the timeline request parameters
+
func (s *timelineService) validateRequest(req *GetTimelineRequest) error {
+
// Validate and set defaults for sort
+
if req.Sort == "" {
+
req.Sort = "hot"
+
}
+
validSorts := map[string]bool{"hot": true, "top": true, "new": true}
+
if !validSorts[req.Sort] {
+
return NewValidationError("sort", "sort must be one of: hot, top, new")
+
}
+
+
// Validate and set defaults for limit
+
if req.Limit <= 0 {
+
req.Limit = 15
+
}
+
if req.Limit > 50 {
+
return NewValidationError("limit", "limit must not exceed 50")
+
}
+
+
// Validate and set defaults for timeframe (only used with top sort)
+
if req.Sort == "top" && req.Timeframe == "" {
+
req.Timeframe = "day"
+
}
+
validTimeframes := map[string]bool{
+
"hour": true, "day": true, "week": true,
+
"month": true, "year": true, "all": true,
+
}
+
if req.Timeframe != "" && !validTimeframes[req.Timeframe] {
+
return NewValidationError("timeframe", "timeframe must be one of: hour, day, week, month, year, all")
+
}
+
+
return nil
+
}
+19
internal/api/handlers/errors.go
···
+
package handlers
+
+
import (
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// WriteError writes a standardized 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(map[string]interface{}{
+
"error": errorType,
+
"message": message,
+
}); err != nil {
+
log.Printf("Failed to encode error response: %v", err)
+
}
+
}
+125
internal/atproto/jetstream/vote_jetstream_connector.go
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
+
"github.com/gorilla/websocket"
+
)
+
+
// VoteJetstreamConnector handles WebSocket connection to Jetstream for vote events
+
type VoteJetstreamConnector struct {
+
consumer *VoteEventConsumer
+
wsURL string
+
}
+
+
// NewVoteJetstreamConnector creates a new Jetstream WebSocket connector for vote events
+
func NewVoteJetstreamConnector(consumer *VoteEventConsumer, wsURL string) *VoteJetstreamConnector {
+
return &VoteJetstreamConnector{
+
consumer: consumer,
+
wsURL: wsURL,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *VoteJetstreamConnector) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream vote consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream vote consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream vote connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *VoteJetstreamConnector) connect(ctx context.Context) error {
+
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() {
+
if closeErr := conn.Close(); closeErr != nil {
+
log.Printf("Failed to close WebSocket connection: %v", closeErr)
+
}
+
}()
+
+
log.Println("Connected to Jetstream (vote consumer)")
+
+
// Set read deadline to detect connection issues
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline in pong handler: %v", err)
+
}
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
var closeOnce sync.Once // Ensure done channel is only closed once
+
+
// Ping goroutine
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+
log.Printf("Failed to send ping: %v", err)
+
closeOnce.Do(func() { close(done) })
+
return
+
}
+
case <-done:
+
return
+
}
+
}
+
}()
+
+
// Read loop
+
for {
+
select {
+
case <-done:
+
return fmt.Errorf("connection closed by ping failure")
+
default:
+
}
+
+
_, message, err := conn.ReadMessage()
+
if err != nil {
+
closeOnce.Do(func() { close(done) })
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Parse Jetstream event
+
var event JetstreamEvent
+
if err := json.Unmarshal(message, &event); err != nil {
+
log.Printf("Failed to parse Jetstream event: %v", err)
+
continue
+
}
+
+
// Process event through consumer
+
if err := c.consumer.HandleEvent(ctx, &event); err != nil {
+
log.Printf("Failed to handle vote event: %v", err)
+
// Continue processing other events even if one fails
+
}
+
}
+
}
+28 -5
internal/atproto/lexicon/social/coves/interaction/vote.json
···
"defs": {
"main": {
"type": "record",
-
"description": "An upvote on a post or comment",
+
"description": "A vote (upvote or downvote) on a post or comment",
"key": "tid",
"record": {
"type": "object",
-
"required": ["subject", "createdAt"],
+
"required": ["subject", "direction", "createdAt"],
"properties": {
"subject": {
+
"type": "ref",
+
"ref": "#strongRef",
+
"description": "Strong reference to the post or comment being voted on"
+
},
+
"direction": {
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment being voted on"
+
"enum": ["up", "down"],
+
"description": "Vote direction: up for upvote, down for downvote"
},
"createdAt": {
"type": "string",
-
"format": "datetime"
+
"format": "datetime",
+
"description": "Timestamp when the vote was created"
}
}
}
+
},
+
"strongRef": {
+
"type": "object",
+
"description": "Strong reference to a record (AT-URI + CID)",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the record"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the record content"
+
}
+
}
}
}
}
+26
internal/core/votes/errors.go
···
+
package votes
+
+
import "errors"
+
+
var (
+
// 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'")
+
+
// ErrInvalidSubject indicates the subject URI is malformed or invalid
+
ErrInvalidSubject = errors.New("invalid subject URI")
+
+
// ErrVoteAlreadyExists indicates a vote already exists on this subject
+
ErrVoteAlreadyExists = errors.New("vote already exists")
+
+
// ErrNotAuthorized indicates the user is not authorized to perform this action
+
ErrNotAuthorized = errors.New("not authorized")
+
+
// ErrBanned indicates the user is banned from the community
+
ErrBanned = errors.New("user is banned from this community")
+
)
+22
internal/db/migrations/014_remove_votes_voter_fk.sql
···
+
-- +goose Up
+
-- Remove foreign key constraint on votes.voter_did to prevent race conditions
+
-- between user and vote Jetstream consumers.
+
--
+
-- Rationale:
+
-- - Vote events can arrive before user events in Jetstream
+
-- - Creating votes should not fail if user hasn't been indexed yet
+
-- - Users are validated at the PDS level (votes come from user repos)
+
-- - Orphaned votes (from deleted users) are harmless and can be ignored in queries
+
+
ALTER TABLE votes DROP CONSTRAINT IF EXISTS fk_voter;
+
+
-- Add check constraint to ensure voter_did is a valid DID format
+
ALTER TABLE votes ADD CONSTRAINT chk_voter_did_format
+
CHECK (voter_did ~ '^did:(plc|web|key):');
+
+
-- +goose Down
+
-- Restore foreign key constraint (note: this may fail if orphaned votes exist)
+
ALTER TABLE votes DROP CONSTRAINT IF EXISTS chk_voter_did_format;
+
+
ALTER TABLE votes ADD CONSTRAINT fk_voter
+
FOREIGN KEY (voter_did) REFERENCES users(did) ON DELETE CASCADE;
+14
scripts/dev-run.sh
···
+
#!/bin/bash
+
# Development server runner - loads .env.dev before starting
+
+
set -a # automatically export all variables
+
source .env.dev
+
set +a
+
+
echo "๐Ÿš€ Starting Coves server in DEV mode..."
+
echo " IS_DEV_ENV: $IS_DEV_ENV"
+
echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL"
+
echo " JETSTREAM_URL: $JETSTREAM_URL"
+
echo ""
+
+
go run ./cmd/server
+68
scripts/setup-mobile-ports.sh
···
+
#!/bin/bash
+
# Setup adb reverse port forwarding for mobile testing
+
# This allows the mobile app to access localhost services on the dev machine
+
+
set -e
+
+
# Colors
+
GREEN='\033[0;32m'
+
CYAN='\033[0;36m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m' # No Color
+
+
echo -e "${CYAN}๐Ÿ“ฑ Setting up Android port forwarding for Coves mobile testing...${NC}"
+
echo ""
+
+
# Check if adb is available
+
if ! command -v adb &> /dev/null; then
+
echo -e "${RED}โœ— adb not found${NC}"
+
echo "Install Android SDK Platform Tools: https://developer.android.com/studio/releases/platform-tools"
+
exit 1
+
fi
+
+
# Check if device is connected
+
DEVICES=$(adb devices | grep -v "List" | grep "device$" | wc -l)
+
if [ "$DEVICES" -eq 0 ]; then
+
echo -e "${RED}โœ— No Android devices connected${NC}"
+
echo "Connect a device via USB or start an emulator"
+
exit 1
+
fi
+
+
echo -e "${YELLOW}Setting up port forwarding...${NC}"
+
+
# Forward ports from Android device to localhost
+
adb reverse tcp:3000 tcp:3001 # PDS (internal port in DID document)
+
adb reverse tcp:3001 tcp:3001 # PDS (external port)
+
adb reverse tcp:3002 tcp:3002 # PLC Directory
+
adb reverse tcp:8081 tcp:8081 # AppView
+
+
echo ""
+
echo -e "${GREEN}โœ… Port forwarding configured successfully!${NC}"
+
echo ""
+
echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo -e "${CYAN} PORT FORWARDING ${NC}"
+
echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo ""
+
echo -e "${GREEN}PDS (3000):${NC} localhost:3001 โ†’ device:3000 ${YELLOW}(DID document port)${NC}"
+
echo -e "${GREEN}PDS (3001):${NC} localhost:3001 โ†’ device:3001"
+
echo -e "${GREEN}PLC (3002):${NC} localhost:3002 โ†’ device:3002"
+
echo -e "${GREEN}AppView (8081):${NC} localhost:8081 โ†’ device:8081"
+
echo ""
+
echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo ""
+
echo -e "${CYAN}๐Ÿ“ฑ Next Steps:${NC}"
+
echo ""
+
echo -e "1. Mobile app is already configured for localhost (environment_config.dart)"
+
echo ""
+
echo -e "2. Run mobile app:"
+
echo -e " ${YELLOW}cd /home/bretton/Code/coves-mobile${NC}"
+
echo -e " ${YELLOW}flutter run --dart-define=ENVIRONMENT=local${NC}"
+
echo ""
+
echo -e "3. Login with:"
+
echo -e " Handle: ${CYAN}charlie.local.coves.dev${NC}"
+
echo -e " Password: ${CYAN}charliepass123${NC}"
+
echo ""
+
echo -e "${YELLOW}๐Ÿ’ก Note: Port forwarding persists until device disconnects or you run:${NC}"
+
echo -e "${YELLOW} adb reverse --remove-all${NC}"
+
echo ""
+116
scripts/start-ngrok.sh
···
+
#!/bin/bash
+
# Automated ngrok tunnel starter for mobile testing
+
# Starts 3 ngrok tunnels and captures their HTTPS URLs
+
+
set -e
+
+
# Colors
+
GREEN='\033[0;32m'
+
CYAN='\033[0;36m'
+
YELLOW='\033[1;33m'
+
NC='\033[0m' # No Color
+
+
echo -e "${CYAN}๐Ÿš€ Starting ngrok tunnels for Coves mobile testing...${NC}"
+
echo ""
+
+
# Kill any existing ngrok processes
+
pkill -f "ngrok http" || true
+
sleep 2
+
+
# Start ngrok tunnels using separate processes (simpler, works with any config version)
+
echo -e "${YELLOW}Starting PDS tunnel (port 3001)...${NC}"
+
ngrok http 3001 --log=stdout > /tmp/ngrok-pds.log 2>&1 &
+
sleep 1
+
+
echo -e "${YELLOW}Starting PLC tunnel (port 3002)...${NC}"
+
ngrok http 3002 --log=stdout > /tmp/ngrok-plc.log 2>&1 &
+
sleep 1
+
+
echo -e "${YELLOW}Starting AppView tunnel (port 8081)...${NC}"
+
ngrok http 8081 --log=stdout > /tmp/ngrok-appview.log 2>&1 &
+
+
# Get all PIDs
+
PIDS=$(pgrep -f "ngrok http")
+
NGROK_PID=$PIDS
+
+
# Save PID for cleanup
+
echo "$NGROK_PID" > /tmp/ngrok-pids.txt
+
+
# Wait for ngrok to initialize
+
echo ""
+
echo -e "${YELLOW}Waiting for tunnels to initialize...${NC}"
+
sleep 7
+
+
# Fetch URLs from ngrok API (single API at port 4040)
+
echo ""
+
echo -e "${GREEN}โœ… Tunnels started successfully!${NC}"
+
echo ""
+
echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo -e "${CYAN} NGROK TUNNEL URLS ${NC}"
+
echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo ""
+
+
# Get all tunnel info
+
TUNNELS=$(curl -s http://localhost:4040/api/tunnels 2>/dev/null || echo "")
+
+
# Extract URLs by matching port in config.addr
+
PDS_URL=$(echo "$TUNNELS" | jq -r '.tunnels[] | select(.config.addr | contains("3001")) | select(.proto=="https") | .public_url' 2>/dev/null | head -1)
+
PLC_URL=$(echo "$TUNNELS" | jq -r '.tunnels[] | select(.config.addr | contains("3002")) | select(.proto=="https") | .public_url' 2>/dev/null | head -1)
+
APPVIEW_URL=$(echo "$TUNNELS" | jq -r '.tunnels[] | select(.config.addr | contains("8081")) | select(.proto=="https") | .public_url' 2>/dev/null | head -1)
+
+
# Fallback if jq filtering fails - just get first 3 HTTPS URLs
+
if [ -z "$PDS_URL" ] || [ -z "$PLC_URL" ] || [ -z "$APPVIEW_URL" ]; then
+
echo -e "${YELLOW}โš ๏ธ Port-based matching failed, using fallback...${NC}"
+
URLS=($(echo "$TUNNELS" | jq -r '.tunnels[] | select(.proto=="https") | .public_url' 2>/dev/null))
+
PDS_URL=${URLS[0]:-ERROR}
+
PLC_URL=${URLS[1]:-ERROR}
+
APPVIEW_URL=${URLS[2]:-ERROR}
+
fi
+
+
echo -e "${GREEN}PDS (3001):${NC} $PDS_URL"
+
echo -e "${GREEN}PLC (3002):${NC} $PLC_URL"
+
echo -e "${GREEN}AppView (8081):${NC} $APPVIEW_URL"
+
+
echo ""
+
echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}"
+
echo ""
+
+
# Check if any URLs failed
+
if [[ "$PDS_URL" == "ERROR" ]] || [[ "$PLC_URL" == "ERROR" ]] || [[ "$APPVIEW_URL" == "ERROR" ]]; then
+
echo -e "${YELLOW}โš ๏ธ Some tunnels failed to start. Check logs:${NC}"
+
echo " tail -f /tmp/ngrok-pds.log"
+
echo " tail -f /tmp/ngrok-plc.log"
+
echo " tail -f /tmp/ngrok-appview.log"
+
exit 1
+
fi
+
+
# Extract clean URLs (remove https://)
+
PDS_CLEAN=$(echo $PDS_URL | sed 's|https://||')
+
PLC_CLEAN=$(echo $PLC_URL | sed 's|https://||')
+
APPVIEW_CLEAN=$(echo $APPVIEW_URL | sed 's|https://||')
+
+
echo -e "${CYAN}๐Ÿ“ฑ Next Steps:${NC}"
+
echo ""
+
echo -e "1. Update ${YELLOW}coves-mobile/lib/config/environment_config.dart${NC}:"
+
echo ""
+
echo -e "${GREEN}static const local = EnvironmentConfig(${NC}"
+
echo -e "${GREEN} environment: Environment.local,${NC}"
+
echo -e "${GREEN} apiUrl: '$APPVIEW_URL',${NC}"
+
echo -e "${GREEN} handleResolverUrl: '$PDS_URL/xrpc/com.atproto.identity.resolveHandle',${NC}"
+
echo -e "${GREEN} plcDirectoryUrl: '$PLC_URL',${NC}"
+
echo -e "${GREEN});${NC}"
+
echo ""
+
echo -e "2. Run mobile app:"
+
echo -e " ${YELLOW}cd /home/bretton/Code/coves-mobile${NC}"
+
echo -e " ${YELLOW}flutter run --dart-define=ENVIRONMENT=local${NC}"
+
echo ""
+
echo -e "3. Login with:"
+
echo -e " Handle: ${CYAN}bob.local.coves.dev${NC}"
+
echo -e " Password: ${CYAN}bobpass123${NC}"
+
echo ""
+
echo -e "${YELLOW}๐Ÿ’ก Tip: Leave this terminal open. Press Ctrl+C to stop tunnels.${NC}"
+
echo -e "${YELLOW} Or run: make ngrok-down${NC}"
+
echo ""
+
+
# Keep script running (can be killed with Ctrl+C or make ngrok-down)
+
wait
+26
scripts/stop-ngrok.sh
···
+
#!/bin/bash
+
# Stop all ngrok tunnels
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
NC='\033[0m'
+
+
echo -e "${YELLOW}Stopping ngrok tunnels...${NC}"
+
+
# Kill processes by PID if available
+
if [ -f /tmp/ngrok-pids.txt ]; then
+
PIDS=$(cat /tmp/ngrok-pids.txt)
+
for pid in $PIDS; do
+
kill $pid 2>/dev/null || true
+
done
+
rm /tmp/ngrok-pids.txt
+
fi
+
+
# Fallback: kill all ngrok processes
+
pkill -f "ngrok http" || true
+
+
# Clean up logs
+
rm -f /tmp/ngrok-*.log
+
+
echo -e "${GREEN}โœ“ ngrok tunnels stopped${NC}"
-67
internal/atproto/lexicon/social/coves/interaction/createVote.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.createVote",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Vote on a post or comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject", "direction"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment to vote on"
-
},
-
"direction": {
-
"type": "string",
-
"enum": ["up", "down"],
-
"description": "Vote direction"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created vote record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created vote record"
-
},
-
"existing": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of existing vote if updating"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "SubjectNotFound",
-
"description": "Post or comment not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to vote"
-
},
-
{
-
"name": "Banned",
-
"description": "User is banned from this community"
-
}
-
]
-
}
-
}
-
}
-37
internal/atproto/lexicon/social/coves/interaction/deleteVote.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.deleteVote",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Remove a vote from a post or comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment to remove vote from"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "VoteNotFound",
-
"description": "No vote found on this subject"
-
}
-
]
-
}
-
}
-
}
+27 -27
internal/core/aggregators/aggregator.go
···
// Aggregators are autonomous services that can post content to communities after authorization
// Following Bluesky's pattern: app.bsky.feed.generator and app.bsky.labeler.service
type Aggregator struct {
-
DID string `json:"did" db:"did"` // Aggregator's DID (primary key)
-
DisplayName string `json:"displayName" db:"display_name"` // Human-readable name
-
Description string `json:"description,omitempty" db:"description"` // What the aggregator does
-
AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"` // Optional avatar image URL
-
ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"` // JSON Schema for configuration (JSONB)
-
MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"` // Contact for support/issues
-
SourceURL string `json:"sourceUrl,omitempty" db:"source_url"` // Source code URL (transparency)
-
CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"` // Auto-updated by trigger
-
PostsCreated int `json:"postsCreated" db:"posts_created"` // Auto-updated by trigger
-
CreatedAt time.Time `json:"createdAt" db:"created_at"` // When aggregator was created (from lexicon)
-
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` // When we indexed this record
-
RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://did/social.coves.aggregator.service/self
-
RecordCID string `json:"recordCid,omitempty" db:"record_cid"` // Content hash
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
+
AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"`
+
DID string `json:"did" db:"did"`
+
MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"`
+
SourceURL string `json:"sourceUrl,omitempty" db:"source_url"`
+
Description string `json:"description,omitempty" db:"description"`
+
DisplayName string `json:"displayName" db:"display_name"`
+
RecordURI string `json:"recordUri,omitempty" db:"record_uri"`
+
RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
+
ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"`
+
CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"`
+
PostsCreated int `json:"postsCreated" db:"posts_created"`
}
// Authorization represents a community's authorization for an aggregator
// Stored in community's repository: at://community_did/social.coves.aggregator.authorization/{rkey}
type Authorization struct {
-
ID int `json:"id" db:"id"` // Database ID
-
AggregatorDID string `json:"aggregatorDid" db:"aggregator_did"` // Which aggregator
-
CommunityDID string `json:"communityDid" db:"community_did"` // Which community
-
Enabled bool `json:"enabled" db:"enabled"` // Current status
-
Config []byte `json:"config,omitempty" db:"config"` // Aggregator-specific config (JSONB)
-
CreatedBy string `json:"createdBy,omitempty" db:"created_by"` // Moderator DID who enabled it
-
DisabledBy string `json:"disabledBy,omitempty" db:"disabled_by"` // Moderator DID who disabled it
-
CreatedAt time.Time `json:"createdAt" db:"created_at"` // When authorization was created
-
DisabledAt *time.Time `json:"disabledAt,omitempty" db:"disabled_at"` // When authorization was disabled (for modlog/audit)
-
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` // When we indexed this record
-
RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://community_did/social.coves.aggregator.authorization/{rkey}
-
RecordCID string `json:"recordCid,omitempty" db:"record_cid"` // Content hash
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
+
DisabledAt *time.Time `json:"disabledAt,omitempty" db:"disabled_at"`
+
AggregatorDID string `json:"aggregatorDid" db:"aggregator_did"`
+
CommunityDID string `json:"communityDid" db:"community_did"`
+
CreatedBy string `json:"createdBy,omitempty" db:"created_by"`
+
DisabledBy string `json:"disabledBy,omitempty" db:"disabled_by"`
+
RecordURI string `json:"recordUri,omitempty" db:"record_uri"`
+
RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
+
Config []byte `json:"config,omitempty" db:"config"`
+
ID int `json:"id" db:"id"`
+
Enabled bool `json:"enabled" db:"enabled"`
}
// AggregatorPost represents tracking of posts created by aggregators
// AppView-only table for rate limiting and statistics
type AggregatorPost struct {
-
ID int `json:"id" db:"id"`
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
AggregatorDID string `json:"aggregatorDid" db:"aggregator_did"`
CommunityDID string `json:"communityDid" db:"community_did"`
PostURI string `json:"postUri" db:"post_uri"`
PostCID string `json:"postCid" db:"post_cid"`
-
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
ID int `json:"id" db:"id"`
}
// EnableAggregatorRequest represents input for enabling an aggregator in a community
+100
internal/atproto/lexicon/social/coves/community/post.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.post",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "A post in a Coves community. Posts live in community repositories and persist independently of the author.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["community", "author", "createdAt"],
+
"properties": {
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the community this was posted to"
+
},
+
"author": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the user who created this post"
+
},
+
"title": {
+
"type": "string",
+
"maxGraphemes": 300,
+
"maxLength": 3000,
+
"description": "Post title (optional for media-only posts)"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Post content - supports rich text via facets"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, tags)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media, external links, or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.video",
+
"social.coves.embed.external",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the post content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels (NSFW, spoilers, etc.)"
+
},
+
"tags": {
+
"type": "array",
+
"description": "User-applied topic tags",
+
"maxLength": 8,
+
"items": {
+
"type": "string",
+
"maxLength": 64,
+
"maxGraphemes": 64
+
}
+
},
+
"crosspostOf": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "If this is a crosspost, strong reference to the immediate parent post"
+
},
+
"crosspostChain": {
+
"type": "array",
+
"description": "Full chain of crossposts with version pinning. First element is original, last is immediate parent.",
+
"maxLength": 25,
+
"items": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef"
+
}
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of post creation"
+
}
+
}
+
}
+
}
+
}
+
}
+119
internal/atproto/lexicon/social/coves/community/post/create.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.post.create",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a new post in a community",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["community"],
+
"properties": {
+
"community": {
+
"type": "string",
+
"format": "at-identifier",
+
"description": "DID or handle of the community to post in"
+
},
+
"title": {
+
"type": "string",
+
"maxGraphemes": 300,
+
"maxLength": 3000,
+
"description": "Post title (optional for media-only posts)"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 10000,
+
"maxLength": 100000,
+
"description": "Post content - supports rich text via facets"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, tags)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media, external links, or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.video",
+
"social.coves.embed.external",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the post content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels (NSFW, spoilers, etc.)"
+
},
+
"tags": {
+
"type": "array",
+
"description": "User-applied topic tags",
+
"maxLength": 8,
+
"items": {
+
"type": "string",
+
"maxLength": 64,
+
"maxGraphemes": 64
+
}
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the created post"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the created post"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "CommunityNotFound",
+
"description": "Community not found"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to post in this community"
+
},
+
{
+
"name": "Banned",
+
"description": "User is banned from this community"
+
},
+
{
+
"name": "InvalidContent",
+
"description": "Post content violates community rules"
+
},
+
{
+
"name": "ContentRuleViolation",
+
"description": "Post violates community content rules (e.g., embeds not allowed, text too short)"
+
}
+
]
+
}
+
}
+
}
+41
internal/atproto/lexicon/social/coves/community/post/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.post.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a post",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the post to delete"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {}
+
}
+
},
+
"errors": [
+
{
+
"name": "PostNotFound",
+
"description": "Post not found"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to delete this post"
+
}
+
]
+
}
+
}
+
}
+6 -6
internal/atproto/lexicon/social/coves/embed/post.json
···
"defs": {
"main": {
"type": "object",
-
"description": "Embedded reference to another post",
-
"required": ["uri"],
+
"description": "Embedded reference to another post (quoted post)",
+
"required": ["post"],
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post being embedded"
+
"post": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the embedded post (includes URI and CID)"
}
}
}
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
···
+
-- +goose Up
+
-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure
+
-- This allows storing the optional 'neg' field and future extensions
+
+
-- Create temporary function to convert TEXT[] to selfLabels JSONB
+
-- +goose StatementBegin
+
CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[])
+
RETURNS JSONB AS $$
+
BEGIN
+
IF labels IS NULL OR array_length(labels, 1) = 0 THEN
+
RETURN NULL;
+
END IF;
+
+
RETURN jsonb_build_object(
+
'values',
+
(SELECT jsonb_agg(jsonb_build_object('val', label))
+
FROM unnest(labels) AS label)
+
);
+
END;
+
$$ LANGUAGE plpgsql IMMUTABLE;
+
-- +goose StatementEnd
+
+
-- Convert column type using the function
+
ALTER TABLE posts
+
ALTER COLUMN content_labels TYPE JSONB
+
USING convert_labels_to_jsonb(content_labels);
+
+
-- Drop the temporary function
+
DROP FUNCTION convert_labels_to_jsonb(TEXT[]);
+
+
-- Update column comment
+
COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+
+
-- +goose Down
+
-- Revert JSONB back to TEXT[] (lossy - drops 'neg' field)
+
ALTER TABLE posts
+
ALTER COLUMN content_labels TYPE TEXT[]
+
USING CASE
+
WHEN content_labels IS NULL THEN NULL
+
ELSE ARRAY(
+
SELECT value->>'val'
+
FROM jsonb_array_elements(content_labels->'values') AS value
+
)
+
END;
+
+
-- Restore original comment
+
COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)';
+1 -1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
···
{
"$type": "social.coves.moderation.tribunalVote",
"tribunal": "at://did:plc:community123/social.coves.moderation.tribunal/3k7a3dmb5bk2c",
-
"subject": "at://did:plc:user123/social.coves.post.record/3k7a2clb4bj2b",
+
"subject": "at://$1/social.coves.community.post/3k7a2clb4bj2b",
"decision": "maybe",
"createdAt": "2025-01-09T18:00:00Z"
}
+5 -6
tests/lexicon-test-data/post/post-invalid-missing-community.json
···
{
-
"$type": "social.coves.post.record",
-
"postType": "text",
+
"$type": "social.coves.community.post",
+
"author": "did:plc:testauthor123",
"title": "Test Post",
-
"text": "This post is missing the required community field",
+
"content": "This post is missing the required community field",
"tags": ["test"],
-
"language": "en",
-
"contentWarnings": [],
+
"langs": ["en"],
"createdAt": "2025-01-09T14:30:00Z"
-
}
+
}
+6 -7
tests/lexicon-test-data/post/post-valid-text.json
···
{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": "did:plc:programming123",
-
"postType": "text",
+
"author": "did:plc:testauthor123",
"title": "Best practices for error handling in Go",
-
"text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
-
"textFacets": [
+
"content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
+
"facets": [
{
"index": {
"byteStart": 20,
···
}
],
"tags": ["golang", "error-handling", "best-practices"],
-
"language": "en",
-
"contentWarnings": [],
+
"langs": ["en"],
"createdAt": "2025-01-09T14:30:00Z"
-
}
+
}
+1 -1
docs/PRD_GOVERNANCE.md
···
- [ ] Go structs: `ContentRules` type in community models
- [ ] Repository: Parse and store `contentRules` from community profiles
- [ ] Service: `ValidatePostAgainstRules(post, community)` function
-
- [ ] Handler: Integrate validation into `social.coves.post.create`
+
- [ ] Handler: Integrate validation into `social.coves.community.post.create`
- [ ] AppView indexing: Index post characteristics (embed_type, text_length, etc.)
- [ ] Tests: Comprehensive rule validation tests
- [ ] Documentation: Content rules guide for community creators
+17 -17
docs/PRD_POSTS.md
···
**Repository Structure:**
```
-
Repository: at://did:plc:community789/social.coves.post.record/3k2a4b5c6d7e
+
Repository: at://did:plc:community789/social.coves.community.post.record/3k2a4b5c6d7e
Owner: did:plc:community789 (community owns the post)
Author: did:plc:user123 (tracked in record metadata)
Hosted By: did:web:coves.social (instance manages community credentials)
···
**Implementation checklist:**
- [x] Lexicon: `contentRules` in `social.coves.community.profile` โœ…
-
- [x] Lexicon: `postType` removed from `social.coves.post.create` โœ…
+
- [x] Lexicon: `postType` removed from `social.coves.community.post.create` โœ…
- [ ] Validation: `ValidatePostAgainstRules()` service function
- [ ] Handler: Integrate validation in post creation endpoint
- [ ] AppView: Index derived characteristics (embed_type, text_length, etc.)
···
**Priority:** CRITICAL - Posts are the foundation of the platform
#### Create Post
-
- [x] Lexicon: `social.coves.post.record` โœ…
-
- [x] Lexicon: `social.coves.post.create` โœ…
+
- [x] Lexicon: `social.coves.community.post.record` โœ…
+
- [x] Lexicon: `social.coves.community.post.create` โœ…
- [x] Removed `postType` enum in favor of content rules โœ… (2025-10-18)
- [x] Removed `postType` from record and get lexicons โœ… (2025-10-18)
-
- [x] **Handler:** `POST /xrpc/social.coves.post.create` โœ… (Alpha - see IMPLEMENTATION_POST_CREATION.md)
+
- [x] **Handler:** `POST /xrpc/social.coves.community.post.create` โœ… (Alpha - see IMPLEMENTATION_POST_CREATION.md)
- โœ… Accept: community (DID/handle), title (optional), content, facets, embed, contentLabels
- โœ… Validate: User is authenticated, community exists, content within limits
- โœ… Write: Create record in **community's PDS repository**
···
- [x] **E2E Test:** Create text post โ†’ Write to **community's PDS** โ†’ Index via Jetstream โ†’ Verify in AppView โœ…
#### Get Post
-
- [x] Lexicon: `social.coves.post.get` โœ…
-
- [ ] **Handler:** `GET /xrpc/social.coves.post.get?uri=at://...`
+
- [x] Lexicon: `social.coves.community.post.get` โœ…
+
- [ ] **Handler:** `GET /xrpc/social.coves.community.post.get?uri=at://...`
- Accept: AT-URI of post
- Return: Full post view with author, community, stats, viewer state
- [ ] **Service Layer:** `PostService.Get(uri, viewerDID)`
···
- [ ] **E2E Test:** Get post by URI โ†’ Verify all fields populated
#### Update Post
-
- [x] Lexicon: `social.coves.post.update` โœ…
-
- [ ] **Handler:** `POST /xrpc/social.coves.post.update`
+
- [x] Lexicon: `social.coves.community.post.update` โœ…
+
- [ ] **Handler:** `POST /xrpc/social.coves.community.post.update`
- Accept: uri, title, content, facets, embed, contentLabels, editNote
- Validate: User is post author, within 24-hour edit window
- Write: Update record in **community's PDS**
···
- [ ] **E2E Test:** Update post โ†’ Verify edit reflected in AppView
#### Delete Post
-
- [x] Lexicon: `social.coves.post.delete` โœ…
-
- [ ] **Handler:** `POST /xrpc/social.coves.post.delete`
+
- [x] Lexicon: `social.coves.community.post.delete` โœ…
+
- [ ] **Handler:** `POST /xrpc/social.coves.community.post.delete`
- Accept: uri
- Validate: User is post author OR community moderator
- Write: Delete record from **community's PDS**
···
#### Post Event Handling
- [x] **Consumer:** `PostConsumer.HandlePostEvent()` โœ… (2025-10-19)
-
- โœ… Listen for `social.coves.post.record` CREATE from **community repositories**
+
- โœ… Listen for `social.coves.community.post.record` CREATE from **community repositories**
- โœ… Parse post record, extract author DID and community DID (from AT-URI owner)
- โš ๏ธ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering)
- โœ… Insert in AppView PostgreSQL (CREATE only - UPDATE/DELETE deferred)
···
- [ ] **Tag Storage:** Tags live in **user's repository** (users own their tags)
#### Crossposting
-
- [x] Lexicon: `social.coves.post.crosspost` โœ…
+
- [x] Lexicon: `social.coves.community.post.crosspost` โœ…
- [ ] **Crosspost Tracking:** Share post to multiple communities
- [ ] **Implementation:** Create new post record in each community's repository
- [ ] **Crosspost Chain:** Track all crosspost relationships
···
- [ ] **AppView Query:** Endpoint to fetch user's saved posts
### Post Search
-
- [x] Lexicon: `social.coves.post.search` โœ…
+
- [x] Lexicon: `social.coves.community.post.search` โœ…
- [ ] **Search Parameters:**
- Query string (q)
- Filter by community
···
- **Reuses Token Refresh:** Can leverage existing community credential management
**Implementation Details:**
-
- Post AT-URI: `at://community_did/social.coves.post.record/tid`
+
- Post AT-URI: `at://community_did/social.coves.community.post.record/tid`
- Write operations use community's PDS credentials (encrypted, stored in AppView)
- Author tracked in post record's `author` field (DID)
- Moderators can delete any post in their community
···
## Lexicon Summary
-
### `social.coves.post.record`
+
### `social.coves.community.post.record`
**Status:** โœ… Defined, implementation TODO
**Last Updated:** 2025-10-18 (removed `postType` enum)
···
- Post "type" is derived from structure (has embed? what embed type? has title? text length?)
- Community's `contentRules` validate post structure at creation time
-
### `social.coves.post.create` (Procedure)
+
### `social.coves.community.post.create` (Procedure)
**Status:** โœ… Defined, implementation TODO
**Last Updated:** 2025-10-18 (removed `postType` parameter)
+3 -3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
···
โ”‚ 3. Deduplication: Tracks posted items via JSON state file โ”‚
โ”‚ 4. Feed Mapper: Maps feed URLs to community handles โ”‚
โ”‚ 5. Post Formatter: Converts to Coves post format โ”‚
-
โ”‚ 6. Post Publisher: Calls social.coves.post.create via XRPC โ”‚
+
โ”‚ 6. Post Publisher: Calls social.coves.community.post.create via XRPC โ”‚
โ”‚ 7. Blob Uploader: Handles image upload to ATProto โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚
โ”‚ Authenticated XRPC calls
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
-
โ”‚ Coves AppView (social.coves.post.create) โ”‚
+
โ”‚ Coves AppView (social.coves.community.post.create) โ”‚
โ”‚ - Validates aggregator authorization โ”‚
โ”‚ - Creates post with author = did:plc:[aggregator-did] โ”‚
โ”‚ - Indexes to community feeds โ”‚
···
```json
{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post.record",
"author": "did:plc:[aggregator-did]",
"community": "world-news.coves.social",
"title": "{Kagi story title}",
+80
internal/atproto/lexicon/social/coves/feed/comment.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.feed.comment",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "A comment on a post or another comment. Comments live in user repositories and support nested threading.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["reply", "content", "createdAt"],
+
"properties": {
+
"reply": {
+
"type": "ref",
+
"ref": "#replyRef",
+
"description": "Reference to the post and parent being replied to"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 3000,
+
"maxLength": 30000,
+
"description": "Comment text content"
+
},
+
"facets": {
+
"type": "array",
+
"description": "Annotations for rich text (mentions, links, etc.)",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded media or quoted posts",
+
"refs": [
+
"social.coves.embed.images",
+
"social.coves.embed.post"
+
]
+
},
+
"langs": {
+
"type": "array",
+
"description": "Languages used in the comment content (ISO 639-1)",
+
"maxLength": 3,
+
"items": {
+
"type": "string",
+
"format": "language"
+
}
+
},
+
"labels": {
+
"type": "ref",
+
"ref": "com.atproto.label.defs#selfLabels",
+
"description": "Self-applied content labels"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of comment creation"
+
}
+
}
+
}
+
},
+
"replyRef": {
+
"type": "object",
+
"description": "References for maintaining thread structure. Root always points to the original post, parent points to the immediate parent (post or comment).",
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the original post that started the thread"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the immediate parent (post or comment) being replied to"
+
}
+
}
+
}
+
}
+
}
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.comment",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A comment on a post or another comment",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "content", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of post or comment being replied to"
-
},
-
"content": {
-
"type": "union",
-
"refs": ["#textContent", "#imageContent", "#stickerContent"]
-
},
-
"location": {
-
"type": "ref",
-
"ref": "social.coves.actor.profile#geoLocation"
-
},
-
"translatedFrom": {
-
"type": "string",
-
"maxLength": 10,
-
"description": "Language code if auto-translated (ISO 639-1)"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
},
-
"textContent": {
-
"type": "object",
-
"required": ["text"],
-
"properties": {
-
"text": {
-
"type": "string",
-
"maxLength": 10000,
-
"description": "Comment text"
-
},
-
"facets": {
-
"type": "array",
-
"description": "Rich text annotations",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
}
-
}
-
},
-
"imageContent": {
-
"type": "object",
-
"required": ["image"],
-
"properties": {
-
"image": {
-
"type": "ref",
-
"ref": "social.coves.embed.images#image"
-
},
-
"caption": {
-
"type": "string",
-
"maxLength": 1000
-
}
-
}
-
},
-
"stickerContent": {
-
"type": "object",
-
"required": ["stickerId"],
-
"properties": {
-
"stickerId": {
-
"type": "string",
-
"description": "Reference to a sticker in a sticker pack"
-
},
-
"stickerPackId": {
-
"type": "string",
-
"description": "Reference to the sticker pack"
-
}
-
}
-
}
-
}
-
}
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.createComment",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Create a comment on a post or another comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["parent", "text"],
-
"properties": {
-
"parent": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment being replied to"
-
},
-
"text": {
-
"type": "string",
-
"maxGraphemes": 3000,
-
"maxLength": 30000,
-
"description": "Comment text"
-
},
-
"textFacets": {
-
"type": "array",
-
"description": "Rich text annotations",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created comment"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created comment"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "ParentNotFound",
-
"description": "Parent post or comment not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to comment"
-
},
-
{
-
"name": "ThreadLocked",
-
"description": "Comment thread is locked"
-
},
-
{
-
"name": "Banned",
-
"description": "User is banned from this community"
-
}
-
]
-
}
-
}
-
}
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.deleteComment",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Delete a comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the comment to delete"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "CommentNotFound",
-
"description": "Comment not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to delete this comment"
-
}
-
]
-
}
-
}
-
}
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.crosspost",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A record tracking crosspost relationships between posts",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["originalPost", "crosspostOf", "createdAt"],
-
"properties": {
-
"originalPost": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the original post in the crosspost chain"
-
},
-
"crosspostOf": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the immediate parent this is a crosspost of"
-
},
-
"allCrossposts": {
-
"type": "array",
-
"description": "Array of AT-URIs of all posts in the crosspost chain",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-
}
-41
internal/atproto/lexicon/social/coves/post/delete.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.delete",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Delete a post",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post to delete"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "PostNotFound",
-
"description": "Post not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to delete this post"
-
}
-
]
-
}
-
}
-
}
-294
internal/atproto/lexicon/social/coves/post/get.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.get",
-
"defs": {
-
"main": {
-
"type": "query",
-
"description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs.",
-
"parameters": {
-
"type": "params",
-
"required": ["uris"],
-
"properties": {
-
"uris": {
-
"type": "array",
-
"description": "List of post AT-URIs to fetch (max 25)",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"maxLength": 25,
-
"minLength": 1
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["posts"],
-
"properties": {
-
"posts": {
-
"type": "array",
-
"description": "Array of post views. May include notFound/blocked entries for missing posts.",
-
"items": {
-
"type": "union",
-
"refs": ["#postView", "#notFoundPost", "#blockedPost"]
-
}
-
}
-
}
-
}
-
},
-
"errors": [
-
{"name": "InvalidRequest", "description": "Invalid URI format or empty array"}
-
]
-
},
-
"postView": {
-
"type": "object",
-
"required": ["uri", "cid", "author", "record", "community", "createdAt", "indexedAt"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid"
-
},
-
"author": {
-
"type": "ref",
-
"ref": "#authorView"
-
},
-
"record": {
-
"type": "unknown",
-
"description": "The actual post record (text, image, video, etc.)"
-
},
-
"community": {
-
"type": "ref",
-
"ref": "#communityRef"
-
},
-
"title": {
-
"type": "string"
-
},
-
"text": {
-
"type": "string"
-
},
-
"textFacets": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
},
-
"embed": {
-
"type": "union",
-
"description": "Embedded content (images, video, link preview, or quoted post)",
-
"refs": [
-
"social.coves.embed.images#view",
-
"social.coves.embed.video#view",
-
"social.coves.embed.external#view",
-
"social.coves.embed.record#view",
-
"social.coves.embed.recordWithMedia#view"
-
]
-
},
-
"language": {
-
"type": "string",
-
"format": "language"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
},
-
"editedAt": {
-
"type": "string",
-
"format": "datetime"
-
},
-
"indexedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When this post was indexed by the AppView"
-
},
-
"stats": {
-
"type": "ref",
-
"ref": "#postStats"
-
},
-
"viewer": {
-
"type": "ref",
-
"ref": "#viewerState"
-
}
-
}
-
},
-
"authorView": {
-
"type": "object",
-
"required": ["did", "handle"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"handle": {
-
"type": "string",
-
"format": "handle"
-
},
-
"displayName": {
-
"type": "string"
-
},
-
"avatar": {
-
"type": "string",
-
"format": "uri"
-
},
-
"reputation": {
-
"type": "integer",
-
"description": "Author's reputation in the community"
-
}
-
}
-
},
-
"communityRef": {
-
"type": "object",
-
"required": ["did", "name"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"name": {
-
"type": "string"
-
},
-
"avatar": {
-
"type": "string",
-
"format": "uri"
-
}
-
}
-
},
-
"notFoundPost": {
-
"type": "object",
-
"description": "Post was not found (deleted, never indexed, or invalid URI)",
-
"required": ["uri", "notFound"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"notFound": {
-
"type": "boolean",
-
"const": true
-
}
-
}
-
},
-
"blockedPost": {
-
"type": "object",
-
"description": "Post is blocked due to viewer blocking author/community, or community moderation",
-
"required": ["uri", "blocked"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"blocked": {
-
"type": "boolean",
-
"const": true
-
},
-
"blockedBy": {
-
"type": "string",
-
"enum": ["author", "community", "moderator"],
-
"description": "What caused the block: viewer blocked author, viewer blocked community, or post was removed by moderators"
-
},
-
"author": {
-
"type": "ref",
-
"ref": "#blockedAuthor"
-
},
-
"community": {
-
"type": "ref",
-
"ref": "#blockedCommunity"
-
}
-
}
-
},
-
"blockedAuthor": {
-
"type": "object",
-
"description": "Minimal author info for blocked posts",
-
"required": ["did"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
}
-
}
-
},
-
"blockedCommunity": {
-
"type": "object",
-
"description": "Minimal community info for blocked posts",
-
"required": ["did"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"name": {
-
"type": "string"
-
}
-
}
-
},
-
"postStats": {
-
"type": "object",
-
"required": ["upvotes", "downvotes", "score", "commentCount"],
-
"properties": {
-
"upvotes": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"downvotes": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"score": {
-
"type": "integer",
-
"description": "Calculated score (upvotes - downvotes)"
-
},
-
"commentCount": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"shareCount": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"tagCounts": {
-
"type": "object",
-
"description": "Aggregate counts of tags applied by community members",
-
"additionalProperties": {
-
"type": "integer",
-
"minimum": 0
-
}
-
}
-
}
-
},
-
"viewerState": {
-
"type": "object",
-
"properties": {
-
"vote": {
-
"type": "string",
-
"enum": ["up", "down"],
-
"description": "Viewer's vote on this post"
-
},
-
"voteUri": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"saved": {
-
"type": "boolean"
-
},
-
"savedUri": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"tags": {
-
"type": "array",
-
"description": "Tags applied by the viewer to this post",
-
"items": {
-
"type": "string",
-
"maxLength": 32
-
}
-
}
-
}
-
}
-
}
-
}
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.getCrosspostChain",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Get all crossposts in a crosspost chain for a given post",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of any post in the crosspost chain"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["crossposts"],
-
"properties": {
-
"crossposts": {
-
"type": "array",
-
"description": "All posts in the crosspost chain",
-
"items": {
-
"type": "ref",
-
"ref": "#crosspostView"
-
}
-
}
-
}
-
}
-
}
-
},
-
"crosspostView": {
-
"type": "object",
-
"required": ["uri", "community", "author", "createdAt"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post"
-
},
-
"community": {
-
"type": "object",
-
"required": ["uri", "name"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the community"
-
},
-
"name": {
-
"type": "string",
-
"description": "Display name of the community"
-
},
-
"handle": {
-
"type": "string",
-
"description": "Handle of the community"
-
}
-
}
-
},
-
"author": {
-
"type": "object",
-
"required": ["did", "handle"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"handle": {
-
"type": "string"
-
},
-
"displayName": {
-
"type": "string"
-
},
-
"avatar": {
-
"type": "string",
-
"format": "uri"
-
}
-
}
-
},
-
"isOriginal": {
-
"type": "boolean",
-
"description": "Whether this is the original post in the chain"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-80
internal/atproto/lexicon/social/coves/post/search.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.search",
-
"defs": {
-
"main": {
-
"type": "query",
-
"description": "Search for posts",
-
"parameters": {
-
"type": "params",
-
"required": ["q"],
-
"properties": {
-
"q": {
-
"type": "string",
-
"description": "Search query"
-
},
-
"community": {
-
"type": "string",
-
"format": "at-identifier",
-
"description": "Filter by specific community"
-
},
-
"author": {
-
"type": "string",
-
"format": "at-identifier",
-
"description": "Filter by author"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["text", "image", "video", "article", "microblog"],
-
"description": "Filter by post type"
-
},
-
"tags": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
},
-
"description": "Filter by tags"
-
},
-
"sort": {
-
"type": "string",
-
"enum": ["relevance", "new", "top"],
-
"default": "relevance"
-
},
-
"timeframe": {
-
"type": "string",
-
"enum": ["hour", "day", "week", "month", "year", "all"],
-
"default": "all"
-
},
-
"limit": {
-
"type": "integer",
-
"minimum": 1,
-
"maximum": 100,
-
"default": 50
-
},
-
"cursor": {
-
"type": "string"
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["posts"],
-
"properties": {
-
"posts": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.post.getFeed#feedPost"
-
}
-
},
-
"cursor": {
-
"type": "string"
-
}
-
}
-
}
-
}
-
}
-
}
-
}
-104
internal/atproto/lexicon/social/coves/post/update.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.post.update",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Update an existing post",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post to update"
-
},
-
"title": {
-
"type": "string",
-
"maxGraphemes": 300,
-
"maxLength": 3000,
-
"description": "Updated title"
-
},
-
"content": {
-
"type": "string",
-
"maxLength": 50000,
-
"description": "Updated content - main text for text posts, description for media, etc."
-
},
-
"facets": {
-
"type": "array",
-
"description": "Updated rich text annotations for content",
-
"items": {
-
"type": "ref",
-
"ref": "social.coves.richtext.facet"
-
}
-
},
-
"embed": {
-
"type": "union",
-
"description": "Updated embedded content (note: changing embed type may be restricted)",
-
"refs": [
-
"social.coves.embed.images",
-
"social.coves.embed.video",
-
"social.coves.embed.external",
-
"social.coves.embed.post"
-
]
-
},
-
"contentLabels": {
-
"type": "array",
-
"description": "Updated content labels",
-
"items": {
-
"type": "string",
-
"knownValues": ["nsfw", "spoiler", "violence"],
-
"maxLength": 32
-
}
-
},
-
"editNote": {
-
"type": "string",
-
"maxLength": 300,
-
"description": "Optional note explaining the edit"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the updated post"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "New CID of the updated post"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "PostNotFound",
-
"description": "Post not found"
-
},
-
{
-
"name": "NotAuthorized",
-
"description": "User is not authorized to edit this post"
-
},
-
{
-
"name": "EditWindowExpired",
-
"description": "Edit window has expired (posts can only be edited within 24 hours)"
-
},
-
{
-
"name": "InvalidUpdate",
-
"description": "Invalid update operation (e.g., changing post type)"
-
}
-
]
-
}
-
}
-
}
+156
internal/atproto/lexicon/com/atproto/label/defs.json
···
+
{
+
"lexicon": 1,
+
"id": "com.atproto.label.defs",
+
"defs": {
+
"label": {
+
"type": "object",
+
"description": "Metadata tag on an atproto resource (eg, repo or record).",
+
"required": ["src", "uri", "val", "cts"],
+
"properties": {
+
"ver": {
+
"type": "integer",
+
"description": "The AT Protocol version of the label object."
+
},
+
"src": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the actor who created this label."
+
},
+
"uri": {
+
"type": "string",
+
"format": "uri",
+
"description": "AT URI of the record, repository (account), or other resource that this label applies to."
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
+
},
+
"val": {
+
"type": "string",
+
"maxLength": 128,
+
"description": "The short string name of the value or type of this label."
+
},
+
"neg": {
+
"type": "boolean",
+
"description": "If true, this is a negation label, overwriting a previous label."
+
},
+
"cts": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp when this label was created."
+
},
+
"exp": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp at which this label expires (no longer applies)."
+
},
+
"sig": {
+
"type": "bytes",
+
"description": "Signature of dag-cbor encoded label."
+
}
+
}
+
},
+
"selfLabels": {
+
"type": "object",
+
"description": "Metadata tags on an atproto record, published by the author within the record.",
+
"required": ["values"],
+
"properties": {
+
"values": {
+
"type": "array",
+
"items": { "type": "ref", "ref": "#selfLabel" },
+
"maxLength": 10
+
}
+
}
+
},
+
"selfLabel": {
+
"type": "object",
+
"description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
+
"required": ["val"],
+
"properties": {
+
"val": {
+
"type": "string",
+
"maxLength": 128,
+
"description": "The short string name of the value or type of this label."
+
}
+
}
+
},
+
"labelValueDefinition": {
+
"type": "object",
+
"description": "Declares a label value and its expected interpretations and behaviors.",
+
"required": ["identifier", "severity", "blurs", "locales"],
+
"properties": {
+
"identifier": {
+
"type": "string",
+
"description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
+
"maxLength": 100,
+
"maxGraphemes": 100
+
},
+
"severity": {
+
"type": "string",
+
"description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
+
"knownValues": ["inform", "alert", "none"]
+
},
+
"blurs": {
+
"type": "string",
+
"description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
+
"knownValues": ["content", "media", "none"]
+
},
+
"defaultSetting": {
+
"type": "string",
+
"description": "The default setting for this label.",
+
"knownValues": ["ignore", "warn", "hide"],
+
"default": "warn"
+
},
+
"adultOnly": {
+
"type": "boolean",
+
"description": "Does the user need to have adult content enabled in order to configure this label?"
+
},
+
"locales": {
+
"type": "array",
+
"items": { "type": "ref", "ref": "#labelValueDefinitionStrings" }
+
}
+
}
+
},
+
"labelValueDefinitionStrings": {
+
"type": "object",
+
"description": "Strings which describe the label in the UI, localized into a specific language.",
+
"required": ["lang", "name", "description"],
+
"properties": {
+
"lang": {
+
"type": "string",
+
"description": "The code of the language these strings are written in.",
+
"format": "language"
+
},
+
"name": {
+
"type": "string",
+
"description": "A short human-readable name for the label.",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"description": {
+
"type": "string",
+
"description": "A longer description of what the label means and why it might be applied.",
+
"maxGraphemes": 10000,
+
"maxLength": 100000
+
}
+
}
+
},
+
"labelValue": {
+
"type": "string",
+
"knownValues": [
+
"!hide",
+
"!no-promote",
+
"!warn",
+
"!no-unauthenticated",
+
"dmca-violation",
+
"doxxing",
+
"porn",
+
"sexual",
+
"nudity",
+
"nsfl",
+
"gore"
+
]
+
}
+
}
+
}
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
···
+
{
+
"lexicon": 1,
+
"id": "com.atproto.repo.strongRef",
+
"description": "A URI with a content-hash fingerprint.",
+
"defs": {
+
"main": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": { "type": "string", "format": "at-uri" },
+
"cid": { "type": "string", "format": "cid" }
+
}
+
}
+
}
+
}
+4 -21
internal/atproto/lexicon/social/coves/interaction/vote.json internal/atproto/lexicon/social/coves/feed/vote.json
···
{
"lexicon": 1,
-
"id": "social.coves.interaction.vote",
+
"id": "social.coves.feed.vote",
"defs": {
"main": {
"type": "record",
-
"description": "A vote (upvote or downvote) on a post or comment",
+
"description": "Record declaring a vote (upvote or downvote) on a post or comment. Requires authentication.",
"key": "tid",
"record": {
"type": "object",
···
"properties": {
"subject": {
"type": "ref",
-
"ref": "#strongRef",
+
"ref": "com.atproto.repo.strongRef",
"description": "Strong reference to the post or comment being voted on"
},
"direction": {
"type": "string",
-
"enum": ["up", "down"],
+
"knownValues": ["up", "down"],
"description": "Vote direction: up for upvote, down for downvote"
},
"createdAt": {
···
}
}
}
-
},
-
"strongRef": {
-
"type": "object",
-
"description": "Strong reference to a record (AT-URI + CID)",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the record content"
-
}
-
}
}
}
}
+3 -3
internal/validation/lexicon_test.go
···
// Test with JSON string
jsonString := `{
-
"$type": "social.coves.interaction.vote",
+
"$type": "social.coves.feed.vote",
"subject": {
"uri": "at://did:plc:test/social.coves.community.post/abc123",
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
···
"createdAt": "2024-01-01T00:00:00Z"
}`
-
if err := validator.ValidateRecord(jsonString, "social.coves.interaction.vote"); err != nil {
+
if err := validator.ValidateRecord(jsonString, "social.coves.feed.vote"); err != nil {
t.Errorf("Failed to validate JSON string: %v", err)
}
// Test with JSON bytes
jsonBytes := []byte(jsonString)
-
if err := validator.ValidateRecord(jsonBytes, "social.coves.interaction.vote"); err != nil {
+
if err := validator.ValidateRecord(jsonBytes, "social.coves.feed.vote"); err != nil {
t.Errorf("Failed to validate JSON bytes: %v", err)
}
}
+9
tests/lexicon-test-data/feed/vote-valid.json
···
+
{
+
"$type": "social.coves.feed.vote",
+
"subject": {
+
"uri": "at://did:plc:alice123/social.coves.community.post/3kbx2n5p",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
},
+
"direction": "up",
+
"createdAt": "2025-01-09T15:00:00Z"
+
}
-5
tests/lexicon-test-data/interaction/vote-valid.json
···
-
{
-
"$type": "social.coves.interaction.vote",
-
"subject": "at://did:plc:alice123/social.coves.post.text/3kbx2n5p",
-
"createdAt": "2025-01-09T15:00:00Z"
-
}
+3 -2
internal/atproto/lexicon/social/coves/community/getMembers.json
···
},
"sort": {
"type": "string",
-
"enum": ["reputation", "recent", "alphabetical"],
-
"default": "reputation"
+
"knownValues": ["reputation", "recent", "alphabetical"],
+
"default": "reputation",
+
"maxLength": 64
}
}
},
-33
internal/atproto/lexicon/social/coves/actor/block.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.block",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A block relationship where one user blocks another",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user being blocked"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the block was created"
-
},
-
"reason": {
-
"type": "string",
-
"maxGraphemes": 300,
-
"maxLength": 3000,
-
"description": "Optional reason for blocking"
-
}
-
}
-
}
-
}
-
}
-
}
-59
internal/atproto/lexicon/social/coves/actor/blockUser.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.blockUser",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Block another user",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user to block"
-
},
-
"reason": {
-
"type": "string",
-
"maxGraphemes": 300,
-
"maxLength": 3000,
-
"description": "Optional reason for blocking"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created block record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created block record"
-
},
-
"existing": {
-
"type": "boolean",
-
"description": "True if user was already blocked"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "SubjectNotFound",
-
"description": "Subject user not found"
-
}
-
]
-
}
-
}
-
}
+139
internal/atproto/lexicon/social/coves/actor/defs.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.actor.defs",
+
"defs": {
+
"profileView": {
+
"type": "object",
+
"description": "Basic profile view with essential information",
+
"required": ["did"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to avatar image"
+
}
+
}
+
},
+
"profileViewDetailed": {
+
"type": "object",
+
"description": "Detailed profile view with stats and viewer state",
+
"required": ["did"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"bio": {
+
"type": "string",
+
"maxGraphemes": 256,
+
"maxLength": 2560
+
},
+
"bioFacets": {
+
"type": "array",
+
"description": "Rich text annotations for bio",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to avatar image"
+
},
+
"banner": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to banner image"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"stats": {
+
"type": "ref",
+
"ref": "#profileStats",
+
"description": "Aggregated statistics"
+
},
+
"viewer": {
+
"type": "ref",
+
"ref": "#viewerState",
+
"description": "Viewer's relationship to this profile"
+
}
+
}
+
},
+
"profileStats": {
+
"type": "object",
+
"description": "Aggregated statistics for a user profile",
+
"properties": {
+
"postCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of posts created"
+
},
+
"commentCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of comments made"
+
},
+
"communityCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of communities subscribed to"
+
},
+
"reputation": {
+
"type": "integer",
+
"description": "Global reputation score"
+
},
+
"membershipCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of communities with membership status"
+
}
+
}
+
},
+
"viewerState": {
+
"type": "object",
+
"description": "The viewing user's relationship to this profile",
+
"properties": {
+
"blocked": {
+
"type": "boolean",
+
"description": "Whether the viewer has blocked this user"
+
},
+
"blockedBy": {
+
"type": "boolean",
+
"description": "Whether the viewer is blocked by this user"
+
},
+
"blockUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the block record if viewer blocked this user"
+
}
+
}
+
}
+
}
+
}
-85
internal/atproto/lexicon/social/coves/actor/getSaved.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.getSaved",
-
"defs": {
-
"main": {
-
"type": "query",
-
"description": "Get all saved posts and comments for the authenticated user",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {
-
"limit": {
-
"type": "integer",
-
"minimum": 1,
-
"maximum": 100,
-
"default": 50,
-
"description": "Number of items to return"
-
},
-
"cursor": {
-
"type": "string",
-
"description": "Cursor for pagination"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Filter by content type (optional)"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["savedItems"],
-
"properties": {
-
"savedItems": {
-
"type": "array",
-
"description": "All saved items for the user",
-
"items": {
-
"type": "ref",
-
"ref": "#savedItemView"
-
}
-
},
-
"cursor": {
-
"type": "string",
-
"description": "Cursor for next page"
-
}
-
}
-
}
-
}
-
},
-
"savedItemView": {
-
"type": "object",
-
"required": ["uri", "subject", "type", "savedAt"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the saved record"
-
},
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the saved post or comment"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Type of content that was saved"
-
},
-
"savedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the item was saved"
-
},
-
"note": {
-
"type": "string",
-
"description": "Optional note about why this was saved"
-
}
-
}
-
}
-
}
-
}
-198
internal/atproto/lexicon/social/coves/actor/preferences.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.preferences",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "User preferences and settings",
-
"key": "literal:self",
-
"record": {
-
"type": "object",
-
"properties": {
-
"feedPreferences": {
-
"type": "ref",
-
"ref": "#feedPreferences"
-
},
-
"contentFiltering": {
-
"type": "ref",
-
"ref": "#contentFiltering"
-
},
-
"notificationSettings": {
-
"type": "ref",
-
"ref": "#notificationSettings"
-
},
-
"privacySettings": {
-
"type": "ref",
-
"ref": "#privacySettings"
-
},
-
"displayPreferences": {
-
"type": "ref",
-
"ref": "#displayPreferences"
-
}
-
}
-
}
-
},
-
"feedPreferences": {
-
"type": "object",
-
"description": "Feed and content preferences",
-
"properties": {
-
"defaultFeed": {
-
"type": "string",
-
"enum": ["home", "all"],
-
"default": "home"
-
},
-
"defaultSort": {
-
"type": "string",
-
"enum": ["hot", "new", "top"],
-
"default": "hot",
-
"description": "Default sort order for community feeds"
-
},
-
"showNSFW": {
-
"type": "boolean",
-
"default": false
-
},
-
"blurNSFW": {
-
"type": "boolean",
-
"default": true,
-
"description": "Blur NSFW content until clicked"
-
},
-
"autoplayVideos": {
-
"type": "boolean",
-
"default": false
-
},
-
"infiniteScroll": {
-
"type": "boolean",
-
"default": true
-
}
-
}
-
},
-
"contentFiltering": {
-
"type": "object",
-
"description": "Content filtering preferences",
-
"properties": {
-
"blockedTags": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
},
-
"description": "Tags to filter out from feeds"
-
},
-
"blockedCommunities": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "did"
-
},
-
"description": "Communities to filter out from /all feeds"
-
},
-
"mutedWords": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
},
-
"description": "Words to filter out from content"
-
},
-
"languageFilter": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "language"
-
},
-
"description": "Only show content in these languages"
-
}
-
}
-
},
-
"notificationSettings": {
-
"type": "object",
-
"description": "Notification preferences",
-
"properties": {
-
"postReplies": {
-
"type": "boolean",
-
"default": true
-
},
-
"commentReplies": {
-
"type": "boolean",
-
"default": true
-
},
-
"mentions": {
-
"type": "boolean",
-
"default": true
-
},
-
"upvotes": {
-
"type": "boolean",
-
"default": false
-
},
-
"newFollowers": {
-
"type": "boolean",
-
"default": true
-
},
-
"communityInvites": {
-
"type": "boolean",
-
"default": true
-
},
-
"moderatorNotifications": {
-
"type": "boolean",
-
"default": true,
-
"description": "Notifications for moderator actions in your communities"
-
}
-
}
-
},
-
"privacySettings": {
-
"type": "object",
-
"description": "Privacy preferences",
-
"properties": {
-
"profileVisibility": {
-
"type": "string",
-
"enum": ["public", "authenticated", "followers"],
-
"default": "public"
-
},
-
"showSubscriptions": {
-
"type": "boolean",
-
"default": true
-
},
-
"showSavedPosts": {
-
"type": "boolean",
-
"default": false
-
},
-
"showVoteHistory": {
-
"type": "boolean",
-
"default": false
-
},
-
"allowDMs": {
-
"type": "string",
-
"enum": ["everyone", "followers", "none"],
-
"default": "everyone"
-
}
-
}
-
},
-
"displayPreferences": {
-
"type": "object",
-
"description": "Display and UI preferences",
-
"properties": {
-
"theme": {
-
"type": "string",
-
"enum": ["light", "dark", "auto"],
-
"default": "auto"
-
},
-
"compactView": {
-
"type": "boolean",
-
"default": false
-
},
-
"showAvatars": {
-
"type": "boolean",
-
"default": true
-
},
-
"showThumbnails": {
-
"type": "boolean",
-
"default": true
-
},
-
"postsPerPage": {
-
"type": "integer",
-
"minimum": 10,
-
"maximum": 100,
-
"default": 25
-
}
-
}
-
}
-
}
-
}
-63
internal/atproto/lexicon/social/coves/actor/saveItem.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.saveItem",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Save a post or comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject", "type"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment to save"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Type of content being saved"
-
},
-
"note": {
-
"type": "string",
-
"maxLength": 300,
-
"description": "Optional note about why this was saved"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created saved record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created saved record"
-
},
-
"existing": {
-
"type": "boolean",
-
"description": "True if item was already saved"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "SubjectNotFound",
-
"description": "The post or comment to save was not found"
-
}
-
]
-
}
-
}
-
}
-37
internal/atproto/lexicon/social/coves/actor/saved.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.saved",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A saved post or comment",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "type", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment being saved"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Type of content being saved"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the item was saved"
-
},
-
"note": {
-
"type": "string",
-
"maxLength": 300,
-
"description": "Optional note about why this was saved"
-
}
-
}
-
}
-
}
-
}
-
}
-39
internal/atproto/lexicon/social/coves/actor/subscription.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.subscription",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A subscription to a community",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["community", "createdAt"],
-
"properties": {
-
"community": {
-
"type": "string",
-
"format": "at-identifier",
-
"description": "DID or handle of the community"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the subscription started"
-
},
-
"endedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the subscription ended (null if current)"
-
},
-
"contentVisibility": {
-
"type": "integer",
-
"minimum": 1,
-
"maximum": 5,
-
"default": 3,
-
"description": "Content visibility level (1=only best content, 5=all content)"
-
}
-
}
-
}
-
}
-
}
-
}
-37
internal/atproto/lexicon/social/coves/actor/unblockUser.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.unblockUser",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Unblock a previously blocked user",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user to unblock"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "NotBlocked",
-
"description": "User is not currently blocked"
-
}
-
]
-
}
-
}
-
}
-37
internal/atproto/lexicon/social/coves/actor/unsaveItem.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.unsaveItem",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Unsave a previously saved post or comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment to unsave"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "NotSaved",
-
"description": "Item is not currently saved"
-
}
-
]
-
}
-
}
-
}
+1 -172
internal/atproto/lexicon/social/coves/actor/profile.json
···
"key": "literal:self",
"record": {
"type": "object",
-
"required": ["handle", "createdAt"],
+
"required": ["createdAt"],
"properties": {
-
"handle": {
-
"type": "string",
-
"format": "handle",
-
"maxLength": 253,
-
"description": "User's handle"
-
},
"displayName": {
"type": "string",
"maxGraphemes": 64,
···
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 2000000
},
-
"verified": {
-
"type": "boolean",
-
"default": false,
-
"description": "Whether the user has completed phone verification"
-
},
-
"verifiedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the user was verified"
-
},
-
"verificationExpiresAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When verification expires"
-
},
-
"federatedFrom": {
-
"type": "string",
-
"knownValues": ["bluesky", "lemmy", "mastodon", "coves"],
-
"description": "Platform user federated from"
-
},
-
"federatedIdentity": {
-
"type": "ref",
-
"ref": "#federatedIdentity",
-
"description": "Identity information from federated platform"
-
},
-
"location": {
-
"type": "ref",
-
"ref": "#geoLocation"
-
},
"createdAt": {
"type": "string",
"format": "datetime"
-
},
-
"moderatedCommunities": {
-
"type": "array",
-
"description": "Communities the user currently moderates",
-
"items": {
-
"type": "string",
-
"format": "did"
-
}
-
},
-
"moderationHistory": {
-
"type": "array",
-
"description": "Historical record of all moderation roles",
-
"items": {
-
"type": "ref",
-
"ref": "#moderationRole"
-
}
-
},
-
"violations": {
-
"type": "array",
-
"description": "Record of rule violations across communities",
-
"items": {
-
"type": "ref",
-
"ref": "#violation"
-
}
}
}
}
-
},
-
"moderationRole": {
-
"type": "object",
-
"required": ["communityDid", "role", "startedAt"],
-
"properties": {
-
"communityDid": {
-
"type": "string",
-
"format": "did",
-
"description": "Community where moderation role was held"
-
},
-
"role": {
-
"type": "string",
-
"knownValues": ["moderator", "admin"],
-
"description": "Type of moderation role"
-
},
-
"startedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the role began"
-
},
-
"endedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the role ended (null if current)"
-
}
-
}
-
},
-
"violation": {
-
"type": "object",
-
"required": ["communityDid", "ruleViolated", "timestamp", "severity"],
-
"properties": {
-
"communityDid": {
-
"type": "string",
-
"format": "did",
-
"description": "Community where violation occurred"
-
},
-
"ruleViolated": {
-
"type": "string",
-
"description": "Description of the rule that was violated"
-
},
-
"timestamp": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the violation occurred"
-
},
-
"severity": {
-
"type": "string",
-
"knownValues": ["minor", "moderate", "major", "severe"],
-
"description": "Severity level of the violation"
-
},
-
"resolution": {
-
"type": "string",
-
"description": "How the violation was resolved"
-
},
-
"postUri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "Optional reference to the violating content"
-
}
-
}
-
},
-
"federatedIdentity": {
-
"type": "object",
-
"description": "Verified identity from a federated platform",
-
"required": ["did", "handle", "verifiedAt"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did",
-
"description": "Original DID from the federated platform"
-
},
-
"handle": {
-
"type": "string",
-
"maxLength": 253,
-
"description": "Original handle from the federated platform"
-
},
-
"verifiedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the federated identity was verified via OAuth"
-
},
-
"lastSyncedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "Last time profile data was synced from the federated platform"
-
},
-
"homePDS": {
-
"type": "string",
-
"description": "Home PDS server URL for the federated account"
-
}
-
}
-
},
-
"geoLocation": {
-
"type": "object",
-
"description": "Geographic location information",
-
"properties": {
-
"country": {
-
"type": "string",
-
"maxLength": 2,
-
"description": "ISO 3166-1 alpha-2 country code"
-
},
-
"region": {
-
"type": "string",
-
"maxLength": 128,
-
"description": "State/province/region name"
-
},
-
"displayName": {
-
"type": "string",
-
"maxLength": 256,
-
"description": "Human-readable location name"
-
}
-
}
}
}
}
-5
tests/lexicon-test-data/actor/block-invalid-did.json
···
-
{
-
"$type": "social.coves.actor.block",
-
"subject": "not-a-valid-did",
-
"createdAt": "2025-01-05T09:15:00Z"
-
}
-6
tests/lexicon-test-data/actor/block-valid.json
···
-
{
-
"$type": "social.coves.actor.block",
-
"subject": "did:plc:blockeduser123",
-
"createdAt": "2025-01-05T09:15:00Z",
-
"reason": "Repeated harassment and spam"
-
}
-7
tests/lexicon-test-data/actor/preferences-invalid-enum.json
···
-
{
-
"$type": "social.coves.actor.preferences",
-
"feedPreferences": {
-
"defaultFeed": "invalid-feed-type",
-
"defaultSort": "hot"
-
}
-
}
-40
tests/lexicon-test-data/actor/preferences-valid.json
···
-
{
-
"$type": "social.coves.actor.preferences",
-
"feedPreferences": {
-
"defaultFeed": "home",
-
"defaultSort": "hot",
-
"showNSFW": false,
-
"blurNSFW": true,
-
"autoplayVideos": true,
-
"infiniteScroll": true
-
},
-
"contentFiltering": {
-
"blockedTags": ["politics", "spoilers"],
-
"blockedCommunities": ["did:plc:controversialcommunity"],
-
"mutedWords": ["spam", "scam"],
-
"languageFilter": ["en", "es"]
-
},
-
"notificationSettings": {
-
"postReplies": true,
-
"commentReplies": true,
-
"mentions": true,
-
"upvotes": false,
-
"newFollowers": true,
-
"communityInvites": true,
-
"moderatorNotifications": true
-
},
-
"privacySettings": {
-
"profileVisibility": "public",
-
"showSubscriptions": true,
-
"showSavedPosts": false,
-
"showVoteHistory": false,
-
"allowDMs": "followers"
-
},
-
"displayPreferences": {
-
"theme": "dark",
-
"compactView": false,
-
"showAvatars": true,
-
"showThumbnails": true,
-
"postsPerPage": 25
-
}
-
}
-6
tests/lexicon-test-data/actor/profile-invalid-handle-format.json
···
-
{
-
"$type": "social.coves.actor.profile",
-
"handle": "invalid handle with spaces",
-
"displayName": "Test User",
-
"createdAt": "2024-01-01T00:00:00Z"
-
}
-4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
···
-
{
-
"$type": "social.coves.actor.profile",
-
"displayName": "Missing Required Fields"
-
}
-1
tests/lexicon-test-data/actor/profile-valid.json
···
{
"$type": "social.coves.actor.profile",
-
"handle": "alice.example.com",
"displayName": "Alice Johnson",
"bio": "Software developer passionate about open-source",
"createdAt": "2024-01-15T10:30:00Z"
+9 -46
internal/atproto/lexicon/social/coves/richtext/facet.json
···
},
"mention": {
"type": "object",
-
"description": "Facet feature for user or community mentions",
-
"required": ["$type", "did"],
+
"description": "Facet feature for mention of a user or community. The text is usually a handle with '@' (user) or '!' (community) prefix, but the facet reference is a DID.",
+
"required": ["did"],
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.richtext.facet#mention"
-
},
"did": {
"type": "string",
"format": "did",
-
"description": "DID of the mentioned user (@) or community (!)"
-
},
-
"handle": {
-
"type": "string",
-
"description": "Handle at time of mention (may change)"
+
"description": "DID of the mentioned user or community"
}
}
},
"link": {
"type": "object",
-
"description": "Facet feature for hyperlinks",
-
"required": ["$type", "uri"],
+
"description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.",
+
"required": ["uri"],
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.richtext.facet#link"
-
},
"uri": {
"type": "string",
"format": "uri",
···
},
"bold": {
"type": "object",
-
"description": "Bold text formatting",
-
"required": ["$type"],
-
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.richtext.facet#bold"
-
}
-
}
+
"description": "Bold text formatting"
},
"italic": {
"type": "object",
-
"description": "Italic text formatting",
-
"required": ["$type"],
-
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.richtext.facet#italic"
-
}
-
}
+
"description": "Italic text formatting"
},
"strikethrough": {
"type": "object",
-
"description": "Strikethrough text formatting",
-
"required": ["$type"],
-
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.richtext.facet#strikethrough"
-
}
-
}
+
"description": "Strikethrough text formatting"
},
"spoiler": {
"type": "object",
"description": "Hidden/spoiler text that requires user interaction to reveal",
-
"required": ["$type"],
"properties": {
-
"$type": {
-
"type": "string",
-
"const": "social.coves.richtext.facet#spoiler"
-
},
"reason": {
"type": "string",
"maxLength": 128,
+
"maxGraphemes": 32,
"description": "Optional explanation of what's hidden"
}
}
+3 -5
internal/atproto/lexicon/social/coves/richtext/facet_test.go
···
},
"features": [{
"$type": "social.coves.richtext.facet#mention",
-
"did": "did:plc:example123",
-
"handle": "alice.bsky.social"
+
"did": "did:plc:example123"
}]
}`,
wantErr: false,
···
name: "mention",
typeName: "social.coves.richtext.facet#mention",
feature: map[string]interface{}{
-
"$type": "social.coves.richtext.facet#mention",
-
"did": "did:plc:example123",
-
"handle": "alice.bsky.social",
+
"$type": "social.coves.richtext.facet#mention",
+
"did": "did:plc:example123",
},
},
{
+1 -1
internal/atproto/lexicon/social/coves/community/search.json
···
"type": "array",
"items": {
"type": "ref",
-
"ref": "social.coves.community.list#communityView"
+
"ref": "social.coves.community.defs#communityView"
}
},
"cursor": {
+1
internal/atproto/lexicon/social/coves/embed/images.json
···
"alt": {
"type": "string",
"maxLength": 1000,
+
"maxGraphemes": 1000,
"description": "Alt text for accessibility"
},
"aspectRatio": {
+1 -4
internal/atproto/lexicon/social/coves/embed/video.json
···
"alt": {
"type": "string",
"maxLength": 1000,
+
"maxGraphemes": 1000,
"description": "Alt text describing video content"
},
"duration": {
"type": "integer",
"minimum": 0,
"description": "Duration in seconds"
-
},
-
"aspectRatio": {
-
"type": "ref",
-
"ref": "social.coves.embed.image#aspectRatio"
}
}
}
-32
internal/atproto/lexicon/social/coves/federation/post.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.federation.post",
-
"defs": {
-
"main": {
-
"type": "object",
-
"description": "Reference to original federated post",
-
"required": ["platform", "uri"],
-
"properties": {
-
"platform": {
-
"type": "string",
-
"knownValues": ["bluesky", "lemmy", "atproto"],
-
"description": "Platform the post originated from"
-
},
-
"uri": {
-
"type": "string",
-
"format": "uri",
-
"description": "Original URI of the post (at:// URI for atproto platforms)"
-
},
-
"id": {
-
"type": "string",
-
"description": "Platform-specific post ID"
-
},
-
"originalCreatedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "Timestamp when originally posted on source platform"
-
}
-
}
-
}
-
}
-
}
+3 -3
internal/atproto/lexicon/social/coves/feed/getCommunity.json
···
},
"sort": {
"type": "string",
-
"enum": ["hot", "top", "new"],
+
"knownValues": ["hot", "top", "new"],
"default": "hot",
"description": "Sort order for community feed"
},
"timeframe": {
"type": "string",
-
"enum": ["hour", "day", "week", "month", "year", "all"],
+
"knownValues": ["hour", "day", "week", "month", "year", "all"],
"default": "day",
"description": "Timeframe for top sorting (only applies when sort=top)"
},
···
"type": "array",
"items": {
"type": "ref",
-
"ref": "social.coves.feed.getTimeline#feedViewPost"
+
"ref": "social.coves.feed.defs#feedViewPost"
}
},
"cursor": {
+2 -2
internal/atproto/lexicon/social/coves/feed/getTimeline.json
···
"properties": {
"sort": {
"type": "string",
-
"enum": ["hot", "top", "new"],
+
"knownValues": ["hot", "top", "new"],
"default": "hot",
"description": "Sort order for timeline feed"
},
"timeframe": {
"type": "string",
-
"enum": ["hour", "day", "week", "month", "year", "all"],
+
"knownValues": ["hour", "day", "week", "month", "year", "all"],
"default": "day",
"description": "Timeframe for top sorting (only applies when sort=top)"
},
-31
internal/atproto/lexicon/social/coves/interaction/share.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.share",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "Sharing a post to another community or platform",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post being shared"
-
},
-
"toCommunity": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "Community being shared to (if applicable)"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-
}
-33
internal/atproto/lexicon/social/coves/interaction/tag.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.tag",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A tag applied to a post or comment",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "tag", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment being tagged"
-
},
-
"tag": {
-
"type": "string",
-
"minLength": 1,
-
"maxLength": 50,
-
"knownValues": ["helpful", "insightful", "spam", "hostile", "offtopic", "misleading"],
-
"description": "Predefined tag or custom community tag"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-
}
-9
tests/lexicon-test-data/community/moderator-invalid-permissions.json
···
-
{
-
"$type": "social.coves.community.moderator",
-
"user": "did:plc:moderator123",
-
"community": "did:plc:community123",
-
"role": "moderator",
-
"permissions": ["remove_posts", "invalid-permission"],
-
"createdAt": "2024-06-15T10:00:00Z",
-
"createdBy": "did:plc:owner123"
-
}
-5
tests/lexicon-test-data/interaction/share-valid-no-community.json
···
-
{
-
"$type": "social.coves.interaction.share",
-
"subject": "at://did:plc:originalauthor/social.coves.post.record/3k7a3dmb5bk2c",
-
"createdAt": "2025-01-09T17:00:00Z"
-
}
-6
tests/lexicon-test-data/interaction/share-valid.json
···
-
{
-
"$type": "social.coves.interaction.share",
-
"subject": "at://did:plc:originalauthor/social.coves.post.record/3k7a3dmb5bk2c",
-
"community": "did:plc:targetcommunity",
-
"createdAt": "2025-01-09T17:00:00Z"
-
}
-6
tests/lexicon-test-data/interaction/tag-invalid-empty.json
···
-
{
-
"$type": "social.coves.interaction.tag",
-
"subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c",
-
"tag": "",
-
"createdAt": "2025-01-09T17:15:00Z"
-
}
-6
tests/lexicon-test-data/interaction/tag-valid-custom.json
···
-
{
-
"$type": "social.coves.interaction.tag",
-
"subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c",
-
"tag": "beginner-friendly",
-
"createdAt": "2025-01-09T17:15:00Z"
-
}
-6
tests/lexicon-test-data/interaction/tag-valid-known.json
···
-
{
-
"$type": "social.coves.interaction.tag",
-
"subject": "at://did:plc:author123/social.coves.post.record/3k7a3dmb5bk2c",
-
"tag": "nsfw",
-
"createdAt": "2025-01-09T17:15:00Z"
-
}
+13
scripts/validate-schemas.sh
···
+
#!/bin/bash
+
# Validate all lexicon schemas and test data
+
+
set -e
+
+
echo "๐Ÿ” Validating Coves lexicon schemas..."
+
echo ""
+
+
# Run the Go validation tool
+
go run ./cmd/validate-lexicon/main.go
+
+
echo ""
+
echo "โœ… Schema validation complete!"
+63
internal/db/migrations/016_create_comments_table.sql
···
+
-- +goose Up
+
-- Create comments table for AppView indexing
+
-- Comments are indexed from the firehose after being written to user repositories
+
CREATE TABLE comments (
+
id BIGSERIAL PRIMARY KEY,
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.feed.comment/rkey)
+
cid TEXT NOT NULL, -- Content ID
+
rkey TEXT NOT NULL, -- Record key (TID)
+
commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field)
+
+
-- Threading structure (reply references)
+
root_uri TEXT NOT NULL, -- Strong reference to original post (at://...)
+
root_cid TEXT NOT NULL, -- CID of root post (version pinning)
+
parent_uri TEXT NOT NULL, -- Strong reference to immediate parent (post or comment)
+
parent_cid TEXT NOT NULL, -- CID of parent (version pinning)
+
+
-- Content (content is required per lexicon, others optional)
+
content TEXT NOT NULL, -- Comment text (max 3000 graphemes, 30000 bytes)
+
content_facets JSONB, -- Rich text facets (social.coves.richtext.facet)
+
embed JSONB, -- Embedded content (images, quoted posts)
+
content_labels JSONB, -- Self-applied labels (com.atproto.label.defs#selfLabels)
+
langs TEXT[], -- Languages (ISO 639-1, max 3)
+
+
-- Timestamps
+
created_at TIMESTAMPTZ NOT NULL, -- Commenter's timestamp from record
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView
+
deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events)
+
+
-- Stats (denormalized for performance)
+
upvote_count INT NOT NULL DEFAULT 0, -- Comments can be voted on (per vote lexicon)
+
downvote_count INT NOT NULL DEFAULT 0,
+
score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count (for sorting)
+
reply_count INT NOT NULL DEFAULT 0 -- Number of direct replies to this comment
+
+
-- NO foreign key constraint on commenter_did to allow out-of-order indexing from Jetstream
+
-- Comment events may arrive before user events, which is acceptable since:
+
-- 1. Comments are authenticated by the user's PDS (security maintained)
+
-- 2. Orphaned comments from never-indexed users are harmless
+
-- 3. This prevents race conditions in the firehose consumer
+
);
+
+
-- Indexes for threading queries (most important for comment UX)
+
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;
+
+
-- Indexes for user queries
+
CREATE INDEX idx_comments_commenter ON comments(commenter_did, created_at DESC);
+
CREATE INDEX idx_comments_uri ON comments(uri);
+
+
-- Index for vote targeting (when votes target comments)
+
CREATE INDEX idx_comments_uri_active ON comments(uri) WHERE deleted_at IS NULL;
+
+
-- Comment on table
+
COMMENT ON TABLE comments IS 'Comments indexed from user repositories via Jetstream firehose consumer';
+
COMMENT ON COLUMN comments.uri IS 'AT-URI in format: at://commenter_did/social.coves.feed.comment/rkey';
+
COMMENT ON COLUMN comments.root_uri IS 'Strong reference to the original post that started the thread';
+
COMMENT ON COLUMN comments.parent_uri IS 'Strong reference to immediate parent (post or comment)';
+
COMMENT ON COLUMN comments.score IS 'Computed as upvote_count - downvote_count for ranking replies';
+
COMMENT ON COLUMN comments.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+
+
-- +goose Down
+
DROP TABLE IF EXISTS comments CASCADE;
+125
internal/atproto/jetstream/comment_jetstream_connector.go
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"sync"
+
"time"
+
+
"github.com/gorilla/websocket"
+
)
+
+
// CommentJetstreamConnector handles WebSocket connection to Jetstream for comment events
+
type CommentJetstreamConnector struct {
+
consumer *CommentEventConsumer
+
wsURL string
+
}
+
+
// NewCommentJetstreamConnector creates a new Jetstream WebSocket connector for comment events
+
func NewCommentJetstreamConnector(consumer *CommentEventConsumer, wsURL string) *CommentJetstreamConnector {
+
return &CommentJetstreamConnector{
+
consumer: consumer,
+
wsURL: wsURL,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *CommentJetstreamConnector) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream comment consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream comment consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream comment connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *CommentJetstreamConnector) connect(ctx context.Context) error {
+
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL, nil)
+
if err != nil {
+
return fmt.Errorf("failed to connect to Jetstream: %w", err)
+
}
+
defer func() {
+
if closeErr := conn.Close(); closeErr != nil {
+
log.Printf("Failed to close WebSocket connection: %v", closeErr)
+
}
+
}()
+
+
log.Println("Connected to Jetstream (comment consumer)")
+
+
// Set read deadline to detect connection issues
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline: %v", err)
+
}
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
+
log.Printf("Failed to set read deadline in pong handler: %v", err)
+
}
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
var closeOnce sync.Once // Ensure done channel is only closed once
+
+
// Ping goroutine
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+
log.Printf("Failed to send ping: %v", err)
+
closeOnce.Do(func() { close(done) })
+
return
+
}
+
case <-done:
+
return
+
}
+
}
+
}()
+
+
// Read loop
+
for {
+
select {
+
case <-done:
+
return fmt.Errorf("connection closed by ping failure")
+
default:
+
}
+
+
_, message, err := conn.ReadMessage()
+
if err != nil {
+
closeOnce.Do(func() { close(done) })
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Parse Jetstream event
+
var event JetstreamEvent
+
if err := json.Unmarshal(message, &event); err != nil {
+
log.Printf("Failed to parse Jetstream event: %v", err)
+
continue
+
}
+
+
// Process event through consumer
+
if err := c.consumer.HandleEvent(ctx, &event); err != nil {
+
log.Printf("Failed to handle comment event: %v", err)
+
// Continue processing other events even if one fails
+
}
+
}
+
}
+221
internal/atproto/lexicon/social/coves/community/comment/defs.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.defs",
+
"defs": {
+
"commentView": {
+
"type": "object",
+
"description": "Base view for a single comment with voting, stats, and viewer state",
+
"required": ["uri", "cid", "author", "record", "post", "content", "createdAt", "indexedAt", "stats"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment record"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the comment record"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "social.coves.community.post.get#authorView",
+
"description": "Comment author information"
+
},
+
"record": {
+
"type": "unknown",
+
"description": "The actual comment record verbatim"
+
},
+
"post": {
+
"type": "ref",
+
"ref": "#postRef",
+
"description": "Reference to the parent post"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "#commentRef",
+
"description": "Reference to parent comment if this is a nested reply"
+
},
+
"content": {
+
"type": "string",
+
"description": "Comment text content"
+
},
+
"contentFacets": {
+
"type": "array",
+
"description": "Rich text annotations for mentions, links, formatting",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded content in the comment (images or quoted post)",
+
"refs": [
+
"social.coves.embed.images#view",
+
"social.coves.embed.post#view"
+
]
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the comment was created"
+
},
+
"indexedAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When this comment was indexed by the AppView"
+
},
+
"stats": {
+
"type": "ref",
+
"ref": "#commentStats",
+
"description": "Comment statistics (votes, replies)"
+
},
+
"viewer": {
+
"type": "ref",
+
"ref": "#commentViewerState",
+
"description": "Viewer-specific state (vote, saved, etc.)"
+
}
+
}
+
},
+
"threadViewComment": {
+
"type": "object",
+
"description": "Wrapper for threaded comment structure, similar to Bluesky's threadViewPost pattern",
+
"required": ["comment"],
+
"properties": {
+
"comment": {
+
"type": "ref",
+
"ref": "#commentView",
+
"description": "The comment itself"
+
},
+
"replies": {
+
"type": "array",
+
"description": "Nested replies to this comment",
+
"items": {
+
"type": "union",
+
"refs": ["#threadViewComment", "#notFoundComment", "#blockedComment"]
+
}
+
},
+
"hasMore": {
+
"type": "boolean",
+
"description": "True if more replies exist but are not included in this response"
+
}
+
}
+
},
+
"commentRef": {
+
"type": "object",
+
"description": "Reference to a comment record",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the comment record"
+
}
+
}
+
},
+
"postRef": {
+
"type": "object",
+
"description": "Reference to a post record",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the post"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the post record"
+
}
+
}
+
},
+
"notFoundComment": {
+
"type": "object",
+
"description": "Comment was not found (deleted, never indexed, or invalid URI)",
+
"required": ["uri", "notFound"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the missing comment"
+
},
+
"notFound": {
+
"type": "boolean",
+
"const": true,
+
"description": "Always true for not found comments"
+
}
+
}
+
},
+
"blockedComment": {
+
"type": "object",
+
"description": "Comment is blocked due to viewer blocking author or moderation action",
+
"required": ["uri", "blocked"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the blocked comment"
+
},
+
"blocked": {
+
"type": "boolean",
+
"const": true,
+
"description": "Always true for blocked comments"
+
},
+
"blockedBy": {
+
"type": "string",
+
"knownValues": ["author", "moderator"],
+
"description": "What caused the block: viewer blocked author, or comment was removed by moderators"
+
}
+
}
+
},
+
"commentStats": {
+
"type": "object",
+
"description": "Statistics for a comment",
+
"required": ["upvotes", "downvotes", "score", "replyCount"],
+
"properties": {
+
"upvotes": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of upvotes"
+
},
+
"downvotes": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of downvotes"
+
},
+
"score": {
+
"type": "integer",
+
"description": "Calculated score (upvotes - downvotes)"
+
},
+
"replyCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of direct replies to this comment"
+
}
+
}
+
},
+
"commentViewerState": {
+
"type": "object",
+
"description": "Viewer-specific state for a comment",
+
"properties": {
+
"vote": {
+
"type": "string",
+
"knownValues": ["up", "down"],
+
"description": "Viewer's vote on this comment"
+
},
+
"voteUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the viewer's vote record"
+
}
+
}
+
}
+
}
+
}
+86
internal/atproto/lexicon/social/coves/community/comment/getComments.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.getComments",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get comments for a post with threading and sorting support. Supports hot/top/new sorting, configurable nesting depth, and pagination.",
+
"parameters": {
+
"type": "params",
+
"required": ["post"],
+
"properties": {
+
"post": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the post to get comments for"
+
},
+
"sort": {
+
"type": "string",
+
"default": "hot",
+
"knownValues": ["hot", "top", "new"],
+
"description": "Sort order: hot (trending), top (highest score), new (most recent)"
+
},
+
"timeframe": {
+
"type": "string",
+
"knownValues": ["hour", "day", "week", "month", "year", "all"],
+
"description": "Timeframe for 'top' sort. Ignored for other sort types."
+
},
+
"depth": {
+
"type": "integer",
+
"default": 10,
+
"minimum": 0,
+
"maximum": 100,
+
"description": "Maximum reply nesting depth to return. 0 returns only top-level comments."
+
},
+
"limit": {
+
"type": "integer",
+
"default": 50,
+
"minimum": 1,
+
"maximum": 100,
+
"description": "Maximum number of top-level comments to return per page"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor from previous response"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["comments", "post"],
+
"properties": {
+
"comments": {
+
"type": "array",
+
"description": "Top-level comments with nested replies up to requested depth",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.community.comment.defs#threadViewComment"
+
}
+
},
+
"post": {
+
"type": "ref",
+
"ref": "social.coves.community.post.get#postView",
+
"description": "The post these comments belong to"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor for fetching next page of top-level comments"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotFound",
+
"description": "Post not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid parameters (malformed URI, invalid sort/timeframe combination, etc.)"
+
}
+
]
+
}
+
}
+
}
+11
internal/core/comments/interfaces.go
···
// Returns map[commentURI]*Vote for efficient lookups
// Future: Used when votes table is implemented
GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
+
+
// ListByParentsBatch retrieves direct replies to multiple parents in a single query
+
// Returns map[parentURI][]*Comment grouped by parent
+
// Used to prevent N+1 queries when loading nested replies
+
// Limits results per parent to avoid memory exhaustion
+
ListByParentsBatch(
+
ctx context.Context,
+
parentURIs []string,
+
sort string,
+
limitPerParent int,
+
) (map[string][]*Comment, error)
}
+6 -6
tests/lexicon_validation_test.go
···
// Test specific cross-references that should work
crossRefs := map[string]string{
-
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
-
"social.coves.community.rules#rule": "rule definition in community rules",
-
"social.coves.actor.defs#profileView": "profileView definition in actor defs",
-
"social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
-
"social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
-
"social.coves.community.defs#communityView": "communityView definition in community defs",
+
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
+
"social.coves.community.rules#rule": "rule definition in community rules",
+
"social.coves.actor.defs#profileView": "profileView definition in actor defs",
+
"social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
+
"social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
+
"social.coves.community.defs#communityView": "communityView definition in community defs",
"social.coves.community.defs#communityStats": "communityStats definition in community defs",
}
+5
internal/atproto/lexicon/social/coves/community/post/get.json
···
"type": "string",
"format": "did"
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
"name": {
"type": "string"
},
+200
internal/core/unfurl/circuit_breaker.go
···
+
package unfurl
+
+
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) {
+
cb.mu.RLock()
+
defer cb.mu.RUnlock()
+
+
state := cb.getState(provider)
+
+
switch state {
+
case stateClosed:
+
return true, nil
+
case stateOpen:
+
// Check if we should transition to half-open
+
lastFail := cb.lastFailure[provider]
+
if time.Since(lastFail) > cb.openDuration {
+
// Transition to half-open (allow one retry)
+
cb.mu.RUnlock()
+
cb.mu.Lock()
+
cb.state[provider] = stateHalfOpen
+
cb.logStateChange(provider, stateHalfOpen)
+
cb.mu.Unlock()
+
cb.mu.RLock()
+
return true, nil
+
}
+
// Still in open period
+
failCount := cb.failures[provider]
+
nextRetry := lastFail.Add(cb.openDuration)
+
return false, fmt.Errorf(
+
"circuit breaker open for provider '%s' (failures: %d, next retry: %s)",
+
provider,
+
failCount,
+
nextRetry.Format("15:04:05"),
+
)
+
case stateHalfOpen:
+
return true, nil
+
default:
+
return true, nil
+
}
+
}
+
+
// recordSuccess records a successful unfurl, 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 unfurl 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(
+
"[UNFURL-CIRCUIT] Opening circuit for provider '%s' after %d consecutive failures. Last error: %v",
+
provider,
+
failCount,
+
err,
+
)
+
cb.lastStateLog[provider] = time.Now()
+
}
+
} else {
+
log.Printf(
+
"[UNFURL-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("[UNFURL-CIRCUIT] Circuit for provider '%s' is now %s", provider, stateStr)
+
cb.lastStateLog[provider] = time.Now()
+
}
+
+
// getStats returns current circuit breaker stats (for debugging/monitoring)
+
func (cb *circuitBreaker) getStats() map[string]interface{} {
+
cb.mu.RLock()
+
defer cb.mu.RUnlock()
+
+
stats := make(map[string]interface{})
+
+
// Collect all providers with any activity (state, failures, or both)
+
providers := make(map[string]bool)
+
for provider := range cb.state {
+
providers[provider] = true
+
}
+
for provider := range cb.failures {
+
providers[provider] = true
+
}
+
+
for provider := range providers {
+
state := cb.getState(provider)
+
var stateStr string
+
switch state {
+
case stateClosed:
+
stateStr = "closed"
+
case stateOpen:
+
stateStr = "open"
+
case stateHalfOpen:
+
stateStr = "half-open"
+
}
+
+
stats[provider] = map[string]interface{}{
+
"state": stateStr,
+
"failures": cb.failures[provider],
+
"last_failure": cb.lastFailure[provider],
+
}
+
}
+
return stats
+
}
+175
internal/core/unfurl/circuit_breaker_test.go
···
+
package unfurl
+
+
import (
+
"fmt"
+
"testing"
+
"time"
+
)
+
+
func TestCircuitBreaker_Basic(t *testing.T) {
+
cb := newCircuitBreaker()
+
+
provider := "test-provider"
+
+
// Should start closed (allow attempts)
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Errorf("Expected circuit to be closed initially, but got error: %v", err)
+
}
+
+
// Record success
+
cb.recordSuccess(provider)
+
canAttempt, _ = cb.canAttempt(provider)
+
if !canAttempt {
+
t.Error("Expected circuit to remain closed after success")
+
}
+
}
+
+
func TestCircuitBreaker_OpensAfterFailures(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "failing-provider"
+
+
// Record failures up to threshold
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, fmt.Errorf("test error %d", i))
+
}
+
+
// Circuit should now be open
+
canAttempt, err := cb.canAttempt(provider)
+
if canAttempt {
+
t.Error("Expected circuit to be open after threshold failures")
+
}
+
if err == nil {
+
t.Error("Expected error when circuit is open")
+
}
+
}
+
+
func TestCircuitBreaker_RecoveryAfterSuccess(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "recovery-provider"
+
+
// Record some failures
+
cb.recordFailure(provider, fmt.Errorf("error 1"))
+
cb.recordFailure(provider, fmt.Errorf("error 2"))
+
+
// Record success - should reset failure count
+
cb.recordSuccess(provider)
+
+
// Should be able to attempt again
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Errorf("Expected circuit to be closed after success, but got error: %v", err)
+
}
+
+
// Failure count should be reset
+
if count := cb.failures[provider]; count != 0 {
+
t.Errorf("Expected failure count to be reset to 0, got %d", count)
+
}
+
}
+
+
func TestCircuitBreaker_HalfOpenTransition(t *testing.T) {
+
cb := newCircuitBreaker()
+
cb.openDuration = 100 * time.Millisecond // Short duration for testing
+
provider := "half-open-provider"
+
+
// Open the circuit
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure(provider, fmt.Errorf("error %d", i))
+
}
+
+
// Should be open
+
canAttempt, _ := cb.canAttempt(provider)
+
if canAttempt {
+
t.Error("Expected circuit to be open")
+
}
+
+
// Wait for open duration
+
time.Sleep(150 * time.Millisecond)
+
+
// Should transition to half-open and allow one attempt
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Errorf("Expected circuit to transition to half-open after duration, but got error: %v", err)
+
}
+
+
// State should be half-open
+
cb.mu.RLock()
+
state := cb.state[provider]
+
cb.mu.RUnlock()
+
+
if state != stateHalfOpen {
+
t.Errorf("Expected state to be half-open, got %v", state)
+
}
+
}
+
+
func TestCircuitBreaker_MultipleProviders(t *testing.T) {
+
cb := newCircuitBreaker()
+
+
// Open circuit for provider A
+
for i := 0; i < cb.failureThreshold; i++ {
+
cb.recordFailure("providerA", fmt.Errorf("error"))
+
}
+
+
// Provider A should be blocked
+
canAttemptA, _ := cb.canAttempt("providerA")
+
if canAttemptA {
+
t.Error("Expected providerA circuit to be open")
+
}
+
+
// Provider B should still be open (independent circuits)
+
canAttemptB, err := cb.canAttempt("providerB")
+
if !canAttemptB {
+
t.Errorf("Expected providerB circuit to be closed, but got error: %v", err)
+
}
+
}
+
+
func TestCircuitBreaker_GetStats(t *testing.T) {
+
cb := newCircuitBreaker()
+
+
// Record some activity
+
cb.recordFailure("provider1", fmt.Errorf("error 1"))
+
cb.recordFailure("provider1", fmt.Errorf("error 2"))
+
+
stats := cb.getStats()
+
+
// Should have stats for providers with failures
+
if providerStats, ok := stats["provider1"]; !ok {
+
t.Error("Expected stats for provider1")
+
} else {
+
// Check that failure count is tracked
+
statsMap := providerStats.(map[string]interface{})
+
if failures, ok := statsMap["failures"].(int); !ok || failures != 2 {
+
t.Errorf("Expected 2 failures for provider1, got %v", statsMap["failures"])
+
}
+
}
+
+
// Provider that succeeds is cleaned up from state
+
cb.recordSuccess("provider2")
+
_ = cb.getStats()
+
// Provider2 should not be in stats (or have state "closed" with 0 failures)
+
}
+
+
func TestCircuitBreaker_FailureThresholdExact(t *testing.T) {
+
cb := newCircuitBreaker()
+
provider := "exact-threshold-provider"
+
+
// Record failures just below threshold
+
for i := 0; i < cb.failureThreshold-1; i++ {
+
cb.recordFailure(provider, fmt.Errorf("error %d", i))
+
}
+
+
// Should still be closed
+
canAttempt, err := cb.canAttempt(provider)
+
if !canAttempt {
+
t.Errorf("Expected circuit to be closed below threshold, but got error: %v", err)
+
}
+
+
// One more failure should open it
+
cb.recordFailure(provider, fmt.Errorf("final error"))
+
+
// Should now be open
+
canAttempt, _ = cb.canAttempt(provider)
+
if canAttempt {
+
t.Error("Expected circuit to be open at threshold")
+
}
+
}
+202
internal/core/unfurl/kagi_test.go
···
+
package unfurl
+
+
import (
+
"context"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
func TestFetchKagiKite_Success(t *testing.T) {
+
// Mock Kagi HTML response
+
mockHTML := `<!DOCTYPE html>
+
<html>
+
<head>
+
<title>FAA orders 10% flight cuts at 40 airports - Kagi News</title>
+
<meta property="og:title" content="FAA orders 10% flight cuts" />
+
<meta property="og:description" content="Flight restrictions announced" />
+
</head>
+
<body>
+
<img src="https://kagiproxy.com/img/DHdCvN_NqVDWU3UyoNZSv86b" alt="Airport runway" />
+
</body>
+
</html>`
+
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(mockHTML))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
require.NoError(t, err)
+
assert.Equal(t, "article", result.Type)
+
assert.Equal(t, "FAA orders 10% flight cuts", result.Title)
+
assert.Equal(t, "Flight restrictions announced", result.Description)
+
assert.Contains(t, result.ThumbnailURL, "kagiproxy.com")
+
assert.Equal(t, "kagi", result.Provider)
+
assert.Equal(t, "kite.kagi.com", result.Domain)
+
}
+
+
func TestFetchKagiKite_NoImage(t *testing.T) {
+
mockHTML := `<!DOCTYPE html>
+
<html>
+
<head><title>Test Story</title></head>
+
<body><p>No images here</p></body>
+
</html>`
+
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(mockHTML))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
assert.Error(t, err)
+
assert.Nil(t, result)
+
assert.Contains(t, err.Error(), "no image found")
+
}
+
+
func TestFetchKagiKite_FallbackToTitle(t *testing.T) {
+
mockHTML := `<!DOCTYPE html>
+
<html>
+
<head><title>Fallback Title</title></head>
+
<body>
+
<img src="https://kagiproxy.com/img/test123" />
+
</body>
+
</html>`
+
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(mockHTML))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
require.NoError(t, err)
+
assert.Equal(t, "Fallback Title", result.Title)
+
assert.Contains(t, result.ThumbnailURL, "kagiproxy.com")
+
}
+
+
func TestFetchKagiKite_ImageWithAltText(t *testing.T) {
+
mockHTML := `<!DOCTYPE html>
+
<html>
+
<head><title>News Story</title></head>
+
<body>
+
<img src="https://kagiproxy.com/img/xyz789" alt="This is the alt text description" />
+
</body>
+
</html>`
+
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(mockHTML))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
require.NoError(t, err)
+
assert.Equal(t, "News Story", result.Title)
+
assert.Equal(t, "This is the alt text description", result.Description)
+
assert.Contains(t, result.ThumbnailURL, "kagiproxy.com")
+
}
+
+
func TestFetchKagiKite_HTTPError(t *testing.T) {
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusNotFound)
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
assert.Error(t, err)
+
assert.Nil(t, result)
+
assert.Contains(t, err.Error(), "HTTP 404")
+
}
+
+
func TestFetchKagiKite_Timeout(t *testing.T) {
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
time.Sleep(2 * time.Second)
+
w.WriteHeader(http.StatusOK)
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 100*time.Millisecond, "TestBot/1.0")
+
+
assert.Error(t, err)
+
assert.Nil(t, result)
+
}
+
+
func TestFetchKagiKite_MultipleImages_PicksSecond(t *testing.T) {
+
mockHTML := `<!DOCTYPE html>
+
<html>
+
<head><title>Story with multiple images</title></head>
+
<body>
+
<img src="https://kagiproxy.com/img/first123" alt="First image (header/logo)" />
+
<img src="https://kagiproxy.com/img/second456" alt="Second image" />
+
</body>
+
</html>`
+
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(mockHTML))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
require.NoError(t, err)
+
// We skip the first image (often a header/logo) and use the second
+
assert.Contains(t, result.ThumbnailURL, "second456")
+
assert.Equal(t, "Second image", result.Description)
+
}
+
+
func TestFetchKagiKite_OnlyNonKagiImages_NoMatch(t *testing.T) {
+
mockHTML := `<!DOCTYPE html>
+
<html>
+
<head><title>Story with non-Kagi images</title></head>
+
<body>
+
<img src="https://example.com/img/test.jpg" alt="External image" />
+
</body>
+
</html>`
+
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(mockHTML))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
+
result, err := fetchKagiKite(ctx, server.URL, 5*time.Second, "TestBot/1.0")
+
+
assert.Error(t, err)
+
assert.Nil(t, result)
+
assert.Contains(t, err.Error(), "no image found")
+
}
+269
internal/core/unfurl/opengraph_test.go
···
+
package unfurl
+
+
import (
+
"context"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
func TestParseOpenGraph_ValidTags(t *testing.T) {
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta property="og:title" content="Test Article Title" />
+
<meta property="og:description" content="This is a test description" />
+
<meta property="og:image" content="https://example.com/image.jpg" />
+
<meta property="og:url" content="https://example.com/canonical" />
+
</head>
+
<body>
+
<p>Some content</p>
+
</body>
+
</html>
+
`
+
+
og, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
assert.Equal(t, "Test Article Title", og.Title)
+
assert.Equal(t, "This is a test description", og.Description)
+
assert.Equal(t, "https://example.com/image.jpg", og.Image)
+
assert.Equal(t, "https://example.com/canonical", og.URL)
+
}
+
+
func TestParseOpenGraph_MissingImage(t *testing.T) {
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta property="og:title" content="Article Without Image" />
+
<meta property="og:description" content="No image tag" />
+
</head>
+
<body></body>
+
</html>
+
`
+
+
og, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
assert.Equal(t, "Article Without Image", og.Title)
+
assert.Equal(t, "No image tag", og.Description)
+
assert.Empty(t, og.Image, "Image should be empty when not provided")
+
}
+
+
func TestParseOpenGraph_FallbackToTitle(t *testing.T) {
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<title>Page Title Fallback</title>
+
<meta name="description" content="Meta description fallback" />
+
</head>
+
<body></body>
+
</html>
+
`
+
+
og, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
assert.Equal(t, "Page Title Fallback", og.Title, "Should fall back to <title>")
+
assert.Equal(t, "Meta description fallback", og.Description, "Should fall back to meta description")
+
}
+
+
func TestParseOpenGraph_PreferOpenGraphOverFallback(t *testing.T) {
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<title>Page Title</title>
+
<meta name="description" content="Meta description" />
+
<meta property="og:title" content="OpenGraph Title" />
+
<meta property="og:description" content="OpenGraph Description" />
+
</head>
+
<body></body>
+
</html>
+
`
+
+
og, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
assert.Equal(t, "OpenGraph Title", og.Title, "Should prefer og:title")
+
assert.Equal(t, "OpenGraph Description", og.Description, "Should prefer og:description")
+
}
+
+
func TestParseOpenGraph_MalformedHTML(t *testing.T) {
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta property="og:title" content="Still Works" />
+
<meta property="og:description" content="Even with broken tags
+
</head>
+
<body>
+
<p>Unclosed paragraph
+
</body>
+
`
+
+
og, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
// Best-effort parsing should still extract what it can
+
assert.NotEmpty(t, og.Title, "Should extract title despite malformed HTML")
+
}
+
+
func TestParseOpenGraph_Empty(t *testing.T) {
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head></head>
+
<body></body>
+
</html>
+
`
+
+
og, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
assert.Empty(t, og.Title)
+
assert.Empty(t, og.Description)
+
assert.Empty(t, og.Image)
+
}
+
+
func TestFetchOpenGraph_Success(t *testing.T) {
+
// Create test server with OpenGraph metadata
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
assert.Contains(t, r.Header.Get("User-Agent"), "CovesBot")
+
+
html := `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta property="og:title" content="Test News Article" />
+
<meta property="og:description" content="Breaking news story" />
+
<meta property="og:image" content="https://example.com/news.jpg" />
+
<meta property="og:url" content="https://example.com/article/123" />
+
</head>
+
<body><p>Article content</p></body>
+
</html>
+
`
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(html))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
result, err := fetchOpenGraph(ctx, server.URL, 10*time.Second, "CovesBot/1.0")
+
require.NoError(t, err)
+
require.NotNil(t, result)
+
+
assert.Equal(t, "Test News Article", result.Title)
+
assert.Equal(t, "Breaking news story", result.Description)
+
assert.Equal(t, "https://example.com/news.jpg", result.ThumbnailURL)
+
assert.Equal(t, "article", result.Type)
+
assert.Equal(t, "opengraph", result.Provider)
+
}
+
+
func TestFetchOpenGraph_HTTPError(t *testing.T) {
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusNotFound)
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
result, err := fetchOpenGraph(ctx, server.URL, 10*time.Second, "CovesBot/1.0")
+
require.Error(t, err)
+
assert.Nil(t, result)
+
assert.Contains(t, err.Error(), "404")
+
}
+
+
func TestFetchOpenGraph_Timeout(t *testing.T) {
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
time.Sleep(2 * time.Second)
+
w.WriteHeader(http.StatusOK)
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
result, err := fetchOpenGraph(ctx, server.URL, 100*time.Millisecond, "CovesBot/1.0")
+
require.Error(t, err)
+
assert.Nil(t, result)
+
}
+
+
func TestFetchOpenGraph_NoMetadata(t *testing.T) {
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
html := `<html><head></head><body><p>No metadata</p></body></html>`
+
w.Header().Set("Content-Type", "text/html")
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(html))
+
}))
+
defer server.Close()
+
+
ctx := context.Background()
+
result, err := fetchOpenGraph(ctx, server.URL, 10*time.Second, "CovesBot/1.0")
+
require.NoError(t, err)
+
require.NotNil(t, result)
+
+
// Should still return a result with domain
+
assert.Equal(t, "article", result.Type)
+
assert.Equal(t, "opengraph", result.Provider)
+
assert.NotEmpty(t, result.Domain)
+
}
+
+
func TestIsOEmbedProvider(t *testing.T) {
+
tests := []struct {
+
url string
+
expected bool
+
}{
+
{"https://streamable.com/abc123", true},
+
{"https://www.youtube.com/watch?v=test", true},
+
{"https://youtu.be/test", true},
+
{"https://reddit.com/r/test/comments/123", true},
+
{"https://www.reddit.com/r/test/comments/123", true},
+
{"https://example.com/article", false},
+
{"https://news.ycombinator.com/item?id=123", false},
+
{"https://kite.kagi.com/search?q=test", false},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.url, func(t *testing.T) {
+
result := isOEmbedProvider(tt.url)
+
assert.Equal(t, tt.expected, result, "URL: %s", tt.url)
+
})
+
}
+
}
+
+
func TestIsSupported(t *testing.T) {
+
tests := []struct {
+
url string
+
expected bool
+
}{
+
{"https://example.com", true},
+
{"http://example.com", true},
+
{"https://news.site.com/article", true},
+
{"ftp://example.com", false},
+
{"not-a-url", false},
+
{"", false},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.url, func(t *testing.T) {
+
result := isSupported(tt.url)
+
assert.Equal(t, tt.expected, result, "URL: %s", tt.url)
+
})
+
}
+
}
+
+
func TestGetAttr(t *testing.T) {
+
html := `<meta property="og:title" content="Test Title" name="test" />`
+
doc, err := parseOpenGraph(html)
+
require.NoError(t, err)
+
+
// This is a simple test to verify the helper function works
+
// The actual usage is tested in the parseOpenGraph tests
+
assert.NotNil(t, doc)
+
}
+170
internal/core/unfurl/service.go
···
+
package unfurl
+
+
import (
+
"context"
+
"fmt"
+
"log"
+
"time"
+
)
+
+
// Service handles URL unfurling with caching
+
type Service interface {
+
UnfurlURL(ctx context.Context, url string) (*UnfurlResult, error)
+
IsSupported(url string) bool
+
}
+
+
type service struct {
+
repo Repository
+
circuitBreaker *circuitBreaker
+
userAgent string
+
timeout time.Duration
+
cacheTTL time.Duration
+
}
+
+
// NewService creates a new unfurl service
+
func NewService(repo Repository, opts ...ServiceOption) Service {
+
s := &service{
+
repo: repo,
+
timeout: 10 * time.Second,
+
userAgent: "CovesBot/1.0 (+https://coves.social)",
+
cacheTTL: 24 * time.Hour,
+
circuitBreaker: newCircuitBreaker(),
+
}
+
+
for _, opt := range opts {
+
opt(s)
+
}
+
+
return s
+
}
+
+
// ServiceOption configures the service
+
type ServiceOption func(*service)
+
+
// WithTimeout sets the HTTP timeout for oEmbed requests
+
func WithTimeout(timeout time.Duration) ServiceOption {
+
return func(s *service) {
+
s.timeout = timeout
+
}
+
}
+
+
// WithUserAgent sets the User-Agent header for oEmbed requests
+
func WithUserAgent(userAgent string) ServiceOption {
+
return func(s *service) {
+
s.userAgent = userAgent
+
}
+
}
+
+
// WithCacheTTL sets the cache TTL
+
func WithCacheTTL(ttl time.Duration) ServiceOption {
+
return func(s *service) {
+
s.cacheTTL = ttl
+
}
+
}
+
+
// IsSupported returns true if we can unfurl this URL
+
func (s *service) IsSupported(url string) bool {
+
return isSupported(url)
+
}
+
+
// UnfurlURL fetches metadata for a URL (with caching)
+
func (s *service) UnfurlURL(ctx context.Context, urlStr string) (*UnfurlResult, error) {
+
// 1. Check cache first
+
cached, err := s.repo.Get(ctx, urlStr)
+
if err == nil && cached != nil {
+
log.Printf("[UNFURL] Cache hit for %s (provider: %s)", urlStr, cached.Provider)
+
return cached, nil
+
}
+
+
// 2. Check if we support this URL
+
if !isSupported(urlStr) {
+
return nil, fmt.Errorf("unsupported URL: %s", urlStr)
+
}
+
+
var result *UnfurlResult
+
domain := extractDomain(urlStr)
+
+
// 3. Smart routing: Special handling for Kagi Kite (client-side rendered, no og:image tags)
+
if domain == "kite.kagi.com" {
+
provider := "kagi"
+
+
// Check circuit breaker
+
canAttempt, err := s.circuitBreaker.canAttempt(provider)
+
if !canAttempt {
+
log.Printf("[UNFURL] Skipping %s due to circuit breaker: %v", urlStr, err)
+
return nil, err
+
}
+
+
log.Printf("[UNFURL] Cache miss for %s, fetching via Kagi parser...", urlStr)
+
result, err = fetchKagiKite(ctx, urlStr, s.timeout, s.userAgent)
+
if err != nil {
+
s.circuitBreaker.recordFailure(provider, err)
+
return nil, err
+
}
+
+
s.circuitBreaker.recordSuccess(provider)
+
+
// Cache result
+
if cacheErr := s.repo.Set(ctx, urlStr, result, s.cacheTTL); cacheErr != nil {
+
log.Printf("[UNFURL] Warning: failed to cache result: %v", cacheErr)
+
}
+
return result, nil
+
}
+
+
// 4. Check if this is a known oEmbed provider
+
if isOEmbedProvider(urlStr) {
+
provider := domain // Use domain as provider name (e.g., "streamable.com", "youtube.com")
+
+
// Check circuit breaker
+
canAttempt, err := s.circuitBreaker.canAttempt(provider)
+
if !canAttempt {
+
log.Printf("[UNFURL] Skipping %s due to circuit breaker: %v", urlStr, err)
+
return nil, err
+
}
+
+
log.Printf("[UNFURL] Cache miss for %s, fetching from oEmbed...", urlStr)
+
+
// Fetch from oEmbed provider
+
oembed, err := fetchOEmbed(ctx, urlStr, s.timeout, s.userAgent)
+
if err != nil {
+
s.circuitBreaker.recordFailure(provider, err)
+
return nil, fmt.Errorf("failed to fetch oEmbed data: %w", err)
+
}
+
+
s.circuitBreaker.recordSuccess(provider)
+
+
// Convert to UnfurlResult
+
result = mapOEmbedToResult(oembed, urlStr)
+
} else {
+
provider := "opengraph"
+
+
// Check circuit breaker
+
canAttempt, err := s.circuitBreaker.canAttempt(provider)
+
if !canAttempt {
+
log.Printf("[UNFURL] Skipping %s due to circuit breaker: %v", urlStr, err)
+
return nil, err
+
}
+
+
log.Printf("[UNFURL] Cache miss for %s, fetching via OpenGraph...", urlStr)
+
+
// Fetch via OpenGraph
+
result, err = fetchOpenGraph(ctx, urlStr, s.timeout, s.userAgent)
+
if err != nil {
+
s.circuitBreaker.recordFailure(provider, err)
+
return nil, fmt.Errorf("failed to fetch OpenGraph data: %w", err)
+
}
+
+
s.circuitBreaker.recordSuccess(provider)
+
}
+
+
// 5. Store in cache
+
if cacheErr := s.repo.Set(ctx, urlStr, result, s.cacheTTL); cacheErr != nil {
+
// Log but don't fail - cache is best-effort
+
log.Printf("[UNFURL] Warning: Failed to cache result for %s: %v", urlStr, cacheErr)
+
}
+
+
log.Printf("[UNFURL] Successfully unfurled %s (provider: %s, type: %s)",
+
urlStr, result.Provider, result.Type)
+
+
return result, nil
+
}
+27
internal/core/unfurl/types.go
···
+
package unfurl
+
+
import "time"
+
+
// UnfurlResult represents the result of unfurling a URL
+
type UnfurlResult struct {
+
Type string `json:"type"` // "video", "article", "image", "website"
+
URI string `json:"uri"` // Original URL
+
Title string `json:"title"` // Page/video title
+
Description string `json:"description"` // Page/video description
+
ThumbnailURL string `json:"thumbnailUrl"` // Preview image URL
+
Provider string `json:"provider"` // "streamable", "youtube", "reddit"
+
Domain string `json:"domain"` // Domain of the URL
+
Width int `json:"width"` // Media width (if applicable)
+
Height int `json:"height"` // Media height (if applicable)
+
}
+
+
// CacheEntry represents a cached unfurl result with metadata
+
type CacheEntry struct {
+
FetchedAt time.Time `db:"fetched_at"`
+
ExpiresAt time.Time `db:"expires_at"`
+
CreatedAt time.Time `db:"created_at"`
+
ThumbnailURL *string `db:"thumbnail_url"`
+
URL string `db:"url"`
+
Provider string `db:"provider"`
+
Metadata UnfurlResult `db:"metadata"`
+
}
+14
internal/core/unfurl/errors.go
···
+
package unfurl
+
+
import "errors"
+
+
var (
+
// ErrNotFound is returned when an unfurl cache entry is not found or has expired
+
ErrNotFound = errors.New("unfurl cache entry not found or expired")
+
+
// ErrInvalidURL is returned when the provided URL is invalid
+
ErrInvalidURL = errors.New("invalid URL")
+
+
// ErrInvalidTTL is returned when the provided TTL is invalid (e.g., negative or zero)
+
ErrInvalidTTL = errors.New("invalid TTL: must be positive")
+
)
+19
internal/core/unfurl/interfaces.go
···
+
package unfurl
+
+
import (
+
"context"
+
"time"
+
)
+
+
// Repository defines the interface for unfurl cache persistence
+
type Repository interface {
+
// Get retrieves a cached unfurl result for the given URL.
+
// Returns nil, nil if not found or expired (not an error condition).
+
// Returns error only on database failures.
+
Get(ctx context.Context, url string) (*UnfurlResult, error)
+
+
// Set stores an unfurl result in the cache with the specified TTL.
+
// If an entry already exists for the URL, it will be updated.
+
// The expires_at is calculated as NOW() + ttl.
+
Set(ctx context.Context, url string, result *UnfurlResult, ttl time.Duration) error
+
}
+117
internal/core/unfurl/repository.go
···
+
package unfurl
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"time"
+
)
+
+
type postgresUnfurlRepo struct {
+
db *sql.DB
+
}
+
+
// NewRepository creates a new PostgreSQL unfurl cache repository
+
func NewRepository(db *sql.DB) Repository {
+
return &postgresUnfurlRepo{db: db}
+
}
+
+
// Get retrieves a cached unfurl result for the given URL.
+
// Returns nil, nil if not found or expired (not an error condition).
+
// Returns error only on database failures.
+
func (r *postgresUnfurlRepo) Get(ctx context.Context, url string) (*UnfurlResult, error) {
+
query := `
+
SELECT metadata, thumbnail_url, provider
+
FROM unfurl_cache
+
WHERE url = $1 AND expires_at > NOW()
+
`
+
+
var metadataJSON []byte
+
var thumbnailURL sql.NullString
+
var provider string
+
+
err := r.db.QueryRowContext(ctx, query, url).Scan(&metadataJSON, &thumbnailURL, &provider)
+
if err == sql.ErrNoRows {
+
// Not found or expired is not an error
+
return nil, nil
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get unfurl cache entry: %w", err)
+
}
+
+
// Unmarshal metadata JSONB to UnfurlResult
+
var result UnfurlResult
+
if err := json.Unmarshal(metadataJSON, &result); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
+
}
+
+
// Ensure provider and thumbnailURL are set (may not be in metadata JSON)
+
result.Provider = provider
+
if thumbnailURL.Valid {
+
result.ThumbnailURL = thumbnailURL.String
+
}
+
+
return &result, nil
+
}
+
+
// Set stores an unfurl result in the cache with the specified TTL.
+
// If an entry already exists for the URL, it will be updated.
+
// The expires_at is calculated as NOW() + ttl.
+
func (r *postgresUnfurlRepo) Set(ctx context.Context, url string, result *UnfurlResult, ttl time.Duration) error {
+
// Marshal UnfurlResult to JSON for metadata column
+
metadataJSON, err := json.Marshal(result)
+
if err != nil {
+
return fmt.Errorf("failed to marshal metadata: %w", err)
+
}
+
+
// Store thumbnail_url separately for potential queries
+
var thumbnailURL sql.NullString
+
if result.ThumbnailURL != "" {
+
thumbnailURL.String = result.ThumbnailURL
+
thumbnailURL.Valid = true
+
}
+
+
// Convert Go duration to PostgreSQL interval string
+
// e.g., "1 hour", "24 hours", "7 days"
+
intervalStr := formatInterval(ttl)
+
+
query := `
+
INSERT INTO unfurl_cache (url, provider, metadata, thumbnail_url, expires_at)
+
VALUES ($1, $2, $3, $4, NOW() + $5::interval)
+
ON CONFLICT (url) DO UPDATE
+
SET provider = EXCLUDED.provider,
+
metadata = EXCLUDED.metadata,
+
thumbnail_url = EXCLUDED.thumbnail_url,
+
expires_at = EXCLUDED.expires_at,
+
fetched_at = NOW()
+
`
+
+
_, err = r.db.ExecContext(ctx, query, url, result.Provider, metadataJSON, thumbnailURL, intervalStr)
+
if err != nil {
+
return fmt.Errorf("failed to insert/update unfurl 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)
+
}
+
}
+23
internal/db/migrations/017_create_unfurl_cache.sql
···
+
-- +goose Up
+
CREATE TABLE unfurl_cache (
+
url TEXT PRIMARY KEY,
+
provider TEXT NOT NULL,
+
metadata JSONB NOT NULL,
+
thumbnail_url TEXT,
+
fetched_at TIMESTAMP NOT NULL DEFAULT NOW(),
+
expires_at TIMESTAMP NOT NULL,
+
created_at TIMESTAMP NOT NULL DEFAULT NOW()
+
);
+
+
CREATE INDEX idx_unfurl_cache_expires ON unfurl_cache(expires_at);
+
+
COMMENT ON TABLE unfurl_cache IS 'Cache for oEmbed/URL unfurl results to reduce external API calls';
+
COMMENT ON COLUMN unfurl_cache.url IS 'The URL that was unfurled (primary key)';
+
COMMENT ON COLUMN unfurl_cache.provider IS 'Provider name (streamable, youtube, reddit, etc.)';
+
COMMENT ON COLUMN unfurl_cache.metadata IS 'Full unfurl result as JSON (title, description, type, etc.)';
+
COMMENT ON COLUMN unfurl_cache.thumbnail_url IS 'URL of the thumbnail image';
+
COMMENT ON COLUMN unfurl_cache.expires_at IS 'When this cache entry should be refetched (TTL-based)';
+
+
-- +goose Down
+
DROP INDEX IF EXISTS idx_unfurl_cache_expires;
+
DROP TABLE IF EXISTS unfurl_cache;
+9
internal/core/blobs/types.go
···
+
package blobs
+
+
// BlobRef represents a blob reference for atproto records
+
type BlobRef struct {
+
Type string `json:"$type"`
+
Ref map[string]string `json:"ref"`
+
MimeType string `json:"mimeType"`
+
Size int `json:"size"`
+
}
+81
internal/core/posts/blob_transform.go
···
+
package posts
+
+
import (
+
"fmt"
+
)
+
+
// TransformBlobRefsToURLs transforms all blob references in a PostView to PDS URLs
+
// This modifies the Embed field in-place, converting blob refs to direct URLs
+
// The transformation only affects external embeds with thumbnail blobs
+
func TransformBlobRefsToURLs(postView *PostView) {
+
if postView == nil || postView.Embed == nil {
+
return
+
}
+
+
// Get community PDS URL from post view
+
if postView.Community == nil || postView.Community.PDSURL == "" {
+
return // Cannot transform without PDS URL
+
}
+
+
communityDID := postView.Community.DID
+
pdsURL := postView.Community.PDSURL
+
+
// Check if embed is a map (should be for external embeds)
+
embedMap, ok := postView.Embed.(map[string]interface{})
+
if !ok {
+
return
+
}
+
+
// Check embed type
+
embedType, ok := embedMap["$type"].(string)
+
if !ok {
+
return
+
}
+
+
// Only transform external embeds
+
if embedType == "social.coves.embed.external" {
+
if external, ok := embedMap["external"].(map[string]interface{}); ok {
+
transformThumbToURL(external, communityDID, pdsURL)
+
}
+
}
+
}
+
+
// transformThumbToURL converts a thumb blob ref to a PDS URL
+
// This modifies the external map in-place
+
func transformThumbToURL(external map[string]interface{}, communityDID, pdsURL string) {
+
// Check if thumb exists
+
thumb, ok := external["thumb"]
+
if !ok {
+
return
+
}
+
+
// If thumb is already a string (URL), don't transform
+
if _, isString := thumb.(string); isString {
+
return
+
}
+
+
// Try to parse as blob ref
+
thumbMap, ok := thumb.(map[string]interface{})
+
if !ok {
+
return
+
}
+
+
// Extract CID from blob ref
+
ref, ok := thumbMap["ref"].(map[string]interface{})
+
if !ok {
+
return
+
}
+
+
cid, ok := ref["$link"].(string)
+
if !ok || cid == "" {
+
return
+
}
+
+
// Transform to PDS blob endpoint URL
+
// Format: {pds_url}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={cid}
+
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
+
pdsURL, communityDID, cid)
+
+
// Replace blob ref with URL string
+
external["thumb"] = blobURL
+
}
+312
internal/core/posts/blob_transform_test.go
···
+
package posts
+
+
import (
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
func TestTransformBlobRefsToURLs(t *testing.T) {
+
t.Run("transforms external embed thumb from blob to URL", func(t *testing.T) {
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "http://localhost:3001",
+
},
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.external",
+
"external": map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": map[string]interface{}{
+
"$type": "blob",
+
"ref": map[string]interface{}{
+
"$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
},
+
"mimeType": "image/jpeg",
+
"size": 52813,
+
},
+
},
+
},
+
}
+
+
TransformBlobRefsToURLs(post)
+
+
// Verify embed is still a map
+
embedMap, ok := post.Embed.(map[string]interface{})
+
require.True(t, ok, "embed should still be a map")
+
+
// Verify external is still a map
+
external, ok := embedMap["external"].(map[string]interface{})
+
require.True(t, ok, "external should be a map")
+
+
// Verify thumb is now a URL string
+
thumbURL, ok := external["thumb"].(string)
+
require.True(t, ok, "thumb should be a string URL")
+
assert.Equal(t,
+
"http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:testcommunity&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
thumbURL)
+
})
+
+
t.Run("handles missing thumb gracefully", func(t *testing.T) {
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "http://localhost:3001",
+
},
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.external",
+
"external": map[string]interface{}{
+
"uri": "https://example.com",
+
// No thumb field
+
},
+
},
+
}
+
+
// Should not panic
+
TransformBlobRefsToURLs(post)
+
+
// Verify external is unchanged
+
embedMap := post.Embed.(map[string]interface{})
+
external := embedMap["external"].(map[string]interface{})
+
_, hasThumb := external["thumb"]
+
assert.False(t, hasThumb, "thumb should not be added")
+
})
+
+
t.Run("handles already-transformed URL thumb", func(t *testing.T) {
+
expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafytest"
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "http://localhost:3001",
+
},
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.external",
+
"external": map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": expectedURL, // Already a URL string
+
},
+
},
+
}
+
+
// Should not error or change the URL
+
TransformBlobRefsToURLs(post)
+
+
// Verify thumb is unchanged
+
embedMap := post.Embed.(map[string]interface{})
+
external := embedMap["external"].(map[string]interface{})
+
thumbURL, ok := external["thumb"].(string)
+
require.True(t, ok, "thumb should still be a string")
+
assert.Equal(t, expectedURL, thumbURL, "thumb URL should be unchanged")
+
})
+
+
t.Run("handles missing embed", func(t *testing.T) {
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "http://localhost:3001",
+
},
+
Embed: nil,
+
}
+
+
// Should not panic
+
TransformBlobRefsToURLs(post)
+
+
// Verify embed is still nil
+
assert.Nil(t, post.Embed, "embed should remain nil")
+
})
+
+
t.Run("handles nil post", func(t *testing.T) {
+
// Should not panic
+
TransformBlobRefsToURLs(nil)
+
})
+
+
t.Run("handles missing community", func(t *testing.T) {
+
post := &PostView{
+
Community: nil,
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.external",
+
"external": map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": map[string]interface{}{
+
"$type": "blob",
+
"ref": map[string]interface{}{
+
"$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
},
+
},
+
},
+
},
+
}
+
+
// Should not panic or transform
+
TransformBlobRefsToURLs(post)
+
+
// Verify thumb is unchanged (still a blob)
+
embedMap := post.Embed.(map[string]interface{})
+
external := embedMap["external"].(map[string]interface{})
+
thumb, ok := external["thumb"].(map[string]interface{})
+
require.True(t, ok, "thumb should still be a map (blob ref)")
+
assert.Equal(t, "blob", thumb["$type"], "blob type should be unchanged")
+
})
+
+
t.Run("handles missing PDS URL", func(t *testing.T) {
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "", // Empty PDS URL
+
},
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.external",
+
"external": map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": map[string]interface{}{
+
"$type": "blob",
+
"ref": map[string]interface{}{
+
"$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
},
+
},
+
},
+
},
+
}
+
+
// Should not panic or transform
+
TransformBlobRefsToURLs(post)
+
+
// Verify thumb is unchanged (still a blob)
+
embedMap := post.Embed.(map[string]interface{})
+
external := embedMap["external"].(map[string]interface{})
+
thumb, ok := external["thumb"].(map[string]interface{})
+
require.True(t, ok, "thumb should still be a map (blob ref)")
+
assert.Equal(t, "blob", thumb["$type"], "blob type should be unchanged")
+
})
+
+
t.Run("handles malformed blob ref gracefully", func(t *testing.T) {
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "http://localhost:3001",
+
},
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.external",
+
"external": map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": map[string]interface{}{
+
"$type": "blob",
+
"ref": "invalid-ref-format", // Should be a map with $link
+
},
+
},
+
},
+
}
+
+
// Should not panic
+
TransformBlobRefsToURLs(post)
+
+
// Verify thumb is unchanged (malformed blob)
+
embedMap := post.Embed.(map[string]interface{})
+
external := embedMap["external"].(map[string]interface{})
+
thumb, ok := external["thumb"].(map[string]interface{})
+
require.True(t, ok, "thumb should still be a map")
+
assert.Equal(t, "invalid-ref-format", thumb["ref"], "malformed ref should be unchanged")
+
})
+
+
t.Run("ignores non-external embed types", func(t *testing.T) {
+
post := &PostView{
+
Community: &CommunityRef{
+
DID: "did:plc:testcommunity",
+
PDSURL: "http://localhost:3001",
+
},
+
Embed: map[string]interface{}{
+
"$type": "social.coves.embed.images",
+
"images": []interface{}{
+
map[string]interface{}{
+
"image": map[string]interface{}{
+
"$type": "blob",
+
"ref": map[string]interface{}{
+
"$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
},
+
},
+
},
+
},
+
},
+
}
+
+
// Should not transform non-external embeds
+
TransformBlobRefsToURLs(post)
+
+
// Verify images embed is unchanged
+
embedMap := post.Embed.(map[string]interface{})
+
images := embedMap["images"].([]interface{})
+
imageObj := images[0].(map[string]interface{})
+
imageBlob := imageObj["image"].(map[string]interface{})
+
assert.Equal(t, "blob", imageBlob["$type"], "image blob should be unchanged")
+
})
+
}
+
+
func TestTransformThumbToURL(t *testing.T) {
+
t.Run("transforms valid blob ref to URL", func(t *testing.T) {
+
external := map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": map[string]interface{}{
+
"$type": "blob",
+
"ref": map[string]interface{}{
+
"$link": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
},
+
"mimeType": "image/jpeg",
+
"size": 52813,
+
},
+
}
+
+
transformThumbToURL(external, "did:plc:test", "http://localhost:3001")
+
+
thumbURL, ok := external["thumb"].(string)
+
require.True(t, ok, "thumb should be a string URL")
+
assert.Equal(t,
+
"http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",
+
thumbURL)
+
})
+
+
t.Run("does not transform if thumb is already string", func(t *testing.T) {
+
expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafytest"
+
external := map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": expectedURL,
+
}
+
+
transformThumbToURL(external, "did:plc:test", "http://localhost:3001")
+
+
thumbURL, ok := external["thumb"].(string)
+
require.True(t, ok, "thumb should still be a string")
+
assert.Equal(t, expectedURL, thumbURL, "thumb should be unchanged")
+
})
+
+
t.Run("does not transform if thumb is missing", func(t *testing.T) {
+
external := map[string]interface{}{
+
"uri": "https://example.com",
+
}
+
+
transformThumbToURL(external, "did:plc:test", "http://localhost:3001")
+
+
_, hasThumb := external["thumb"]
+
assert.False(t, hasThumb, "thumb should not be added")
+
})
+
+
t.Run("does not transform if CID is empty", func(t *testing.T) {
+
external := map[string]interface{}{
+
"uri": "https://example.com",
+
"thumb": map[string]interface{}{
+
"$type": "blob",
+
"ref": map[string]interface{}{
+
"$link": "", // Empty CID
+
},
+
},
+
}
+
+
transformThumbToURL(external, "did:plc:test", "http://localhost:3001")
+
+
// Verify thumb is unchanged
+
thumb, ok := external["thumb"].(map[string]interface{})
+
require.True(t, ok, "thumb should still be a map")
+
ref := thumb["ref"].(map[string]interface{})
+
assert.Equal(t, "", ref["$link"], "empty CID should be unchanged")
+
})
+
}
+4 -3
aggregators/kagi-news/src/main.py
···
embed = self.coves_client.create_external_embed(
uri=story.link,
title=story.title,
-
description=story.summary[:200] if len(story.summary) > 200 else story.summary,
-
thumb=story.image_url
+
description=story.summary[:200] if len(story.summary) > 200 else story.summary
)
# Post to community
+
# Pass thumbnail URL from RSS feed at top level for trusted aggregator upload
try:
post_uri = self.coves_client.create_post(
community_handle=feed_config.community_handle,
title=story.title,
content=rich_text["content"],
facets=rich_text["facets"],
-
embed=embed
+
embed=embed,
+
thumbnail_url=story.image_url # From RSS feed - server will validate and upload
)
# Mark as posted (only if successful)
+134
scripts/post_streamable.py
···
+
#!/usr/bin/env python3
+
"""
+
Quick script to post a Streamable video to test-usnews community.
+
Uses the kagi-news CovesClient infrastructure.
+
"""
+
+
import sys
+
import os
+
+
# Add kagi-news src to path to use CovesClient
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../aggregators/kagi-news'))
+
+
from src.coves_client import CovesClient
+
+
def main():
+
# Configuration
+
COVES_API_URL = "http://localhost:8081"
+
PDS_URL = "http://localhost:3001"
+
+
# Use PDS instance credentials (from .env.dev)
+
HANDLE = "testuser123.local.coves.dev"
+
PASSWORD = "test-password-123"
+
+
# Post details
+
COMMUNITY_HANDLE = "test-usnews.community.coves.social"
+
+
# Post 1: Streamable video
+
STREAMABLE_URL = "https://streamable.com/7kpdft"
+
STREAMABLE_TITLE = "NBACentral - \"Your son don't wanna be here, we know it's your last weekend. Enjoy ..."
+
+
# Post 2: Reddit highlight
+
REDDIT_URL = "https://www.reddit.com/r/nba/comments/1orfsgm/highlight_giannis_antetokounmpo_41_pts_15_reb_9/"
+
REDDIT_TITLE = "[Highlight] Giannis Antetokounmpo (41 PTS, 15 REB, 9 AST) tallies his 56th career regular season game of 40+ points, passing Kareem Abdul-Jabbar for the most such games in franchise history. Milwaukee defeats Chicago 126-110 to win their NBA Cup opener."
+
+
# Initialize client
+
print(f"Initializing Coves client...")
+
print(f" API URL: {COVES_API_URL}")
+
print(f" PDS URL: {PDS_URL}")
+
print(f" Handle: {HANDLE}")
+
+
client = CovesClient(
+
api_url=COVES_API_URL,
+
handle=HANDLE,
+
password=PASSWORD,
+
pds_url=PDS_URL
+
)
+
+
# Authenticate
+
print("\nAuthenticating...")
+
try:
+
client.authenticate()
+
print(f"โœ“ Authenticated as {client.did}")
+
except Exception as e:
+
print(f"โœ— Authentication failed: {e}")
+
return 1
+
+
# Post 1: Streamable video
+
print("\n" + "="*60)
+
print("POST 1: STREAMABLE VIDEO")
+
print("="*60)
+
+
print("\nCreating minimal external embed (URI only)...")
+
streamable_embed = {
+
"$type": "social.coves.embed.external",
+
"external": {
+
"uri": STREAMABLE_URL
+
}
+
}
+
print(f"โœ“ Embed created with URI only (unfurl service should enrich)")
+
+
print(f"\nPosting to {COMMUNITY_HANDLE}...")
+
print(f" Title: {STREAMABLE_TITLE}")
+
print(f" Video: {STREAMABLE_URL}")
+
+
try:
+
post_uri = client.create_post(
+
community_handle=COMMUNITY_HANDLE,
+
title=STREAMABLE_TITLE,
+
content="",
+
facets=[],
+
embed=streamable_embed
+
)
+
+
print(f"\nโœ“ Streamable post created successfully!")
+
print(f" URI: {post_uri}")
+
+
except Exception as e:
+
print(f"\nโœ— Streamable post creation failed: {e}")
+
import traceback
+
traceback.print_exc()
+
return 1
+
+
# Post 2: Reddit highlight
+
print("\n" + "="*60)
+
print("POST 2: REDDIT HIGHLIGHT")
+
print("="*60)
+
+
print("\nCreating minimal external embed (URI only)...")
+
reddit_embed = {
+
"$type": "social.coves.embed.external",
+
"external": {
+
"uri": REDDIT_URL
+
}
+
}
+
print(f"โœ“ Embed created with URI only (unfurl service should enrich)")
+
+
print(f"\nPosting to {COMMUNITY_HANDLE}...")
+
print(f" Title: {REDDIT_TITLE}")
+
print(f" URL: {REDDIT_URL}")
+
+
try:
+
post_uri = client.create_post(
+
community_handle=COMMUNITY_HANDLE,
+
title=REDDIT_TITLE,
+
content="",
+
facets=[],
+
embed=reddit_embed
+
)
+
+
print(f"\nโœ“ Reddit post created successfully!")
+
print(f" URI: {post_uri}")
+
print(f"\n" + "="*60)
+
print("Both posts created! Check them out at !test-usnews")
+
print("="*60)
+
return 0
+
+
except Exception as e:
+
print(f"\nโœ— Reddit post creation failed: {e}")
+
import traceback
+
traceback.print_exc()
+
return 1
+
+
if __name__ == "__main__":
+
sys.exit(main())
-2
docker-compose.dev.yml
···
-
version: '3.8'
-
# Coves Local Development Stack
# All-in-one setup: PDS + PostgreSQL + optional Relay
#
+1 -1
internal/atproto/lexicon/social/coves/feed/comment.json internal/atproto/lexicon/social/coves/community/comment.json
···
{
"lexicon": 1,
-
"id": "social.coves.feed.comment",
+
"id": "social.coves.community.comment",
"defs": {
"main": {
"type": "record",
+34
internal/db/migrations/018_migrate_comment_namespace.sql
···
+
-- +goose Up
+
-- Migration: Update comment URIs from social.coves.feed.comment to social.coves.community.comment
+
-- This updates the namespace for all comment records in the database.
+
-- Since we're pre-production, we're only updating the comments table (not votes).
+
+
-- Update main comment URIs
+
UPDATE comments
+
SET uri = REPLACE(uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')
+
WHERE uri LIKE '%/social.coves.feed.comment/%';
+
+
-- Update root references (when root is a comment, not a post)
+
UPDATE comments
+
SET root_uri = REPLACE(root_uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')
+
WHERE root_uri LIKE '%/social.coves.feed.comment/%';
+
+
-- Update parent references (when parent is a comment)
+
UPDATE comments
+
SET parent_uri = REPLACE(parent_uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')
+
WHERE parent_uri LIKE '%/social.coves.feed.comment/%';
+
+
-- +goose Down
+
-- Rollback: Revert comment URIs from social.coves.community.comment to social.coves.feed.comment
+
+
UPDATE comments
+
SET uri = REPLACE(uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')
+
WHERE uri LIKE '%/social.coves.community.comment/%';
+
+
UPDATE comments
+
SET root_uri = REPLACE(root_uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')
+
WHERE root_uri LIKE '%/social.coves.community.comment/%';
+
+
UPDATE comments
+
SET parent_uri = REPLACE(parent_uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')
+
WHERE parent_uri LIKE '%/social.coves.community.comment/%';
+1 -1
internal/atproto/utils/record_utils.go
···
// Format: at://did/collection/rkey -> collection
//
// Returns:
-
// - Collection name (e.g., "social.coves.feed.comment") if URI is well-formed
+
// - Collection name (e.g., "social.coves.community.comment") if URI is well-formed
// - Empty string if URI is malformed or doesn't contain a collection segment
//
// Note: Empty string indicates "unknown/unsupported collection" and should be
+2 -2
internal/core/comments/view_models.go
···
)
// CommentView represents the full view of a comment with all metadata
-
// Matches social.coves.feed.getComments#commentView lexicon
+
// Matches social.coves.community.comment.getComments#commentView lexicon
// Used in thread views and get endpoints
type CommentView struct {
Embed interface{} `json:"embed,omitempty"`
···
}
// ThreadViewComment represents a comment with its nested replies
-
// Matches social.coves.feed.getComments#threadViewComment lexicon
+
// Matches social.coves.community.comment.getComments#threadViewComment lexicon
// Supports recursive threading for comment trees
type ThreadViewComment struct {
Comment *CommentView `json:"comment"`
+1 -1
internal/validation/lexicon.go
···
// ValidateComment validates a comment record
func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error {
-
return v.ValidateRecord(comment, "social.coves.feed.comment")
+
return v.ValidateRecord(comment, "social.coves.community.comment")
}
// ValidateVote validates a vote record
+18 -15
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
···
```json
{
"lexicon": 1,
-
"id": "social.coves.feed.comment",
+
"id": "social.coves.community.comment",
"defs": {
"main": {
"type": "record",
···
```sql
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
-
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.feed.comment/rkey)
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.community.comment/rkey)
cid TEXT NOT NULL, -- Content ID
rkey TEXT NOT NULL, -- Record key (TID)
commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field)
···
return nil
}
-
if event.Commit.Collection == "social.coves.feed.comment" {
+
if event.Commit.Collection == "social.coves.community.comment" {
switch event.Commit.Operation {
case "create":
return c.createComment(ctx, event.Did, commit)
···
- Auto-reconnect on errors (5-second retry)
- Ping/pong keepalive (30-second ping, 60-second read deadline)
- Graceful shutdown via context cancellation
-
- Subscribes to: `wantedCollections=social.coves.feed.comment`
+
- Subscribes to: `wantedCollections=social.coves.community.comment`
---
···
// Start Jetstream consumer for comments
commentJetstreamURL := os.Getenv("COMMENT_JETSTREAM_URL")
if commentJetstreamURL == "" {
-
commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
+
commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
}
commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
···
}()
log.Printf("Started Jetstream comment consumer: %s", commentJetstreamURL)
-
log.Println(" - Indexing: social.coves.feed.comment CREATE/UPDATE/DELETE operations")
+
log.Println(" - Indexing: social.coves.community.comment CREATE/UPDATE/DELETE operations")
log.Println(" - Updating: Post comment counts and comment reply counts atomically")
```
···
| Aspect | Votes | Comments |
|--------|-------|----------|
| **Location** | User repositories | User repositories |
-
| **Lexicon** | `social.coves.feed.vote` | `social.coves.feed.comment` |
+
| **Lexicon** | `social.coves.feed.vote` | `social.coves.community.comment` |
| **Operations** | CREATE, DELETE | CREATE, UPDATE, DELETE |
| **Mutability** | Immutable | Editable |
| **Foreign Keys** | None (out-of-order indexing) | None (out-of-order indexing) |
···
---
-
### ๐Ÿ“‹ Phase 4: Namespace Migration (Separate Task)
+
### โœ… Phase 4: Namespace Migration (COMPLETED)
+
+
**Completed:** 2025-11-16
**Scope:**
-
- Migrate existing `social.coves.feed.comment` records to `social.coves.community.comment`
-
- Update all AT-URIs in database
-
- Update Jetstream consumer collection filter
-
- Migration script with rollback capability
-
- Zero-downtime deployment strategy
+
- โœ… Migrated `social.coves.community.comment` namespace to `social.coves.community.comment`
+
- โœ… Updated lexicon definitions (record and query schemas)
+
- โœ… Updated Jetstream consumer collection filter
+
- โœ… Updated all code references (consumer, service, validation layers)
+
- โœ… Updated integration tests and test data generation scripts
+
- โœ… Created database migration (018_migrate_comment_namespace.sql)
-
**Note:** Currently out of scope - will be tackled separately when needed.
+
**Note:** Since we're pre-production, no historical data migration was needed. Migration script updates URIs in comments table (uri, root_uri, parent_uri columns).
---
···
### Environment Variables
```bash
# Jetstream URL (optional, defaults to localhost:6008)
-
export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
+
export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
# Database URL
export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+3 -2
internal/core/unfurl/providers.go
···
// normalizeURL converts protocol-relative URLs to HTTPS
// Examples:
-
// "//example.com/image.jpg" -> "https://example.com/image.jpg"
-
// "https://example.com/image.jpg" -> "https://example.com/image.jpg" (unchanged)
+
//
+
// "//example.com/image.jpg" -> "https://example.com/image.jpg"
+
// "https://example.com/image.jpg" -> "https://example.com/image.jpg" (unchanged)
func normalizeURL(urlStr string) string {
if strings.HasPrefix(urlStr, "//") {
return "https:" + urlStr
+785
docs/federation-prd.md
···
+
# Federation PRD: Cross-Instance Posting (Beta)
+
+
**Status:** Planning - Beta
+
**Target:** Beta Release
+
**Owner:** TBD
+
**Last Updated:** 2025-11-16
+
+
---
+
+
## Overview
+
+
Enable Lemmy-style federation where users on any Coves instance can post to communities hosted on other instances, while maintaining community ownership and moderation control.
+
+
### Problem Statement
+
+
**Current (Alpha):**
+
- Posts to communities require community credentials
+
- Users can only post to communities on their home instance
+
- No true federation across instances
+
+
**Desired (Beta):**
+
- User A@coves.social can post to !gaming@covesinstance.com
+
- Communities maintain full moderation control
+
- Content lives in community repositories (not user repos)
+
- Seamless UX - users don't think about federation
+
+
---
+
+
## Goals
+
+
### Primary Goals
+
1. **Enable cross-instance posting** - Users can post to any community on any federated instance
+
2. **Preserve community ownership** - Posts live in community repos, not user repos
+
3. **atProto-native implementation** - Use `com.atproto.server.getServiceAuth` pattern
+
4. **Maintain security** - No compromise on auth, validation, or moderation
+
+
### Non-Goals (Future Versions)
+
- Automatic instance discovery (Beta: manual allowlist)
+
- Cross-instance moderation delegation
+
- Content mirroring/replication
+
- User migration between instances
+
+
---
+
+
## Technical Approach
+
+
### Architecture: atProto Service Auth
+
+
Use atProto's native service authentication delegation pattern:
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ User A โ”‚ โ”‚ coves.social โ”‚ โ”‚ covesinstanceโ”‚
+
โ”‚ @coves.soc โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ AppView โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ .com PDS โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ (1) โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ (2) โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
JWT auth Request Service Auth Validate
+
โ”‚ โ”‚
+
โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚ (3) Scoped Token
+
โ”‚
+
โ–ผ
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ covesinstance โ”‚
+
โ”‚ .com PDS โ”‚
+
โ”‚ Write Post โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚
+
โ–ผ
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ Firehose โ”‚
+
โ”‚ (broadcasts) โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ–ผ โ–ผ
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ coves.social โ”‚ โ”‚covesinstance โ”‚
+
โ”‚ AppView โ”‚ โ”‚ .com AppViewโ”‚
+
โ”‚ (indexes) โ”‚ โ”‚ (indexes) โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
```
+
+
### Flow Breakdown
+
+
**Step 1: User Authentication (Unchanged)**
+
- User authenticates with their home instance (coves.social)
+
- Receives JWT token for API requests
+
+
**Step 2: Service Auth Request (New)**
+
- When posting to remote community, AppView requests service auth token
+
- Endpoint: `POST {remote-pds}/xrpc/com.atproto.server.getServiceAuth`
+
- Payload:
+
```json
+
{
+
"aud": "did:plc:community123", // Community DID
+
"exp": 1234567890, // Token expiration
+
"lxm": "social.coves.community.post.create" // Authorized method
+
}
+
```
+
+
**Step 3: Service Auth Validation (New - PDS Side)**
+
- Remote PDS validates request:
+
- Is requesting service trusted? (instance allowlist)
+
- Is user banned from community?
+
- Does community allow remote posts?
+
- Rate limiting checks
+
- Returns scoped token valid for specific community + operation
+
+
**Step 4: Post Creation (Modified)**
+
- AppView uses service auth token to write to remote PDS
+
- Same `com.atproto.repo.createRecord` endpoint as current implementation
+
- Post record written to community's repository
+
+
**Step 5: Indexing (Unchanged)**
+
- PDS broadcasts to firehose
+
- All AppViews index via Jetstream consumers
+
+
---
+
+
## Implementation Details
+
+
### Phase 1: Service Detection (Local vs Remote)
+
+
**File:** `internal/core/posts/service.go`
+
+
```go
+
func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {
+
// ... existing validation ...
+
+
community, err := s.communityService.GetByDID(ctx, communityDID)
+
if err != nil {
+
return nil, err
+
}
+
+
// NEW: Route based on community location
+
if s.isLocalCommunity(community) {
+
return s.createLocalPost(ctx, community, req)
+
}
+
return s.createFederatedPost(ctx, community, req)
+
}
+
+
func (s *postService) isLocalCommunity(community *communities.Community) bool {
+
localPDSHost := extractHost(s.pdsURL)
+
communityPDSHost := extractHost(community.PDSURL)
+
return localPDSHost == communityPDSHost
+
}
+
```
+
+
### Phase 2: Service Auth Client
+
+
**New File:** `internal/atproto/service_auth/client.go`
+
+
```go
+
type ServiceAuthClient interface {
+
// RequestServiceAuth obtains a scoped token for writing to remote community
+
RequestServiceAuth(ctx context.Context, opts ServiceAuthOptions) (*ServiceAuthToken, error)
+
}
+
+
type ServiceAuthOptions struct {
+
RemotePDSURL string // Remote PDS endpoint
+
CommunityDID string // Target community DID
+
UserDID string // Author DID (for validation)
+
Method string // "social.coves.community.post.create"
+
ExpiresIn int // Token lifetime (seconds)
+
}
+
+
type ServiceAuthToken struct {
+
Token string // JWT token for auth
+
ExpiresAt time.Time // When token expires
+
}
+
+
func (c *serviceAuthClient) RequestServiceAuth(ctx context.Context, opts ServiceAuthOptions) (*ServiceAuthToken, error) {
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.server.getServiceAuth", opts.RemotePDSURL)
+
+
payload := map[string]interface{}{
+
"aud": opts.CommunityDID,
+
"exp": time.Now().Add(time.Duration(opts.ExpiresIn) * time.Second).Unix(),
+
"lxm": opts.Method,
+
}
+
+
// Sign request with our instance DID credentials
+
signedReq, err := c.signRequest(payload)
+
if err != nil {
+
return nil, fmt.Errorf("failed to sign service auth request: %w", err)
+
}
+
+
resp, err := c.httpClient.Post(endpoint, signedReq)
+
if err != nil {
+
return nil, fmt.Errorf("service auth request failed: %w", err)
+
}
+
+
return parseServiceAuthResponse(resp)
+
}
+
```
+
+
### Phase 3: Federated Post Creation
+
+
**File:** `internal/core/posts/service.go`
+
+
```go
+
func (s *postService) createFederatedPost(ctx context.Context, community *communities.Community, req CreatePostRequest) (*CreatePostResponse, error) {
+
// 1. Request service auth token from remote PDS
+
token, err := s.serviceAuthClient.RequestServiceAuth(ctx, service_auth.ServiceAuthOptions{
+
RemotePDSURL: community.PDSURL,
+
CommunityDID: community.DID,
+
UserDID: req.AuthorDID,
+
Method: "social.coves.community.post.create",
+
ExpiresIn: 300, // 5 minutes
+
})
+
if err != nil {
+
// Handle specific errors
+
if isUnauthorized(err) {
+
return nil, ErrNotAuthorizedRemote
+
}
+
if isBanned(err) {
+
return nil, ErrBannedRemote
+
}
+
return nil, fmt.Errorf("failed to obtain service auth: %w", err)
+
}
+
+
// 2. Build post record (same as local)
+
postRecord := PostRecord{
+
Type: "social.coves.community.post",
+
Community: community.DID,
+
Author: req.AuthorDID,
+
Title: req.Title,
+
Content: req.Content,
+
// ... other fields ...
+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
+
}
+
+
// 3. Write to remote PDS using service auth token
+
uri, cid, err := s.createPostOnRemotePDS(ctx, community.PDSURL, community.DID, postRecord, token.Token)
+
if err != nil {
+
return nil, fmt.Errorf("failed to write to remote PDS: %w", err)
+
}
+
+
log.Printf("[FEDERATION] User %s posted to remote community %s: %s",
+
req.AuthorDID, community.DID, uri)
+
+
return &CreatePostResponse{
+
URI: uri,
+
CID: cid,
+
}, nil
+
}
+
+
func (s *postService) createPostOnRemotePDS(
+
ctx context.Context,
+
pdsURL string,
+
communityDID string,
+
record PostRecord,
+
serviceAuthToken string,
+
) (uri, cid string, err error) {
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL)
+
+
payload := map[string]interface{}{
+
"repo": communityDID,
+
"collection": "social.coves.community.post",
+
"record": record,
+
}
+
+
jsonData, _ := json.Marshal(payload)
+
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
+
+
// Use service auth token instead of community credentials
+
req.Header.Set("Authorization", "Bearer "+serviceAuthToken)
+
req.Header.Set("Content-Type", "application/json")
+
+
// ... execute request, parse response ...
+
return uri, cid, nil
+
}
+
```
+
+
### Phase 4: PDS Service Auth Validation (PDS Extension)
+
+
**Note:** This requires extending the PDS. Options:
+
1. Contribute to official atproto PDS
+
2. Run modified PDS fork
+
3. Use PDS middleware/proxy
+
+
**Conceptual Implementation:**
+
+
```go
+
// PDS validates service auth requests before issuing tokens
+
func (h *ServiceAuthHandler) HandleGetServiceAuth(w http.ResponseWriter, r *http.Request) {
+
var req ServiceAuthRequest
+
json.NewDecoder(r.Body).Decode(&req)
+
+
// 1. Verify requesting service is trusted
+
requestingDID := extractDIDFromJWT(r.Header.Get("Authorization"))
+
if !h.isTrustedInstance(requestingDID) {
+
writeError(w, http.StatusForbidden, "UntrustedInstance", "Instance not in allowlist")
+
return
+
}
+
+
// 2. Validate community exists on this PDS
+
community, err := h.getCommunityByDID(req.Aud)
+
if err != nil {
+
writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not hosted here")
+
return
+
}
+
+
// 3. Check user not banned (query from AppView or local moderation records)
+
if h.isUserBanned(req.UserDID, req.Aud) {
+
writeError(w, http.StatusForbidden, "Banned", "User banned from community")
+
return
+
}
+
+
// 4. Check community settings (allows remote posts?)
+
if !community.AllowFederatedPosts {
+
writeError(w, http.StatusForbidden, "FederationDisabled", "Community doesn't accept federated posts")
+
return
+
}
+
+
// 5. Rate limiting (per user, per community, per instance)
+
if h.exceedsRateLimit(req.UserDID, req.Aud, requestingDID) {
+
writeError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests")
+
return
+
}
+
+
// 6. Generate scoped token
+
token := h.issueServiceAuthToken(ServiceAuthTokenOptions{
+
Audience: req.Aud, // Community DID
+
Subject: requestingDID, // Requesting instance DID
+
Method: req.Lxm, // Authorized method
+
ExpiresAt: time.Unix(req.Exp, 0),
+
Scopes: []string{"write:posts"},
+
})
+
+
json.NewEncoder(w).Encode(map[string]string{
+
"token": token,
+
})
+
}
+
```
+
+
---
+
+
## Database Schema Changes
+
+
### New Table: `instance_federation`
+
+
Tracks trusted instances and federation settings:
+
+
```sql
+
CREATE TABLE instance_federation (
+
id SERIAL PRIMARY KEY,
+
instance_did TEXT NOT NULL UNIQUE,
+
instance_domain TEXT NOT NULL,
+
trust_level TEXT NOT NULL, -- 'trusted', 'limited', 'blocked'
+
allowed_methods TEXT[] NOT NULL DEFAULT '{}',
+
rate_limit_posts_per_hour INTEGER NOT NULL DEFAULT 100,
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
notes TEXT
+
);
+
+
CREATE INDEX idx_instance_federation_did ON instance_federation(instance_did);
+
CREATE INDEX idx_instance_federation_trust ON instance_federation(trust_level);
+
```
+
+
### New Table: `federation_rate_limits`
+
+
Track federated post rate limits:
+
+
```sql
+
CREATE TABLE federation_rate_limits (
+
id SERIAL PRIMARY KEY,
+
user_did TEXT NOT NULL,
+
community_did TEXT NOT NULL,
+
instance_did TEXT NOT NULL,
+
window_start TIMESTAMPTZ NOT NULL,
+
post_count INTEGER NOT NULL DEFAULT 1,
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+
UNIQUE(user_did, community_did, instance_did, window_start)
+
);
+
+
CREATE INDEX idx_federation_rate_limits_lookup
+
ON federation_rate_limits(user_did, community_did, instance_did, window_start);
+
```
+
+
### Update Table: `communities`
+
+
Add federation settings:
+
+
```sql
+
ALTER TABLE communities
+
ADD COLUMN allow_federated_posts BOOLEAN NOT NULL DEFAULT true,
+
ADD COLUMN federation_mode TEXT NOT NULL DEFAULT 'open';
+
-- federation_mode: 'open' (any instance), 'allowlist' (trusted only), 'local' (no federation)
+
```
+
+
---
+
+
## Security Considerations
+
+
### 1. Instance Trust Model
+
+
**Allowlist Approach (Beta):**
+
- Manual approval of federated instances
+
- Admin UI to manage instance trust levels
+
- Default: block all, explicit allow
+
+
**Trust Levels:**
+
- `trusted` - Full federation, normal rate limits
+
- `limited` - Federation allowed, strict rate limits
+
- `blocked` - No federation
+
+
### 2. User Ban Synchronization
+
+
**Challenge:** Remote instance needs to check local bans
+
+
**Options:**
+
1. **Service auth validation** - PDS queries AppView for ban status
+
2. **Ban records in PDS** - Moderation records stored in community repo
+
3. **Cached ban list** - Remote instances cache ban lists (with TTL)
+
+
**Beta Approach:** Option 1 (service auth validation queries AppView)
+
+
### 3. Rate Limiting
+
+
**Multi-level rate limits:**
+
- Per user per community: 10 posts/hour
+
- Per instance per community: 100 posts/hour
+
- Per user across all communities: 50 posts/hour
+
+
**Implementation:** In-memory + PostgreSQL for persistence
+
+
### 4. Content Validation
+
+
**Same validation as local posts:**
+
- Lexicon validation
+
- Content length limits
+
- Embed validation
+
- Label validation
+
+
**Additional federation checks:**
+
- Verify author DID is valid
+
- Verify requesting instance signature
+
- Verify token scopes match operation
+
+
---
+
+
## API Changes
+
+
### New Endpoint: `social.coves.federation.getTrustedInstances`
+
+
**Purpose:** List instances this instance federates with
+
+
**Lexicon:**
+
```json
+
{
+
"lexicon": 1,
+
"id": "social.coves.federation.getTrustedInstances",
+
"defs": {
+
"main": {
+
"type": "query",
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["instances"],
+
"properties": {
+
"instances": {
+
"type": "array",
+
"items": { "$ref": "#instanceView" }
+
}
+
}
+
}
+
}
+
},
+
"instanceView": {
+
"type": "object",
+
"required": ["did", "domain", "trustLevel"],
+
"properties": {
+
"did": { "type": "string" },
+
"domain": { "type": "string" },
+
"trustLevel": { "type": "string" },
+
"allowedMethods": { "type": "array", "items": { "type": "string" } }
+
}
+
}
+
}
+
}
+
```
+
+
### Modified Endpoint: `social.coves.community.post.create`
+
+
**Changes:**
+
- No API contract changes
+
- Internal routing: local vs federated
+
- New error codes:
+
- `FederationFailed` - Remote instance unreachable
+
- `RemoteNotAuthorized` - Remote instance rejected auth
+
- `RemoteBanned` - User banned on remote community
+
+
---
+
+
## User Experience
+
+
### Happy Path: Cross-Instance Post
+
+
1. User on coves.social navigates to !gaming@covesinstance.com
+
2. Clicks "Create Post"
+
3. Fills out post form (title, content, etc.)
+
4. Clicks "Submit"
+
5. **Behind the scenes:**
+
- coves.social requests service auth from covesinstance.com
+
- covesinstance.com validates and issues token
+
- coves.social writes post using token
+
- Post appears in feed within seconds (via firehose)
+
6. **User sees:** Post published successfully
+
7. Post appears in:
+
- covesinstance.com feeds (native community)
+
- coves.social discover/all feeds (indexed via firehose)
+
- User's profile on coves.social
+
+
### Error Cases
+
+
**User Banned:**
+
- Error: "You are banned from !gaming@covesinstance.com"
+
- Suggestion: "Contact community moderators for more information"
+
+
**Instance Blocked:**
+
- Error: "This community does not accept posts from your instance"
+
- Suggestion: "Contact community administrators or create a local account"
+
+
**Federation Unavailable:**
+
- Error: "Unable to connect to covesinstance.com. Try again later."
+
- Fallback: Allow saving as draft (future feature)
+
+
**Rate Limited:**
+
- Error: "You're posting too quickly. Please wait before posting again."
+
- Show: Countdown until next post allowed
+
+
---
+
+
## Testing Requirements
+
+
### Unit Tests
+
+
1. **Service Detection:**
+
- `isLocalCommunity()` correctly identifies local vs remote
+
- Handles edge cases (different ports, subdomains)
+
+
2. **Service Auth Client:**
+
- Correctly formats service auth requests
+
- Handles token expiration
+
- Retries on transient failures
+
+
3. **Federated Post Creation:**
+
- Uses service auth token instead of community credentials
+
- Falls back gracefully on errors
+
- Logs federation events
+
+
### Integration Tests
+
+
1. **Local Post (Regression):**
+
- Posting to local community still works
+
- No performance degradation
+
+
2. **Federated Post:**
+
- User can post to remote community
+
- Service auth token requested correctly
+
- Post written to remote PDS
+
- Post indexed by both AppViews
+
+
3. **Authorization Failures:**
+
- Banned users rejected at service auth stage
+
- Untrusted instances rejected
+
- Expired tokens rejected
+
+
4. **Rate Limiting:**
+
- Per-user rate limits enforced
+
- Per-instance rate limits enforced
+
- Rate limit resets correctly
+
+
### End-to-End Tests
+
+
1. **Cross-Instance User Journey:**
+
- Set up two instances (instance-a, instance-b)
+
- Create community on instance-b
+
- User on instance-a posts to instance-b community
+
- Verify post appears on both instances
+
+
2. **Moderation Enforcement:**
+
- Ban user on remote instance
+
- Verify user can't post from any instance
+
- Unban user
+
- Verify user can post again
+
+
3. **Instance Blocklist:**
+
- Block instance-a on instance-b
+
- Verify users from instance-a can't post to instance-b communities
+
- Unblock instance-a
+
- Verify posting works again
+
+
---
+
+
## Migration Path (Alpha โ†’ Beta)
+
+
### Phase 1: Backend Implementation (No User Impact)
+
1. Add service auth client
+
2. Add local vs remote detection
+
3. Deploy with feature flag `ENABLE_FEDERATION=false`
+
+
### Phase 2: Database Migration
+
1. Add federation tables
+
2. Seed with initial trusted instances (manual)
+
3. Add community federation flags (default: allow)
+
+
### Phase 3: Soft Launch
+
1. Enable federation for single test instance
+
2. Monitor service auth requests/errors
+
3. Validate rate limiting works
+
+
### Phase 4: Beta Rollout
+
1. Enable `ENABLE_FEDERATION=true` for all instances
+
2. Admin UI for managing trusted instances
+
3. Community settings for federation preferences
+
+
### Phase 5: Documentation & Onboarding
+
1. Instance operator guide: "How to federate with other instances"
+
2. Community moderator guide: "Federation settings"
+
3. User guide: "Posting across instances"
+
+
---
+
+
## Metrics & Success Criteria
+
+
### Performance Metrics
+
- Service auth request latency: p95 < 200ms
+
- Federated post creation time: p95 < 2 seconds (vs 500ms local)
+
- Service auth token cache hit rate: > 80%
+
+
### Adoption Metrics
+
- % of posts that are federated: Target 20% by end of Beta
+
- Number of federated instances: Target 5+ by end of Beta
+
- Cross-instance engagement (comments, votes): Monitor trend
+
+
### Reliability Metrics
+
- Service auth success rate: > 99%
+
- Federated post success rate: > 95%
+
- Service auth token validation errors: < 1%
+
+
### Security Metrics
+
- Unauthorized access attempts: Monitor & alert
+
- Rate limit triggers: Track per instance
+
- Ban evasion attempts: Zero tolerance
+
+
---
+
+
## Rollback Plan
+
+
If federation causes critical issues:
+
+
1. **Immediate:** Set `ENABLE_FEDERATION=false` via env var
+
2. **Fallback:** All posts route through local-only flow
+
3. **Investigation:** Review logs for service auth failures
+
4. **Fix Forward:** Deploy patch, re-enable gradually
+
+
**No data loss:** Posts are written to PDS, indexed via firehose regardless of federation method.
+
+
---
+
+
## Open Questions
+
+
1. **Instance Discovery:** How do users find communities on other instances?
+
- Beta: Manual (users share links)
+
- Future: Instance directory, community search across instances
+
+
2. **Service Auth Token Caching:** Should AppViews cache service auth tokens?
+
- Pros: Reduce latency, fewer PDS requests
+
- Cons: Stale permissions, ban enforcement delay
+
- **Decision needed:** Cache with short TTL (5 minutes)?
+
+
3. **PDS Implementation:** Who implements service auth validation?
+
- Option A: Contribute to official PDS (long timeline)
+
- Option B: Run forked PDS (maintenance burden)
+
- Option C: Proxy/middleware (added complexity)
+
- **Decision needed:** Start with Option B, migrate to Option A?
+
+
4. **Federation Symmetry:** If instance-a trusts instance-b, does instance-b auto-trust instance-a?
+
- Beta: No (asymmetric trust)
+
- Future: Mutual federation agreements?
+
+
5. **Cross-Instance Moderation:** Should bans propagate across instances?
+
- Beta: No (each instance decides)
+
- Future: Shared moderation lists?
+
+
---
+
+
## Future Enhancements (Post-Beta)
+
+
1. **Service Auth Token Caching:** Reduce latency for frequent posters
+
2. **Batch Service Auth:** Request tokens for multiple communities at once
+
3. **Instance Discovery API:** Automatic instance detection/registration
+
4. **Federation Analytics:** Dashboard showing cross-instance activity
+
5. **Moderation Sync:** Optional shared ban lists across trusted instances
+
6. **Content Mirroring:** Cache federated posts locally for performance
+
7. **User Migration:** Transfer account between instances
+
+
---
+
+
## Resources
+
+
### Documentation
+
- [atProto Service Auth Spec](https://atproto.com/specs/service-auth) (hypothetical - check actual docs)
+
- Lemmy Federation Architecture
+
- Mastodon Federation Implementation
+
+
### Code References
+
- `internal/core/posts/service.go` - Post creation service
+
- `internal/api/handlers/post/create.go` - Post creation handler
+
- `internal/atproto/jetstream/` - Firehose consumers
+
+
### Dependencies
+
- atproto SDK (for service auth)
+
- PDS v0.4+ (service auth support)
+
- PostgreSQL 14+ (for federation tables)
+
+
---
+
+
## Appendix A: Service Auth Request Example
+
+
**Request to Remote PDS:**
+
```http
+
POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth
+
Authorization: Bearer {coves-social-instance-jwt}
+
Content-Type: application/json
+
+
{
+
"aud": "did:plc:community123",
+
"exp": 1700000000,
+
"lxm": "social.coves.community.post.create"
+
}
+
```
+
+
**Response:**
+
```http
+
HTTP/1.1 200 OK
+
Content-Type: application/json
+
+
{
+
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
+
}
+
```
+
+
**Using Token to Create Post:**
+
```http
+
POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord
+
Authorization: Bearer {service-auth-token}
+
Content-Type: application/json
+
+
{
+
"repo": "did:plc:community123",
+
"collection": "social.coves.community.post",
+
"record": {
+
"$type": "social.coves.community.post",
+
"community": "did:plc:community123",
+
"author": "did:plc:user456",
+
"title": "Hello from coves.social!",
+
"content": "This is a federated post",
+
"createdAt": "2024-11-16T12:00:00Z"
+
}
+
}
+
```
+
+
---
+
+
## Appendix B: Error Handling Matrix
+
+
| Error Condition | HTTP Status | Error Code | User Message | Retry Strategy |
+
|----------------|-------------|------------|--------------|----------------|
+
| Instance not trusted | 403 | `UntrustedInstance` | "This community doesn't accept posts from your instance" | No retry |
+
| User banned | 403 | `Banned` | "You are banned from this community" | No retry |
+
| Rate limit exceeded | 429 | `RateLimited` | "Too many posts. Try again in X minutes" | Exponential backoff |
+
| PDS unreachable | 503 | `ServiceUnavailable` | "Community temporarily unavailable" | Retry 3x with backoff |
+
| Invalid token | 401 | `InvalidToken` | "Session expired. Please try again" | Refresh token & retry |
+
| Community not found | 404 | `CommunityNotFound` | "Community not found" | No retry |
+
| Service auth failed | 500 | `FederationFailed` | "Unable to connect. Try again later" | Retry 2x |
+
+
---
+
+
**End of PRD**
+130 -28
docs/PRD_ALPHA_GO_LIVE.md
···
## ๐ŸŽฏ Major Progress Update
**โœ… ALL E2E TESTS COMPLETE!** (Completed 2025-11-16)
+
**โœ… BIDIRECTIONAL DID VERIFICATION COMPLETE!** (Completed 2025-11-16)
All 6 critical E2E test suites have been implemented and are passing:
- โœ… Full User Journey (signup โ†’ community โ†’ post โ†’ comment โ†’ vote)
···
**Time Saved**: ~7-12 hours through parallel agent implementation
**Test Quality**: Enhanced with comprehensive database record verification to catch race conditions
+
### Production Deployment Requirements
+
+
**Architecture**:
+
- **AppView Domain**: coves.social (instance identity, API, frontend)
+
- **PDS Domain**: coves.me (separate domain required - cannot be same as AppView)
+
- **Community Handles**: Use @coves.social (AppView domain)
+
- **Jetstream**: Connects to Bluesky's production firehose (wss://jetstream2.us-east.bsky.network)
+
+
**Required: .well-known/did.json at coves.social**:
+
```json
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "z..."
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+
```
+
+
**Environment Variables**:
+
- AppView:
+
- `INSTANCE_DID=did:web:coves.social`
+
- `INSTANCE_DOMAIN=coves.social`
+
- `PDS_URL=https://coves.me` (separate domain)
+
- `SKIP_DID_WEB_VERIFICATION=false` (production)
+
- `JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe`
+
+
**Verification**:
+
- `curl https://coves.social/.well-known/did.json` (should return DID document)
+
- `curl https://coves.me/xrpc/_health` (PDS health check)
+
## Overview
This document tracks the remaining work required to launch Coves alpha with real users. Focus is on critical functionality, security, and operational readiness.
···
### 1. Authentication & Security
+
#### Production PDS Deployment
+
**CRITICAL**: PDS must be on separate domain from AppView (coves.me, not coves.social)
+
+
- [ ] Deploy PDS to coves.me domain
+
- [ ] Set up DNS: A record for coves.me โ†’ server IP
+
- [ ] Configure SSL certificate for coves.me
+
- [ ] Deploy PDS container/service on port 2583
+
- [ ] Configure nginx/Caddy reverse proxy for coves.me โ†’ localhost:2583
+
- [ ] Set PDS_HOSTNAME=coves.me in PDS environment
+
- [ ] Mount persistent volume for PDS data (/pds/data)
+
- [ ] Verify PDS connectivity
+
- [ ] Test: `curl https://coves.me/xrpc/_health`
+
- [ ] Create test community account on PDS
+
- [ ] Verify JWKS endpoint: `curl https://coves.me/.well-known/jwks.json`
+
- [ ] Test community account token provisioning
+
- [ ] Configure AppView to use production PDS
+
- [ ] Set `PDS_URL=https://coves.me` in AppView .env
+
- [ ] Test community creation flow (provisions account on coves.me)
+
- [ ] Verify account provisioning works end-to-end
+
+
**Important**: Jetstream connects to Bluesky's production firehose, which automatically includes events from all production PDS instances (including coves.me once it's live)
+
+
**Estimated Effort**: 4-6 hours
+
**Risk**: Medium (infrastructure setup, DNS propagation)
+
#### JWT Signature Verification (Production Mode)
-
- [ ] Test with production PDS at `pds.bretton.dev`
-
- [ ] Create test account on production PDS
-
- [ ] Verify JWKS endpoint is accessible
+
- [ ] Test with production PDS at coves.me
+
- [ ] Verify JWKS endpoint is accessible: `https://coves.me/.well-known/jwks.json`
- [ ] Run `TestJWTSignatureVerification` against production PDS
- [ ] Confirm signature verification succeeds
-
- [ ] Test token refresh flow
+
- [ ] Test token refresh flow for community accounts
- [ ] Set `AUTH_SKIP_VERIFY=false` in production environment
- [ ] Verify all auth middleware tests pass with verification enabled
-
- [ ] Document production PDS requirements for communities
-
-
**Estimated Effort**: 2-3 hours
-
**Risk**: Medium (code implemented, needs validation)
-
-
#### did:web Verification
-
- [ ] Complete did:web domain verification implementation
-
- [ ] Test with real did:web identities
-
- [ ] Add security logging for verification failures
-
- [ ] Set `SKIP_DID_WEB_VERIFICATION=false` for production
**Estimated Effort**: 2-3 hours
-
**Risk**: Medium
+
**Risk**: Low (depends on PDS deployment)
+
+
#### did:web Verification โœ… COMPLETE
+
- [x] Complete did:web domain verification implementation (2025-11-16)
+
- [x] Implement Bluesky-compatible bidirectional verification
+
- [x] Add alsoKnownAs field verification in DID documents
+
- [x] Add security logging for verification failures
+
- [x] Update cache TTL to 24h (matches Bluesky recommendations)
+
- [x] Comprehensive test coverage with mock HTTP servers
+
- [ ] Set `SKIP_DID_WEB_VERIFICATION=false` for production (dev default: true)
+
- [ ] Deploy `.well-known/did.json` to production domain
+
+
**Implementation Details**:
+
- **Location**: [internal/atproto/jetstream/community_consumer.go](../internal/atproto/jetstream/community_consumer.go)
+
- **Verification Flow**: Domain matching + DID document fetch + alsoKnownAs validation
+
- **Security Model**: Matches Bluesky (DNS/HTTPS authority + bidirectional binding)
+
- **Performance**: Bounded LRU cache (1000 entries), rate limiting (10 req/s), 24h TTL
+
- **Impact**: AppView indexing and federation trust (not community creation API)
+
- **Tests**: `tests/integration/community_hostedby_security_test.go`
+
+
**Actual Effort**: 3 hours (implementation + testing)
+
**Risk**: โœ… Low (complete and tested)
### 2. DPoP Token Architecture Fix
···
- [ ] Common issues and fixes
- [ ] Emergency procedures (PDS down, database down, etc.)
- [ ] Create production environment checklist
-
- [ ] All environment variables set
-
- [ ] `AUTH_SKIP_VERIFY=false`
-
- [ ] `SKIP_DID_WEB_VERIFICATION=false`
-
- [ ] Database migrations applied
-
- [ ] PDS connectivity verified
-
- [ ] JWKS caching working
-
- [ ] Jetstream consumers running
+
- [ ] **Domain Setup**
+
- [ ] AppView domain (coves.social) DNS configured
+
- [ ] PDS domain (coves.me) DNS configured - MUST be separate domain
+
- [ ] SSL certificates for both domains
+
- [ ] Nginx/Caddy reverse proxy configured for both domains
+
- [ ] **AppView Environment Variables**
+
- [ ] `INSTANCE_DID=did:web:coves.social`
+
- [ ] `INSTANCE_DOMAIN=coves.social`
+
- [ ] `PDS_URL=https://coves.me` (separate domain)
+
- [ ] `AUTH_SKIP_VERIFY=false`
+
- [ ] `SKIP_DID_WEB_VERIFICATION=false`
+
- [ ] `JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe`
+
- [ ] **PDS Environment Variables**
+
- [ ] `PDS_HOSTNAME=coves.me`
+
- [ ] `PDS_PORT=2583`
+
- [ ] Persistent storage mounted
+
- [ ] **Deployment Verification**
+
- [ ] Deploy `.well-known/did.json` to coves.social with `serviceEndpoint: https://coves.me`
+
- [ ] Verify: `curl https://coves.social/.well-known/did.json`
+
- [ ] Verify: `curl https://coves.me/xrpc/_health`
+
- [ ] Database migrations applied
+
- [ ] PDS connectivity verified from AppView
+
- [ ] JWKS caching working
+
- [ ] Jetstream consumer connected to Bluesky production firehose
+
- [ ] Test community creation end-to-end
- [ ] Monitoring and alerting active
**Estimated Effort**: 6-8 hours
···
## Timeline Estimate
### Week 1: Critical Blockers (P0)
-
- **Days 1-2**: Authentication (JWT + did:web verification)
+
- ~~**Days 1-2**: Authentication (JWT + did:web verification)~~ โœ… **did:web COMPLETED**
+
- **Day 1**: Production PDS deployment (coves.me domain setup)
+
- **Day 2**: JWT signature verification with production PDS
- **Day 3**: DPoP token architecture fix
- ~~**Day 4**: Handle resolution + comment count reconciliation~~ โœ… **COMPLETED**
- **Day 4-5**: Testing and bug fixes
-
**Total**: 15-20 hours (reduced from 20-25 due to completed items)
+
**Total**: 16-23 hours (added 4-6 hours for PDS deployment, reduced from original due to did:web completion)
### Week 2: Production Infrastructure (P1)
- **Days 6-7**: Monitoring + structured logging
···
**Total**: ~~20-25 hours~~ โ†’ **13 hours actual** (E2E tests) + 7-12 hours remaining (load testing, polish)
-
**Grand Total: ~~65-80 hours~~ โ†’ 50-65 hours remaining (approximately 1.5-2 weeks full-time)**
-
*(Originally 70-85 hours. Reduced by completed items: handle resolution, comment count reconciliation, and ALL E2E tests)*
+
**Grand Total: ~~65-80 hours~~ โ†’ 51-68 hours remaining (approximately 1.5-2 weeks full-time)**
+
*(Originally 70-85 hours. Adjusted for: +4-6 hours PDS deployment, -3 hours did:web completion, -13 hours E2E tests completion, -4 hours handle resolution and comment reconciliation)*
**โœ… Progress Update**: E2E testing section COMPLETE ahead of schedule - saved ~7-12 hours through parallel agent implementation
···
- [ ] All P0 blockers resolved
- โœ… Handle resolution (COMPLETE)
- โœ… Comment count reconciliation (COMPLETE)
+
- โœ… did:web verification (COMPLETE - needs production deployment)
+
- [ ] Production PDS deployed to coves.me (separate domain)
- [ ] JWT signature verification working with production PDS
- [ ] DPoP architecture fix implemented
-
- [ ] did:web verification complete
- [ ] Subscriptions/blocking work via client-write pattern
- [x] **All integration tests passing** โœ…
- [x] **E2E user journey test passing** โœ…
···
11. [ ] Go/no-go decision
12. [ ] Launch! ๐Ÿš€
-
**๐ŸŽ‰ Major Milestone**: All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.
+
**๐ŸŽ‰ Major Milestones**:
+
- All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.
+
- Bidirectional DID verification complete! Bluesky-compatible security model with alsoKnownAs validation, 24h cache TTL, and comprehensive test coverage.
+19 -16
docs/PRD_BACKLOG.md
···
---
-
### did:web Domain Verification & hostedByDID Auto-Population
-
**Added:** 2025-10-11 | **Updated:** 2025-10-16 | **Effort:** 2-3 days | **Priority:** ALPHA BLOCKER
+
### โœ… did:web Domain Verification & hostedByDID Auto-Population - COMPLETE
+
**Added:** 2025-10-11 | **Updated:** 2025-11-16 | **Completed:** 2025-11-16 | **Status:** โœ… DONE
**Problem:**
1. **Domain Impersonation**: Self-hosters can set `INSTANCE_DID=did:web:nintendo.com` without owning the domain, enabling attacks where communities appear hosted by trusted domains
···
- Federation partners can't verify instance authenticity
- AppView pollution with fake hosting claims
-
**Solution:**
-
1. **Basic Validation (Phase 1)**: Verify `did:web:` domain matches configured `instanceDomain`
-
2. **Cryptographic Verification (Phase 2)**: Fetch `https://domain/.well-known/did.json` and verify:
+
**Solution Implemented (Bluesky-Compatible):**
+
1. โœ… **Domain Matching**: Verify `did:web:` domain matches configured `instanceDomain`
+
2. โœ… **Bidirectional Verification**: Fetch `https://domain/.well-known/did.json` and verify:
- DID document exists and is valid
-
- Domain ownership proven via HTTPS hosting
-
- DID document matches claimed `instanceDID`
-
3. **Auto-populate hostedByDID**: Remove from client API, derive from instance configuration in service layer
+
- DID document ID matches claimed `instanceDID`
+
- DID document claims handle domain in `alsoKnownAs` field (bidirectional binding)
+
- Domain ownership proven via HTTPS hosting (matches Bluesky's trust model)
+
3. โœ… **Auto-populate hostedByDID**: Removed from client API, derived from instance configuration in service layer
**Current Status:**
- โœ… Default changed from `coves.local` โ†’ `coves.social` (fixes `.local` TLD bug)
-
- โœ… TODO comment in [cmd/server/main.go:126-131](../cmd/server/main.go#L126-L131)
- โœ… hostedByDID removed from client requests (2025-10-16)
- โœ… Service layer auto-populates `hostedByDID` from `instanceDID` (2025-10-16)
- โœ… Handler rejects client-provided `hostedByDID` (2025-10-16)
- โœ… Basic validation: Logs warning if `did:web:` domain โ‰  `instanceDomain` (2025-10-16)
-
- โš ๏ธ **REMAINING**: Full DID document verification (cryptographic proof of ownership)
-
-
**Implementation Notes:**
-
- Phase 1 complete: Basic validation catches config errors, logs warnings
-
- Phase 2 needed: Fetch `https://domain/.well-known/did.json` and verify ownership
-
- Add `SKIP_DID_WEB_VERIFICATION=true` for dev mode
-
- Full verification blocks startup if domain ownership cannot be proven
+
- โœ… **MANDATORY bidirectional DID verification** (2025-11-16)
+
- โœ… Cache TTL updated to 24h (matches Bluesky recommendations) (2025-11-16)
+
+
**Implementation Details:**
+
- **Security Model**: Matches Bluesky's approach - relies on DNS/HTTPS authority, not cryptographic proof
+
- **Enforcement**: MANDATORY hard-fail in production (rejects communities with verification failures)
+
- **Dev Mode**: Set `SKIP_DID_WEB_VERIFICATION=true` to bypass verification for local development
+
- **Performance**: Bounded LRU cache (1000 entries), rate limiting (10 req/s), 24h cache TTL
+
- **Bidirectional Check**: Prevents impersonation by requiring DID document to claim the handle
+
- **Location**: [internal/atproto/jetstream/community_consumer.go](../internal/atproto/jetstream/community_consumer.go)
---
+73
internal/atproto/lexicon/social/coves/aggregator/register.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.register",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Register an existing aggregator DID with this Coves instance. Aggregators must first create their own DID via PLC directory, then call this endpoint to register. Domain ownership is verified via .well-known/atproto-did file.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "domain"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator (did:plc or did:web format)"
+
},
+
"domain": {
+
"type": "string",
+
"format": "uri",
+
"description": "Domain where the aggregator is hosted (e.g., 'rss-bot.example.com'). Must serve .well-known/atproto-did file containing the DID."
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "handle"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the registered aggregator"
+
},
+
"handle": {
+
"type": "string",
+
"description": "Handle extracted from DID document"
+
},
+
"message": {
+
"type": "string",
+
"description": "Success message with next steps"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InvalidDID",
+
"description": "DID format is invalid or not did:plc or did:web format"
+
},
+
{
+
"name": "DomainVerificationFailed",
+
"description": "Could not verify domain ownership via .well-known/atproto-did or DID mismatch"
+
},
+
{
+
"name": "AlreadyRegistered",
+
"description": "This aggregator DID is already registered with this instance"
+
},
+
{
+
"name": "DIDResolutionFailed",
+
"description": "Could not resolve DID document to extract handle and PDS URL"
+
},
+
{
+
"name": "RegistrationFailed",
+
"description": "Internal server error occurred during registration"
+
}
+
]
+
}
+
}
+
}
+591
docs/aggregators/SETUP_GUIDE.md
···
+
# Aggregator Setup Guide
+
+
This guide explains how to set up and register an aggregator with Coves instances.
+
+
## Table of Contents
+
+
- [Overview](#overview)
+
- [Architecture](#architecture)
+
- [Prerequisites](#prerequisites)
+
- [Quick Start](#quick-start)
+
- [Detailed Setup Steps](#detailed-setup-steps)
+
- [Authorization Process](#authorization-process)
+
- [Posting to Communities](#posting-to-communities)
+
- [Rate Limits](#rate-limits)
+
- [Security Best Practices](#security-best-practices)
+
- [Troubleshooting](#troubleshooting)
+
- [API Reference](#api-reference)
+
+
## Overview
+
+
**Aggregators** are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers - self-managed external services that integrate with the platform.
+
+
**Key characteristics**:
+
- Self-owned: You create and manage your own PDS account
+
- Domain-verified: Prove ownership via `.well-known/atproto-did`
+
- Community-authorized: Moderators grant posting permission per-community
+
- Rate-limited: 10 posts per hour per community
+
+
**Example use cases**:
+
- RSS feed aggregators (tech news, blog posts)
+
- Social media cross-posters (Twitter โ†’ Coves)
+
- Event notifications (GitHub releases, weather alerts)
+
- Content curation bots (daily links, summaries)
+
+
## Architecture
+
+
### Data Flow
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ 1. One-Time Setup โ”‚
+
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+
โ”‚ Aggregator creates PDS account โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Proves domain ownership (.well-known) โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Registers with Coves (enters users table) โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Writes service declaration โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Jetstream indexes into aggregators table โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ 2. Per-Community Authorization โ”‚
+
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+
โ”‚ Moderator writes authorization record โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Jetstream indexes into aggregator_authorizations โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ 3. Posting (Ongoing) โ”‚
+
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+
โ”‚ Aggregator calls post creation endpoint โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Handler validates: โ”‚
+
โ”‚ - Author in users table โœ“ โ”‚
+
โ”‚ - Author in aggregators table โœ“ โ”‚
+
โ”‚ - Authorization exists โœ“ โ”‚
+
โ”‚ - Rate limit not exceeded โœ“ โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Post written to community's PDS โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Jetstream indexes post โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
```
+
+
### Database Tables
+
+
**users** - All actors (users, communities, aggregators)
+
```sql
+
CREATE TABLE users (
+
did TEXT PRIMARY KEY,
+
handle TEXT NOT NULL,
+
pds_url TEXT,
+
indexed_at TIMESTAMPTZ
+
);
+
```
+
+
**aggregators** - Aggregator-specific metadata
+
```sql
+
CREATE TABLE aggregators (
+
did TEXT PRIMARY KEY,
+
display_name TEXT NOT NULL,
+
description TEXT,
+
avatar_url TEXT,
+
config_schema JSONB,
+
source_url TEXT,
+
maintainer_did TEXT,
+
record_uri TEXT NOT NULL UNIQUE,
+
record_cid TEXT NOT NULL,
+
created_at TIMESTAMPTZ,
+
indexed_at TIMESTAMPTZ
+
);
+
```
+
+
**aggregator_authorizations** - Community authorizations
+
```sql
+
CREATE TABLE aggregator_authorizations (
+
id BIGSERIAL PRIMARY KEY,
+
aggregator_did TEXT NOT NULL,
+
community_did TEXT NOT NULL,
+
enabled BOOLEAN NOT NULL DEFAULT true,
+
config JSONB,
+
created_by TEXT,
+
record_uri TEXT NOT NULL UNIQUE,
+
record_cid TEXT NOT NULL,
+
UNIQUE(aggregator_did, community_did)
+
);
+
```
+
+
## Prerequisites
+
+
1. **Domain ownership**: You must own a domain where you can host static files over HTTPS
+
2. **Web server**: Ability to serve the `.well-known/atproto-did` file
+
3. **Development tools**: `curl`, `jq`, basic shell scripting knowledge
+
4. **Email address**: For creating the PDS account
+
+
**Optional**:
+
- Custom avatar image (PNG/JPEG/WebP, max 1MB)
+
- GitHub repository for source code transparency
+
+
## Quick Start
+
+
We provide automated setup scripts:
+
+
```bash
+
cd scripts/aggregator-setup
+
+
# Make scripts executable
+
chmod +x *.sh
+
+
# Run setup scripts in order
+
./1-create-pds-account.sh
+
./2-setup-wellknown.sh
+
# (Upload .well-known to your web server)
+
./3-register-with-coves.sh
+
./4-create-service-declaration.sh
+
```
+
+
See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed script documentation.
+
+
## Detailed Setup Steps
+
+
### Step 1: Create PDS Account
+
+
Your aggregator needs its own atProto identity (DID). The easiest way is to create an account on an existing PDS.
+
+
**Using an existing PDS (recommended)**:
+
+
```bash
+
curl -X POST https://bsky.social/xrpc/com.atproto.server.createAccount \
+
-H "Content-Type: application/json" \
+
-d '{
+
"handle": "mynewsbot.bsky.social",
+
"email": "bot@example.com",
+
"password": "secure-password-here"
+
}'
+
```
+
+
**Response**:
+
```json
+
{
+
"accessJwt": "eyJ...",
+
"refreshJwt": "eyJ...",
+
"handle": "mynewsbot.bsky.social",
+
"did": "did:plc:abc123...",
+
"didDoc": {...}
+
}
+
```
+
+
**Save these credentials securely!** You'll need the DID and access token for all subsequent operations.
+
+
**Alternative**: Run your own PDS or use `did:web` (advanced).
+
+
### Step 2: Prove Domain Ownership
+
+
To register with Coves, you must prove you own a domain by serving your DID at `https://yourdomain.com/.well-known/atproto-did`.
+
+
**Create the file**:
+
+
```bash
+
mkdir -p .well-known
+
echo "did:plc:abc123..." > .well-known/atproto-did
+
```
+
+
**Upload to your web server** so it's accessible at:
+
```
+
https://rss-bot.example.com/.well-known/atproto-did
+
```
+
+
**Verify it works**:
+
```bash
+
curl https://rss-bot.example.com/.well-known/atproto-did
+
# Should return: did:plc:abc123...
+
```
+
+
**Nginx configuration example**:
+
```nginx
+
location /.well-known/atproto-did {
+
alias /var/www/.well-known/atproto-did;
+
default_type text/plain;
+
add_header Access-Control-Allow-Origin *;
+
}
+
```
+
+
### Step 3: Register with Coves
+
+
Call the registration endpoint to register your aggregator DID with the Coves instance.
+
+
**Endpoint**: `POST /xrpc/social.coves.aggregator.register`
+
+
**Request**:
+
```bash
+
curl -X POST https://api.coves.social/xrpc/social.coves.aggregator.register \
+
-H "Content-Type: application/json" \
+
-d '{
+
"did": "did:plc:abc123...",
+
"domain": "rss-bot.example.com"
+
}'
+
```
+
+
**Response** (Success):
+
```json
+
{
+
"did": "did:plc:abc123...",
+
"handle": "mynewsbot.bsky.social",
+
"message": "Aggregator registered successfully. Next step: create a service declaration record at at://did:plc:abc123.../social.coves.aggregator.service/self"
+
}
+
```
+
+
**What happens**:
+
1. Coves fetches `https://rss-bot.example.com/.well-known/atproto-did`
+
2. Verifies it contains your DID
+
3. Resolves your DID to get handle and PDS URL
+
4. Inserts you into the `users` table
+
+
**You're now registered!** But you need to create a service declaration next.
+
+
### Step 4: Create Service Declaration
+
+
Write a `social.coves.aggregator.service` record to your repository. This contains metadata about your aggregator and gets indexed by Coves' Jetstream consumer.
+
+
**Endpoint**: `POST https://your-pds.com/xrpc/com.atproto.repo.createRecord`
+
+
**Request**:
+
```bash
+
curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
+
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
+
-H "Content-Type: application/json" \
+
-d '{
+
"repo": "did:plc:abc123...",
+
"collection": "social.coves.aggregator.service",
+
"rkey": "self",
+
"record": {
+
"$type": "social.coves.aggregator.service",
+
"did": "did:plc:abc123...",
+
"displayName": "RSS News Aggregator",
+
"description": "Aggregates tech news from various RSS feeds",
+
"sourceUrl": "https://github.com/yourname/rss-aggregator",
+
"maintainer": "did:plc:your-personal-did",
+
"createdAt": "2024-01-15T12:00:00Z"
+
}
+
}'
+
```
+
+
**Response**:
+
```json
+
{
+
"uri": "at://did:plc:abc123.../social.coves.aggregator.service/self",
+
"cid": "bafyrei..."
+
}
+
```
+
+
**Optional fields**:
+
- `avatar`: Blob reference to avatar image
+
- `configSchema`: JSON Schema for community-specific configuration
+
+
**Wait 5-10 seconds** for Jetstream to index your service declaration into the `aggregators` table.
+
+
## Authorization Process
+
+
Before you can post to a community, a moderator must authorize your aggregator.
+
+
### How Authorization Works
+
+
1. **Moderator decision**: Community moderator evaluates your aggregator
+
2. **Authorization record**: Moderator writes `social.coves.aggregator.authorization` to community's repo
+
3. **Jetstream indexing**: Record gets indexed into `aggregator_authorizations` table
+
4. **Posting enabled**: You can now post to that community
+
+
### Authorization Record Structure
+
+
**Location**: `at://{community_did}/social.coves.aggregator.authorization/{rkey}`
+
+
**Example**:
+
```json
+
{
+
"$type": "social.coves.aggregator.authorization",
+
"aggregatorDid": "did:plc:abc123...",
+
"communityDid": "did:plc:community123...",
+
"enabled": true,
+
"createdBy": "did:plc:moderator...",
+
"createdAt": "2024-01-15T12:00:00Z",
+
"config": {
+
"maxPostsPerHour": 5,
+
"allowedCategories": ["tech", "news"]
+
}
+
}
+
```
+
+
### Checking Your Authorizations
+
+
**Endpoint**: `GET /xrpc/social.coves.aggregator.getAuthorizations`
+
+
```bash
+
curl "https://api.coves.social/xrpc/social.coves.aggregator.getAuthorizations?aggregatorDid=did:plc:abc123...&enabledOnly=true"
+
```
+
+
**Response**:
+
```json
+
{
+
"authorizations": [
+
{
+
"aggregatorDid": "did:plc:abc123...",
+
"communityDid": "did:plc:community123...",
+
"communityHandle": "~tech@coves.social",
+
"enabled": true,
+
"createdAt": "2024-01-15T12:00:00Z",
+
"config": {...}
+
}
+
]
+
}
+
```
+
+
## Posting to Communities
+
+
Once authorized, you can post to communities using the standard post creation endpoint.
+
+
### Create Post
+
+
**Endpoint**: `POST /xrpc/social.coves.community.post.create`
+
+
**Request**:
+
```bash
+
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
+
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
+
-H "Content-Type: application/json" \
+
-d '{
+
"communityDid": "did:plc:community123...",
+
"post": {
+
"text": "New blog post: Understanding atProto Identity\nhttps://example.com/post",
+
"createdAt": "2024-01-15T12:00:00Z",
+
"facets": [
+
{
+
"index": { "byteStart": 50, "byteEnd": 75 },
+
"features": [
+
{
+
"$type": "social.coves.richtext.facet#link",
+
"uri": "https://example.com/post"
+
}
+
]
+
}
+
]
+
}
+
}'
+
```
+
+
**Response**:
+
```json
+
{
+
"uri": "at://did:plc:abc123.../social.coves.community.post/3k...",
+
"cid": "bafyrei..."
+
}
+
```
+
+
### Post Validation
+
+
The handler validates:
+
1. **Authentication**: Valid JWT token
+
2. **Author exists**: DID in `users` table
+
3. **Is aggregator**: DID in `aggregators` table
+
4. **Authorization**: Active authorization for (aggregator, community)
+
5. **Rate limit**: Less than 10 posts/hour to this community
+
6. **Content**: Valid post structure per lexicon
+
+
### Rate Limits
+
+
**Per-community rate limit**: 10 posts per hour
+
+
This is tracked in the `aggregator_posts` table and enforced at the handler level.
+
+
**Why?**: Prevents spam while allowing useful bot activity.
+
+
**Best practices**:
+
- Batch similar content
+
- Post only high-quality content
+
- Respect community guidelines
+
- Monitor your posting rate
+
+
## Security Best Practices
+
+
### Credential Management
+
+
โœ… **DO**:
+
- Store credentials in environment variables or secret management
+
- Use HTTPS for all API calls
+
- Rotate access tokens regularly (use refresh tokens)
+
- Keep `aggregator-config.env` out of version control
+
+
โŒ **DON'T**:
+
- Hardcode credentials in source code
+
- Commit credentials to Git
+
- Share access tokens publicly
+
- Reuse personal credentials for bots
+
+
### Domain Security
+
+
โœ… **DO**:
+
- Use HTTPS for `.well-known` endpoint
+
- Keep domain under your control
+
- Monitor for unauthorized changes
+
- Use DNSSEC if possible
+
+
โŒ **DON'T**:
+
- Use HTTP (will fail verification)
+
- Use shared/untrusted hosting
+
- Allow others to modify `.well-known` files
+
- Use expired SSL certificates
+
+
### Content Security
+
+
โœ… **DO**:
+
- Validate all external content before posting
+
- Sanitize URLs and text
+
- Rate-limit your own posting
+
- Implement circuit breakers for failures
+
+
โŒ **DON'T**:
+
- Post unvalidated user input
+
- Include malicious links
+
- Spam communities
+
- Bypass rate limits
+
+
## Troubleshooting
+
+
### Registration Errors
+
+
#### Error: "DomainVerificationFailed"
+
+
**Cause**: `.well-known/atproto-did` not accessible or contains wrong DID
+
+
**Solutions**:
+
1. Verify file is accessible: `curl https://yourdomain.com/.well-known/atproto-did`
+
2. Check content matches your DID exactly (no extra whitespace)
+
3. Ensure HTTPS is working (not HTTP)
+
4. Check web server logs for access errors
+
5. Verify firewall rules allow HTTPS traffic
+
+
#### Error: "AlreadyRegistered"
+
+
**Cause**: This DID is already registered with this Coves instance
+
+
**Solutions**:
+
- This is safe to ignore if you're re-running setup
+
- If you need to update info, just create a new service declaration
+
- Contact instance admin if you need to remove registration
+
+
#### Error: "DIDResolutionFailed"
+
+
**Cause**: Could not resolve DID document from PLC directory
+
+
**Solutions**:
+
1. Verify DID exists: `curl https://plc.directory/{your-did}`
+
2. Wait 30 seconds and retry (PLC propagation delay)
+
3. Check PDS is accessible
+
4. Verify DID format is correct (must start with `did:plc:` or `did:web:`)
+
+
### Posting Errors
+
+
#### Error: "NotAuthorized"
+
+
**Cause**: No active authorization for this (aggregator, community) pair
+
+
**Solutions**:
+
1. Check authorizations: `GET /xrpc/social.coves.aggregator.getAuthorizations`
+
2. Contact community moderator to request authorization
+
3. Verify authorization wasn't disabled
+
4. Wait for Jetstream to index authorization (5-10 seconds)
+
+
#### Error: "RateLimitExceeded"
+
+
**Cause**: Exceeded 10 posts/hour to this community
+
+
**Solutions**:
+
1. Wait for the rate limit window to reset
+
2. Batch posts to stay under limit
+
3. Distribute posts across multiple communities
+
4. Implement posting queue in your aggregator
+
+
### Service Declaration Not Appearing
+
+
**Symptoms**: Service declaration created but not in `aggregators` table
+
+
**Solutions**:
+
1. Wait 5-10 seconds for Jetstream to index
+
2. Check Jetstream consumer logs for errors
+
3. Verify record was created: Check PDS at `at://your-did/social.coves.aggregator.service/self`
+
4. Verify `$type` field is exactly `"social.coves.aggregator.service"`
+
5. Check `displayName` is not empty (required field)
+
+
## API Reference
+
+
### Registration Endpoint
+
+
**`POST /xrpc/social.coves.aggregator.register`**
+
+
**Input**:
+
```typescript
+
{
+
did: string // DID of aggregator (did:plc or did:web)
+
domain: string // Domain serving .well-known/atproto-did
+
}
+
```
+
+
**Output**:
+
```typescript
+
{
+
did: string // Registered DID
+
handle: string // Handle from DID document
+
message: string // Next steps message
+
}
+
```
+
+
**Errors**:
+
- `InvalidDID`: DID format invalid
+
- `DomainVerificationFailed`: .well-known verification failed
+
- `AlreadyRegistered`: DID already registered
+
- `DIDResolutionFailed`: Could not resolve DID
+
+
### Query Endpoints
+
+
**`GET /xrpc/social.coves.aggregator.getServices`**
+
+
Get aggregator service details.
+
+
**Parameters**:
+
- `dids`: Array of DIDs (comma-separated)
+
+
**`GET /xrpc/social.coves.aggregator.getAuthorizations`**
+
+
List communities that authorized an aggregator.
+
+
**Parameters**:
+
- `aggregatorDid`: Aggregator DID
+
- `enabledOnly`: Filter to enabled only (default: false)
+
+
**`GET /xrpc/social.coves.aggregator.listForCommunity`**
+
+
List aggregators authorized by a community.
+
+
**Parameters**:
+
- `communityDid`: Community DID
+
- `enabledOnly`: Filter to enabled only (default: false)
+
+
## Further Reading
+
+
- [Aggregator PRD](PRD_AGGREGATORS.md) - Architecture and design decisions
+
- [atProto Guide](../../ATPROTO_GUIDE.md) - atProto fundamentals
+
- [Communities PRD](../PRD_COMMUNITIES.md) - Community system overview
+
- [Setup Scripts README](../../scripts/aggregator-setup/README.md) - Script documentation
+
+
## Support
+
+
For issues or questions:
+
+
1. Check this guide's troubleshooting section
+
2. Review the PRD and architecture docs
+
3. Check Coves GitHub issues
+
4. Ask in Coves developer community
+95
scripts/aggregator-setup/1-create-pds-account.sh
···
+
#!/bin/bash
+
+
# Script: 1-create-pds-account.sh
+
# Purpose: Create a PDS account for your aggregator
+
#
+
# This script helps you create an account on a PDS (Personal Data Server).
+
# The PDS will automatically create a DID:PLC for you.
+
+
set -e
+
+
echo "================================================"
+
echo "Step 1: Create PDS Account for Your Aggregator"
+
echo "================================================"
+
echo ""
+
+
# Get PDS URL
+
read -p "Enter PDS URL (default: https://bsky.social): " PDS_URL
+
PDS_URL=${PDS_URL:-https://bsky.social}
+
+
# Get credentials
+
read -p "Enter desired handle (e.g., mynewsbot.bsky.social): " HANDLE
+
read -p "Enter email: " EMAIL
+
read -sp "Enter password: " PASSWORD
+
echo ""
+
+
# Validate inputs
+
if [ -z "$HANDLE" ] || [ -z "$EMAIL" ] || [ -z "$PASSWORD" ]; then
+
echo "Error: All fields are required"
+
exit 1
+
fi
+
+
echo ""
+
echo "Creating account on $PDS_URL..."
+
+
# Create account via com.atproto.server.createAccount
+
RESPONSE=$(curl -s -X POST "$PDS_URL/xrpc/com.atproto.server.createAccount" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"handle\": \"$HANDLE\",
+
\"email\": \"$EMAIL\",
+
\"password\": \"$PASSWORD\"
+
}")
+
+
# Check if successful
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "Error creating account:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
# Extract DID and access token
+
DID=$(echo "$RESPONSE" | jq -r '.did')
+
ACCESS_JWT=$(echo "$RESPONSE" | jq -r '.accessJwt')
+
REFRESH_JWT=$(echo "$RESPONSE" | jq -r '.refreshJwt')
+
+
if [ -z "$DID" ] || [ "$DID" = "null" ]; then
+
echo "Error: Failed to extract DID from response"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo ""
+
echo "โœ“ Account created successfully!"
+
echo ""
+
echo "=== Save these credentials ===="
+
echo "DID: $DID"
+
echo "Handle: $HANDLE"
+
echo "PDS URL: $PDS_URL"
+
echo "Email: $EMAIL"
+
echo "Password: [hidden]"
+
echo "Access JWT: $ACCESS_JWT"
+
echo "Refresh JWT: $REFRESH_JWT"
+
echo "==============================="
+
echo ""
+
+
# Save to config file
+
CONFIG_FILE="aggregator-config.env"
+
cat > "$CONFIG_FILE" <<EOF
+
# Aggregator Account Configuration
+
# Generated: $(date)
+
+
AGGREGATOR_DID="$DID"
+
AGGREGATOR_HANDLE="$HANDLE"
+
AGGREGATOR_PDS_URL="$PDS_URL"
+
AGGREGATOR_EMAIL="$EMAIL"
+
AGGREGATOR_PASSWORD="$PASSWORD"
+
AGGREGATOR_ACCESS_JWT="$ACCESS_JWT"
+
AGGREGATOR_REFRESH_JWT="$REFRESH_JWT"
+
EOF
+
+
echo "โœ“ Configuration saved to $CONFIG_FILE"
+
echo ""
+
echo "IMPORTANT: Keep this file secure! It contains your credentials."
+
echo ""
+
echo "Next step: Run ./2-setup-wellknown.sh"
+93
scripts/aggregator-setup/2-setup-wellknown.sh
···
+
#!/bin/bash
+
+
# Script: 2-setup-wellknown.sh
+
# Purpose: Generate .well-known/atproto-did file for domain verification
+
#
+
# This script creates the .well-known/atproto-did file that proves you own your domain.
+
# You'll need to host this file at https://yourdomain.com/.well-known/atproto-did
+
+
set -e
+
+
echo "================================================"
+
echo "Step 2: Setup .well-known/atproto-did"
+
echo "================================================"
+
echo ""
+
+
# Load config if available
+
if [ -f "aggregator-config.env" ]; then
+
source aggregator-config.env
+
echo "โœ“ Loaded configuration from aggregator-config.env"
+
echo " DID: $AGGREGATOR_DID"
+
echo ""
+
else
+
echo "Configuration file not found. Please run 1-create-pds-account.sh first."
+
exit 1
+
fi
+
+
# Get domain
+
read -p "Enter your aggregator's domain (e.g., rss-bot.example.com): " DOMAIN
+
+
if [ -z "$DOMAIN" ]; then
+
echo "Error: Domain is required"
+
exit 1
+
fi
+
+
# Save domain to config
+
echo "" >> aggregator-config.env
+
echo "AGGREGATOR_DOMAIN=\"$DOMAIN\"" >> aggregator-config.env
+
+
echo ""
+
echo "Creating .well-known directory..."
+
mkdir -p .well-known
+
+
# Create the atproto-did file
+
echo "$AGGREGATOR_DID" > .well-known/atproto-did
+
+
echo "โœ“ Created .well-known/atproto-did with content: $AGGREGATOR_DID"
+
echo ""
+
+
echo "================================================"
+
echo "Next Steps:"
+
echo "================================================"
+
echo ""
+
echo "1. Upload the .well-known directory to your web server"
+
echo " The file must be accessible at:"
+
echo " https://$DOMAIN/.well-known/atproto-did"
+
echo ""
+
echo "2. Verify it's working by running:"
+
echo " curl https://$DOMAIN/.well-known/atproto-did"
+
echo " (Should return: $AGGREGATOR_DID)"
+
echo ""
+
echo "3. Once verified, run: ./3-register-with-coves.sh"
+
echo ""
+
+
# Create nginx example
+
cat > nginx-example.conf <<EOF
+
# Example nginx configuration for serving .well-known
+
# Add this to your nginx server block:
+
+
location /.well-known/atproto-did {
+
alias /path/to/your/.well-known/atproto-did;
+
default_type text/plain;
+
add_header Access-Control-Allow-Origin *;
+
}
+
EOF
+
+
echo "โœ“ Created nginx-example.conf for reference"
+
echo ""
+
+
# Create Apache example
+
cat > apache-example.conf <<EOF
+
# Example Apache configuration for serving .well-known
+
# Add this to your Apache virtual host:
+
+
Alias /.well-known /path/to/your/.well-known
+
<Directory /path/to/your/.well-known>
+
Options None
+
AllowOverride None
+
Require all granted
+
Header set Access-Control-Allow-Origin "*"
+
</Directory>
+
EOF
+
+
echo "โœ“ Created apache-example.conf for reference"
+103
scripts/aggregator-setup/3-register-with-coves.sh
···
+
#!/bin/bash
+
+
# Script: 3-register-with-coves.sh
+
# Purpose: Register your aggregator with a Coves instance
+
#
+
# This script calls the social.coves.aggregator.register XRPC endpoint
+
# to register your aggregator DID with the Coves instance.
+
+
set -e
+
+
echo "================================================"
+
echo "Step 3: Register with Coves Instance"
+
echo "================================================"
+
echo ""
+
+
# Load config if available
+
if [ -f "aggregator-config.env" ]; then
+
source aggregator-config.env
+
echo "โœ“ Loaded configuration from aggregator-config.env"
+
echo " DID: $AGGREGATOR_DID"
+
echo " Domain: $AGGREGATOR_DOMAIN"
+
echo ""
+
else
+
echo "Configuration file not found. Please run previous scripts first."
+
exit 1
+
fi
+
+
# Validate domain is set
+
if [ -z "$AGGREGATOR_DOMAIN" ]; then
+
echo "Error: AGGREGATOR_DOMAIN not set. Please run 2-setup-wellknown.sh first."
+
exit 1
+
fi
+
+
# Get Coves instance URL
+
read -p "Enter Coves instance URL (default: https://api.coves.social): " COVES_URL
+
COVES_URL=${COVES_URL:-https://api.coves.social}
+
+
echo ""
+
echo "Verifying .well-known/atproto-did is accessible..."
+
+
# Verify .well-known is accessible
+
WELLKNOWN_URL="https://$AGGREGATOR_DOMAIN/.well-known/atproto-did"
+
WELLKNOWN_CONTENT=$(curl -s "$WELLKNOWN_URL" || echo "ERROR")
+
+
if [ "$WELLKNOWN_CONTENT" = "ERROR" ]; then
+
echo "โœ— Error: Could not access $WELLKNOWN_URL"
+
echo " Please ensure the file is uploaded and accessible."
+
exit 1
+
elif [ "$WELLKNOWN_CONTENT" != "$AGGREGATOR_DID" ]; then
+
echo "โœ— Error: .well-known/atproto-did contains wrong DID"
+
echo " Expected: $AGGREGATOR_DID"
+
echo " Got: $WELLKNOWN_CONTENT"
+
exit 1
+
fi
+
+
echo "โœ“ .well-known/atproto-did is correctly configured"
+
echo ""
+
+
echo "Registering with $COVES_URL..."
+
+
# Call registration endpoint
+
RESPONSE=$(curl -s -X POST "$COVES_URL/xrpc/social.coves.aggregator.register" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"did\": \"$AGGREGATOR_DID\",
+
\"domain\": \"$AGGREGATOR_DOMAIN\"
+
}")
+
+
# Check if successful
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Registration failed:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
# Extract response
+
REGISTERED_DID=$(echo "$RESPONSE" | jq -r '.did')
+
REGISTERED_HANDLE=$(echo "$RESPONSE" | jq -r '.handle')
+
MESSAGE=$(echo "$RESPONSE" | jq -r '.message')
+
+
if [ -z "$REGISTERED_DID" ] || [ "$REGISTERED_DID" = "null" ]; then
+
echo "โœ— Error: Unexpected response format"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo ""
+
echo "โœ“ Registration successful!"
+
echo ""
+
echo "=== Registration Details ===="
+
echo "DID: $REGISTERED_DID"
+
echo "Handle: $REGISTERED_HANDLE"
+
echo "Message: $MESSAGE"
+
echo "============================="
+
echo ""
+
+
# Save Coves URL to config
+
echo "" >> aggregator-config.env
+
echo "COVES_INSTANCE_URL=\"$COVES_URL\"" >> aggregator-config.env
+
+
echo "โœ“ Updated aggregator-config.env with Coves instance URL"
+
echo ""
+
echo "Next step: Run ./4-create-service-declaration.sh"
+125
scripts/aggregator-setup/4-create-service-declaration.sh
···
+
#!/bin/bash
+
+
# Script: 4-create-service-declaration.sh
+
# Purpose: Create aggregator service declaration record
+
#
+
# This script writes a social.coves.aggregator.service record to your aggregator's repository.
+
# This record contains metadata about your aggregator (name, description, etc.) and will be
+
# indexed by Coves' Jetstream consumer into the aggregators table.
+
+
set -e
+
+
echo "================================================"
+
echo "Step 4: Create Service Declaration"
+
echo "================================================"
+
echo ""
+
+
# Load config if available
+
if [ -f "aggregator-config.env" ]; then
+
source aggregator-config.env
+
echo "โœ“ Loaded configuration from aggregator-config.env"
+
echo " DID: $AGGREGATOR_DID"
+
echo " PDS URL: $AGGREGATOR_PDS_URL"
+
echo ""
+
else
+
echo "Configuration file not found. Please run previous scripts first."
+
exit 1
+
fi
+
+
# Validate required fields
+
if [ -z "$AGGREGATOR_ACCESS_JWT" ]; then
+
echo "Error: AGGREGATOR_ACCESS_JWT not set. Please run 1-create-pds-account.sh first."
+
exit 1
+
fi
+
+
echo "Enter aggregator metadata:"
+
echo ""
+
+
# Get metadata from user
+
read -p "Display Name (e.g., 'RSS News Aggregator'): " DISPLAY_NAME
+
read -p "Description: " DESCRIPTION
+
read -p "Source URL (e.g., 'https://github.com/yourname/aggregator'): " SOURCE_URL
+
read -p "Maintainer DID (your personal DID, optional): " MAINTAINER_DID
+
+
if [ -z "$DISPLAY_NAME" ]; then
+
echo "Error: Display name is required"
+
exit 1
+
fi
+
+
echo ""
+
echo "Creating service declaration record..."
+
+
# Build the service record
+
SERVICE_RECORD=$(cat <<EOF
+
{
+
"\$type": "social.coves.aggregator.service",
+
"did": "$AGGREGATOR_DID",
+
"displayName": "$DISPLAY_NAME",
+
"description": "$DESCRIPTION",
+
"sourceUrl": "$SOURCE_URL",
+
"maintainer": "$MAINTAINER_DID",
+
"createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+
}
+
EOF
+
)
+
+
# Call com.atproto.repo.createRecord
+
RESPONSE=$(curl -s -X POST "$AGGREGATOR_PDS_URL/xrpc/com.atproto.repo.createRecord" \
+
-H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"repo\": \"$AGGREGATOR_DID\",
+
\"collection\": \"social.coves.aggregator.service\",
+
\"rkey\": \"self\",
+
\"record\": $SERVICE_RECORD
+
}")
+
+
# Check if successful
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Failed to create service declaration:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
# Extract response
+
RECORD_URI=$(echo "$RESPONSE" | jq -r '.uri')
+
RECORD_CID=$(echo "$RESPONSE" | jq -r '.cid')
+
+
if [ -z "$RECORD_URI" ] || [ "$RECORD_URI" = "null" ]; then
+
echo "โœ— Error: Unexpected response format"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo ""
+
echo "โœ“ Service declaration created successfully!"
+
echo ""
+
echo "=== Record Details ===="
+
echo "URI: $RECORD_URI"
+
echo "CID: $RECORD_CID"
+
echo "======================="
+
echo ""
+
+
# Save to config
+
echo "" >> aggregator-config.env
+
echo "SERVICE_DECLARATION_URI=\"$RECORD_URI\"" >> aggregator-config.env
+
echo "SERVICE_DECLARATION_CID=\"$RECORD_CID\"" >> aggregator-config.env
+
+
echo "โœ“ Updated aggregator-config.env"
+
echo ""
+
echo "================================================"
+
echo "Setup Complete!"
+
echo "================================================"
+
echo ""
+
echo "Your aggregator is now registered with Coves!"
+
echo ""
+
echo "Next steps:"
+
echo "1. Wait a few seconds for Jetstream to index your service declaration"
+
echo "2. Verify your aggregator appears in the aggregators list"
+
echo "3. Community moderators can now authorize your aggregator"
+
echo "4. Once authorized, you can start posting to communities"
+
echo ""
+
echo "To test posting, use the Coves XRPC endpoint:"
+
echo " POST $COVES_INSTANCE_URL/xrpc/social.coves.community.post.create"
+
echo ""
+
echo "See docs/aggregators/SETUP_GUIDE.md for more information"
+252
scripts/aggregator-setup/README.md
···
+
# Aggregator Setup Scripts
+
+
This directory contains scripts to help you set up and register your aggregator with Coves instances.
+
+
## Overview
+
+
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`
+
3. Register with a Coves instance
+
4. Create a service declaration record
+
+
These scripts automate this process for you.
+
+
## 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
+
+
## Quick Start
+
+
### Interactive Setup (Recommended)
+
+
Run the scripts in order:
+
+
```bash
+
# Make scripts executable
+
chmod +x *.sh
+
+
# 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
+
+
# Step 4: Create service declaration
+
./4-create-service-declaration.sh
+
```
+
+
### 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).
+
+
The Kagi script shows how to automate all 4 steps (with the manual .well-known upload step in between).
+
+
## Script Reference
+
+
### 1-create-pds-account.sh
+
+
**Purpose**: Creates a PDS account for your aggregator
+
+
**Prompts for**:
+
- PDS URL (default: https://bsky.social)
+
- Handle (e.g., mynewsbot.bsky.social)
+
- Email
+
- Password
+
+
**Outputs**:
+
- `aggregator-config.env` - Configuration file with DID and credentials
+
- Prints your DID and access tokens
+
+
**Notes**:
+
- Keep the config file secure! It contains your credentials
+
- The PDS automatically generates a DID:PLC for you
+
- You can use any PDS service, not just bsky.social
+
+
### 2-setup-wellknown.sh
+
+
**Purpose**: Generates the `.well-known/atproto-did` file for domain verification
+
+
**Prompts for**:
+
- Your domain (e.g., rss-bot.example.com)
+
+
**Outputs**:
+
- `.well-known/atproto-did` - File containing your DID
+
- `nginx-example.conf` - Example nginx configuration
+
- `apache-example.conf` - Example Apache configuration
+
+
**Manual step required**:
+
Upload the `.well-known` directory to your web server. The file must be accessible at:
+
```
+
https://yourdomain.com/.well-known/atproto-did
+
```
+
+
**Verify it works**:
+
```bash
+
curl https://yourdomain.com/.well-known/atproto-did
+
# Should return your DID (e.g., did:plc:abc123...)
+
```
+
+
### 3-register-with-coves.sh
+
+
**Purpose**: Registers your aggregator with a Coves instance
+
+
**Prompts for**:
+
- Coves instance URL (default: https://api.coves.social)
+
+
**Prerequisites**:
+
- `.well-known/atproto-did` must be accessible from your domain
+
- Scripts 1 and 2 must be completed
+
+
**What it does**:
+
1. Verifies your `.well-known/atproto-did` is accessible
+
2. Calls `social.coves.aggregator.register` XRPC endpoint
+
3. Coves verifies domain ownership
+
4. Inserts your aggregator into the `users` table
+
+
**Outputs**:
+
- Updates `aggregator-config.env` with Coves instance URL
+
- Prints registration confirmation
+
+
### 4-create-service-declaration.sh
+
+
**Purpose**: Creates the service declaration record in your repository
+
+
**Prompts for**:
+
- Display name (e.g., "RSS News Aggregator")
+
- Description
+
- Source URL (GitHub repo, etc.)
+
- Maintainer DID (optional)
+
+
**What it does**:
+
1. Creates a `social.coves.aggregator.service` record at `at://your-did/social.coves.aggregator.service/self`
+
2. Jetstream consumer will index this into the `aggregators` table
+
3. Communities can now discover and authorize your aggregator
+
+
**Outputs**:
+
- Updates `aggregator-config.env` with record URI and CID
+
- Prints record details
+
+
## Configuration File
+
+
After running the scripts, you'll have an `aggregator-config.env` file with:
+
+
```bash
+
AGGREGATOR_DID="did:plc:..."
+
AGGREGATOR_HANDLE="mynewsbot.bsky.social"
+
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"
+
SERVICE_DECLARATION_URI="at://did:plc:.../social.coves.aggregator.service/self"
+
SERVICE_DECLARATION_CID="..."
+
```
+
+
**Use this in your aggregator code** to authenticate and post.
+
+
## What Happens Next?
+
+
After completing all 4 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
+
+
## Creating an Authorization
+
+
Authorizations are created by community moderators, not by aggregators. The moderator writes a `social.coves.aggregator.authorization` record to their community's repository.
+
+
See `docs/aggregators/SETUP_GUIDE.md` for more information on the authorization process.
+
+
## Posting to Communities
+
+
Once authorized, your aggregator can post using:
+
+
```bash
+
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
+
-H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \
+
-H "Content-Type: application/json" \
+
-d '{
+
"communityDid": "did:plc:...",
+
"post": {
+
"text": "Your post content",
+
"createdAt": "2024-01-15T12:00:00Z"
+
}
+
}'
+
```
+
+
## Troubleshooting
+
+
### Error: "DomainVerificationFailed"
+
+
- Verify `.well-known/atproto-did` is accessible: `curl https://yourdomain.com/.well-known/atproto-did`
+
- Check the content matches your DID exactly (no extra whitespace)
+
- Ensure HTTPS is working (not HTTP)
+
- Check CORS headers if accessing from browser
+
+
### Error: "AlreadyRegistered"
+
+
- You've already registered this DID with this Coves instance
+
- This is safe to ignore if you're re-running the setup
+
+
### Error: "DIDResolutionFailed"
+
+
- Your DID might be invalid or not found in the PLC directory
+
- Verify your DID exists: `curl https://plc.directory/<your-did>`
+
- Wait a few seconds and try again (PLC directory might be propagating)
+
+
### Service declaration not appearing
+
+
- Wait 5-10 seconds for Jetstream consumer to index it
+
- Check the Jetstream logs for errors
+
- Verify the record was created: Check your PDS at `at://your-did/social.coves.aggregator.service/self`
+
+
## Example: Kagi News Aggregator
+
+
For a complete reference implementation, see the Kagi News aggregator at `aggregators/kagi-news/`.
+
+
The Kagi aggregator includes an automated setup script at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh) that demonstrates how to:
+
+
- Automate the entire registration process
+
- Use environment variables for configuration
+
- Handle errors gracefully
+
- Integrate the setup into your aggregator project
+
+
This shows how you can package scripts 1-4 into a single automated flow for your specific aggregator.
+
+
## Security Notes
+
+
- **Never commit `aggregator-config.env`** to version control
+
- Store credentials securely (use environment variables or secret management)
+
- Rotate access tokens regularly
+
- Use HTTPS for all API calls
+
- Validate community authorization before posting
+
+
## More Information
+
+
- [Aggregator Setup Guide](../../docs/aggregators/SETUP_GUIDE.md)
+
- [Aggregator PRD](../../docs/aggregators/PRD_AGGREGATORS.md)
+
- [atProto Identity Guide](../../ATPROTO_GUIDE.md)
+
- [Coves Communities PRD](../../docs/PRD_COMMUNITIES.md)
+
+
## Support
+
+
If you encounter issues:
+
+
1. Check the troubleshooting section above
+
2. Review the full documentation in `docs/aggregators/`
+
3. Open an issue on GitHub with:
+
- Which script failed
+
- Error message
+
- Your domain (without credentials)
+188 -1
aggregators/kagi-news/README.md
···
โ”‚ โ”œโ”€โ”€ sample_rss_item.xml
โ”‚ โ””โ”€โ”€ world.xml
โ”œโ”€โ”€ scripts/
-
โ”‚ โ””โ”€โ”€ generate_did.py # Helper to generate aggregator DID (TODO)
+
โ”‚ โ””โ”€โ”€ setup.sh # Automated Coves registration script
+
โ”œโ”€โ”€ Dockerfile # Docker image definition
+
โ”œโ”€โ”€ docker-compose.yml # Docker Compose configuration
+
โ”œโ”€โ”€ docker-entrypoint.sh # Container entrypoint script
+
โ”œโ”€โ”€ .dockerignore # Docker build exclusions
โ”œโ”€โ”€ requirements.txt # Python dependencies
โ”œโ”€โ”€ config.example.yaml # Example configuration
โ”œโ”€โ”€ .env.example # Environment variables template
···
โ””โ”€โ”€ README.md
```
+
## Registration with Coves
+
+
Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves.
+
+
### Quick Setup (Automated)
+
+
The automated setup script handles the entire registration process:
+
+
```bash
+
cd scripts
+
chmod +x setup.sh
+
./setup.sh
+
```
+
+
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)
+
+
**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`.
+
+
After completion, you'll have a `kagi-aggregator-config.env` file with:
+
- Aggregator DID and credentials
+
- Access/refresh JWTs
+
- Service declaration URI
+
+
**Keep this file secure!** It contains your aggregator's credentials.
+
+
### Manual Setup (Step-by-step)
+
+
Alternatively, use the generic setup scripts from the main Coves repo for more control:
+
+
```bash
+
# From the Coves project root
+
cd scripts/aggregator-setup
+
+
# Follow the 4-step process
+
./1-create-pds-account.sh
+
./2-setup-wellknown.sh
+
./3-register-with-coves.sh
+
./4-create-service-declaration.sh
+
```
+
+
See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step.
+
+
### What Happens During Registration?
+
+
1. **PDS Account Creation**: Your aggregator gets a `did:plc:...` identifier
+
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
+
+
Once registered and authorized by a community, your aggregator can post content.
+
## Setup
### Prerequisites
- Python 3.11+
- python3-venv package (`apt install python3.12-venv`)
+
- **Completed registration** (see above)
### Installation
···
pytest --cov=src --cov-report=html
```
+
## Deployment
+
+
### Docker Deployment (Recommended for Production)
+
+
The easiest way to deploy the Kagi aggregator is using Docker. The cron job runs inside the container automatically.
+
+
#### Prerequisites
+
+
- Docker and Docker Compose installed
+
- Completed registration (you have `.env` with credentials)
+
- `config.yaml` configured with your feed mappings
+
+
#### Quick Start
+
+
1. **Configure your environment:**
+
```bash
+
# Copy and edit configuration
+
cp config.example.yaml config.yaml
+
cp .env.example .env
+
+
# Edit .env with your aggregator credentials
+
nano .env
+
```
+
+
2. **Start the aggregator:**
+
```bash
+
docker compose up -d
+
```
+
+
3. **View logs:**
+
```bash
+
docker compose logs -f
+
```
+
+
4. **Stop the aggregator:**
+
```bash
+
docker compose down
+
```
+
+
#### Configuration
+
+
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_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)
+
+
#### Testing the Setup
+
+
Run the aggregator immediately without waiting for cron:
+
+
```bash
+
# Run once and exit
+
docker compose run --rm kagi-aggregator python -m src.main
+
+
# Or set RUN_ON_STARTUP=true in .env and restart
+
docker compose restart
+
```
+
+
#### Production Deployment
+
+
For production, consider:
+
+
1. **Using Docker Secrets** for credentials:
+
```yaml
+
secrets:
+
aggregator_credentials:
+
file: ./secrets/aggregator.env
+
```
+
+
2. **Setting up log rotation** (already configured in docker-compose.yml):
+
- Max size: 10MB per file
+
- Max files: 3
+
+
3. **Monitoring health checks:**
+
```bash
+
docker inspect --format='{{.State.Health.Status}}' kagi-news-aggregator
+
```
+
+
4. **Auto-restart on failure** (already enabled with `restart: unless-stopped`)
+
+
#### Viewing Cron Logs
+
+
```bash
+
# Follow cron execution logs
+
docker compose logs -f kagi-aggregator
+
+
# View last 100 lines
+
docker compose logs --tail=100 kagi-aggregator
+
```
+
+
#### Updating the Aggregator
+
+
```bash
+
# Pull latest code
+
git pull
+
+
# Rebuild and restart
+
docker compose up -d --build
+
```
+
+
### Manual Deployment (Alternative)
+
+
If you prefer running without Docker, use the traditional approach:
+
+
1. **Install dependencies:**
+
```bash
+
python3 -m venv venv
+
source venv/bin/activate
+
pip install -r requirements.txt
+
```
+
+
2. **Configure crontab:**
+
```bash
+
# Edit the crontab file with your paths
+
# Then install it:
+
crontab crontab
+
```
+
+
3. **Verify cron is running:**
+
```bash
+
crontab -l
+
```
+
## Development Status
### โœ… Phase 1-2 Complete (Oct 24, 2025)
+195
aggregators/kagi-news/scripts/setup.sh
···
+
#!/bin/bash
+
+
# Script: setup-kagi-aggregator.sh
+
# Purpose: Complete setup script for Kagi News RSS aggregator
+
#
+
# This is a reference implementation showing automated setup for a specific aggregator.
+
# Other aggregator developers can use this as a template.
+
+
set -e
+
+
echo "================================================"
+
echo "Kagi News RSS Aggregator - Automated Setup"
+
echo "================================================"
+
echo ""
+
+
# Configuration for Kagi aggregator
+
AGGREGATOR_NAME="kagi-news-bot"
+
DISPLAY_NAME="Kagi News RSS"
+
DESCRIPTION="Aggregates tech news from Kagi RSS feeds and posts to relevant communities"
+
SOURCE_URL="https://github.com/coves-social/kagi-aggregator"
+
+
# Check if config already exists
+
if [ -f "kagi-aggregator-config.env" ]; then
+
echo "Configuration file already exists. Loading existing configuration..."
+
source kagi-aggregator-config.env
+
SKIP_ACCOUNT_CREATION=true
+
else
+
SKIP_ACCOUNT_CREATION=false
+
fi
+
+
# Get runtime configuration
+
if [ "$SKIP_ACCOUNT_CREATION" = false ]; then
+
read -p "Enter PDS URL (default: https://bsky.social): " PDS_URL
+
PDS_URL=${PDS_URL:-https://bsky.social}
+
+
read -p "Enter email for bot account: " EMAIL
+
read -sp "Enter password for bot account: " PASSWORD
+
echo ""
+
+
# Generate handle
+
TIMESTAMP=$(date +%s)
+
HANDLE="$AGGREGATOR_NAME-$TIMESTAMP.bsky.social"
+
+
echo ""
+
echo "Creating PDS account..."
+
echo "Handle: $HANDLE"
+
+
# Create account
+
RESPONSE=$(curl -s -X POST "$PDS_URL/xrpc/com.atproto.server.createAccount" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"handle\": \"$HANDLE\",
+
\"email\": \"$EMAIL\",
+
\"password\": \"$PASSWORD\"
+
}")
+
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Error creating account:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
DID=$(echo "$RESPONSE" | jq -r '.did')
+
ACCESS_JWT=$(echo "$RESPONSE" | jq -r '.accessJwt')
+
REFRESH_JWT=$(echo "$RESPONSE" | jq -r '.refreshJwt')
+
+
echo "โœ“ Account created: $DID"
+
+
# Save configuration
+
cat > kagi-aggregator-config.env <<EOF
+
# Kagi Aggregator Configuration
+
AGGREGATOR_DID="$DID"
+
AGGREGATOR_HANDLE="$HANDLE"
+
AGGREGATOR_PDS_URL="$PDS_URL"
+
AGGREGATOR_EMAIL="$EMAIL"
+
AGGREGATOR_PASSWORD="$PASSWORD"
+
AGGREGATOR_ACCESS_JWT="$ACCESS_JWT"
+
AGGREGATOR_REFRESH_JWT="$REFRESH_JWT"
+
EOF
+
+
echo "โœ“ Configuration saved to kagi-aggregator-config.env"
+
fi
+
+
# Get domain and Coves instance
+
read -p "Enter aggregator domain (e.g., kagi-news.example.com): " DOMAIN
+
read -p "Enter Coves instance URL (default: https://api.coves.social): " COVES_URL
+
COVES_URL=${COVES_URL:-https://api.coves.social}
+
+
# Setup .well-known
+
echo ""
+
echo "Setting up .well-known/atproto-did..."
+
mkdir -p .well-known
+
echo "$DID" > .well-known/atproto-did
+
echo "โœ“ Created .well-known/atproto-did"
+
+
echo ""
+
echo "================================================"
+
echo "IMPORTANT: Manual Step Required"
+
echo "================================================"
+
echo ""
+
echo "Upload the .well-known directory to your web server at:"
+
echo " https://$DOMAIN/.well-known/atproto-did"
+
echo ""
+
read -p "Press Enter when the file is uploaded and accessible..."
+
+
# Verify .well-known
+
echo ""
+
echo "Verifying .well-known/atproto-did..."
+
WELLKNOWN_CONTENT=$(curl -s "https://$DOMAIN/.well-known/atproto-did" || echo "ERROR")
+
+
if [ "$WELLKNOWN_CONTENT" != "$DID" ]; then
+
echo "โœ— Error: .well-known/atproto-did not accessible or contains wrong DID"
+
echo " Expected: $DID"
+
echo " Got: $WELLKNOWN_CONTENT"
+
exit 1
+
fi
+
+
echo "โœ“ .well-known/atproto-did verified"
+
+
# Register with Coves
+
echo ""
+
echo "Registering with Coves instance..."
+
RESPONSE=$(curl -s -X POST "$COVES_URL/xrpc/social.coves.aggregator.register" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"did\": \"$DID\",
+
\"domain\": \"$DOMAIN\"
+
}")
+
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Registration failed:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo "โœ“ Registered with Coves"
+
+
# Create service declaration
+
echo ""
+
echo "Creating service declaration..."
+
SERVICE_RECORD=$(cat <<EOF
+
{
+
"\$type": "social.coves.aggregator.service",
+
"did": "$DID",
+
"displayName": "$DISPLAY_NAME",
+
"description": "$DESCRIPTION",
+
"sourceUrl": "$SOURCE_URL",
+
"createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+
}
+
EOF
+
)
+
+
RESPONSE=$(curl -s -X POST "$PDS_URL/xrpc/com.atproto.repo.createRecord" \
+
-H "Authorization: Bearer $ACCESS_JWT" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"repo\": \"$DID\",
+
\"collection\": \"social.coves.aggregator.service\",
+
\"rkey\": \"self\",
+
\"record\": $SERVICE_RECORD
+
}")
+
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Failed to create service declaration:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
RECORD_URI=$(echo "$RESPONSE" | jq -r '.uri')
+
echo "โœ“ Service declaration created: $RECORD_URI"
+
+
# Save final configuration
+
cat >> kagi-aggregator-config.env <<EOF
+
+
# Setup completed on $(date)
+
AGGREGATOR_DOMAIN="$DOMAIN"
+
COVES_INSTANCE_URL="$COVES_URL"
+
SERVICE_DECLARATION_URI="$RECORD_URI"
+
EOF
+
+
echo ""
+
echo "================================================"
+
echo "โœ“ Kagi Aggregator Setup Complete!"
+
echo "================================================"
+
echo ""
+
echo "Configuration saved to: kagi-aggregator-config.env"
+
echo ""
+
echo "Your aggregator is now registered and ready to use."
+
echo ""
+
echo "Next steps:"
+
echo "1. Start your aggregator bot: npm start (or appropriate command)"
+
echo "2. Community moderators can authorize your aggregator"
+
echo "3. Once authorized, your bot can start posting"
+
echo ""
+
echo "See docs/aggregators/SETUP_GUIDE.md for more information"
+55
aggregators/kagi-news/.dockerignore
···
+
# Git
+
.git
+
.gitignore
+
+
# Python
+
__pycache__
+
*.py[cod]
+
*$py.class
+
*.so
+
.Python
+
venv/
+
*.egg-info
+
dist/
+
build/
+
+
# Testing
+
.pytest_cache/
+
.coverage
+
htmlcov/
+
.tox/
+
.hypothesis/
+
+
# IDE
+
.vscode/
+
.idea/
+
*.swp
+
*.swo
+
*~
+
+
# Environment
+
.env.local
+
.env.*.local
+
+
# Data and logs
+
data/
+
*.log
+
+
# Documentation
+
README.md
+
docs/
+
+
# Docker
+
Dockerfile
+
docker-compose.yml
+
.dockerignore
+
+
# Development
+
tests/
+
pytest.ini
+
mypy.ini
+
.mypy_cache/
+
+
# OS
+
.DS_Store
+
Thumbs.db
+53
aggregators/kagi-news/Dockerfile
···
+
# Kagi News RSS Aggregator
+
# Production-ready Docker image with cron scheduler
+
+
FROM python:3.11-slim
+
+
# Install cron and other utilities
+
RUN apt-get update && apt-get install -y \
+
cron \
+
curl \
+
procps \
+
&& rm -rf /var/lib/apt/lists/*
+
+
# Set working directory
+
WORKDIR /app
+
+
# Copy requirements first for better caching
+
COPY requirements.txt .
+
+
# Install Python dependencies (exclude dev/test deps in production)
+
RUN pip install --no-cache-dir \
+
feedparser==6.0.11 \
+
beautifulsoup4==4.12.3 \
+
requests==2.31.0 \
+
atproto==0.0.55 \
+
pyyaml==6.0.1
+
+
# Copy application code
+
COPY src/ ./src/
+
COPY config.yaml ./
+
+
# Copy crontab file
+
COPY crontab /etc/cron.d/kagi-aggregator
+
+
# Give execution rights on the cron job and apply it
+
RUN chmod 0644 /etc/cron.d/kagi-aggregator && \
+
crontab /etc/cron.d/kagi-aggregator
+
+
# Create log file to be able to run tail
+
RUN touch /var/log/cron.log
+
+
# Copy entrypoint script
+
COPY docker-entrypoint.sh /usr/local/bin/
+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+
# Health check - verify cron is running
+
HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \
+
CMD pgrep cron || exit 1
+
+
# Run the entrypoint script
+
ENTRYPOINT ["docker-entrypoint.sh"]
+
+
# Default command: tail the cron log
+
CMD ["tail", "-f", "/var/log/cron.log"]
+48
aggregators/kagi-news/docker-compose.yml
···
+
services:
+
kagi-aggregator:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
container_name: kagi-news-aggregator
+
restart: unless-stopped
+
+
# Environment variables - override in .env file or here
+
environment:
+
# Required: Aggregator credentials
+
- AGGREGATOR_HANDLE=${AGGREGATOR_HANDLE}
+
- AGGREGATOR_PASSWORD=${AGGREGATOR_PASSWORD}
+
+
# Optional: Override Coves API URL
+
- COVES_API_URL=${COVES_API_URL:-https://api.coves.social}
+
+
# Optional: Run immediately on startup (useful for testing)
+
- RUN_ON_STARTUP=${RUN_ON_STARTUP:-false}
+
+
# Mount config file if you want to modify it without rebuilding
+
volumes:
+
- ./config.yaml:/app/config.yaml:ro
+
- ./data:/app/data # For state persistence (if implemented)
+
+
# Use env_file to load credentials from .env
+
env_file:
+
- .env
+
+
# Logging configuration
+
logging:
+
driver: "json-file"
+
options:
+
max-size: "10m"
+
max-file: "3"
+
+
# Health check
+
healthcheck:
+
test: ["CMD", "pgrep", "cron"]
+
interval: 60s
+
timeout: 10s
+
retries: 3
+
start_period: 10s
+
+
# Optional: Networks for multi-container setups
+
# networks:
+
# coves:
+
# external: true
+41
aggregators/kagi-news/docker-entrypoint.sh
···
+
#!/bin/bash
+
set -e
+
+
echo "Starting Kagi News RSS Aggregator..."
+
echo "========================================="
+
+
# Load environment variables if .env file exists
+
if [ -f /app/.env ]; then
+
echo "Loading environment variables from .env"
+
export $(grep -v '^#' /app/.env | xargs)
+
fi
+
+
# Validate required environment variables
+
if [ -z "$AGGREGATOR_HANDLE" ] || [ -z "$AGGREGATOR_PASSWORD" ]; then
+
echo "ERROR: Missing required environment variables!"
+
echo "Please set AGGREGATOR_HANDLE and AGGREGATOR_PASSWORD"
+
exit 1
+
fi
+
+
echo "Aggregator Handle: $AGGREGATOR_HANDLE"
+
echo "Cron schedule loaded from /etc/cron.d/kagi-aggregator"
+
+
# Start cron in the background
+
echo "Starting cron daemon..."
+
cron
+
+
# Optional: Run aggregator immediately on startup (for testing)
+
if [ "$RUN_ON_STARTUP" = "true" ]; then
+
echo "Running aggregator immediately (RUN_ON_STARTUP=true)..."
+
cd /app && python -m src.main
+
fi
+
+
echo "========================================="
+
echo "Kagi News Aggregator is running!"
+
echo "Cron schedule: Daily at 1 PM UTC"
+
echo "Logs will appear below:"
+
echo "========================================="
+
echo ""
+
+
# Execute the command passed to docker run (defaults to tail -f /var/log/cron.log)
+
exec "$@"
+20
.beads/.gitignore
···
+
# SQLite databases
+
*.db
+
*.db-journal
+
*.db-wal
+
*.db-shm
+
+
# Daemon runtime files
+
daemon.lock
+
daemon.log
+
daemon.pid
+
bd.sock
+
+
# Legacy database files
+
db.sqlite
+
bd.db
+
+
# Keep JSONL exports and config (source of truth for git)
+
!*.jsonl
+
!metadata.json
+
!config.json
+56
.beads/config.yaml
···
+
# Beads Configuration File
+
# This file configures default behavior for all bd commands in this repository
+
# All settings can also be set via environment variables (BD_* prefix)
+
# or overridden with command-line flags
+
+
# Issue prefix for this repository (used by bd init)
+
# If not set, bd init will auto-detect from directory name
+
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
+
# issue-prefix: ""
+
+
# Use no-db mode: load from JSONL, no SQLite, write back after each command
+
# When true, bd will use .beads/issues.jsonl as the source of truth
+
# instead of SQLite database
+
# no-db: false
+
+
# Disable daemon for RPC communication (forces direct database access)
+
# no-daemon: false
+
+
# Disable auto-flush of database to JSONL after mutations
+
# no-auto-flush: false
+
+
# Disable auto-import from JSONL when it's newer than database
+
# no-auto-import: false
+
+
# Enable JSON output by default
+
# json: false
+
+
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
+
# actor: ""
+
+
# Path to database (overridden by BEADS_DB or --db)
+
# db: ""
+
+
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
+
# auto-start-daemon: true
+
+
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
+
# flush-debounce: "5s"
+
+
# Multi-repo configuration (experimental - bd-307)
+
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
+
# repos:
+
# primary: "." # Primary repo (where this database lives)
+
# additional: # Additional repos to hydrate from (read-only)
+
# - ~/beads-planning # Personal planning repo
+
# - ~/work-planning # Work planning repo
+
+
# Integration settings (access with 'bd config get/set')
+
# These are stored in the database, not in this file:
+
# - jira.url
+
# - jira.project
+
# - linear.url
+
# - linear.api-key
+
# - github.org
+
# - github.repo
+
# - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set)
+4
.beads/metadata.json
···
+
{
+
"database": "beads.db",
+
"jsonl_export": "beads.jsonl"
+
}
+3
.gitattributes
···
+
+
# Use bd merge for beads JSONL files
+
.beads/beads.jsonl merge=beads
+131
AGENTS.md
···
+
# AI Agent Guidelines for Coves
+
+
## Issue Tracking with bd (beads)
+
+
**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.
+
+
### Why bd?
+
+
- Dependency-aware: Track blockers and relationships between issues
+
- Git-friendly: Auto-syncs to JSONL for version control
+
- Agent-optimized: JSON output, ready work detection, discovered-from links
+
- Prevents duplicate tracking systems and confusion
+
+
### Quick Start
+
+
**Check for ready work:**
+
```bash
+
bd ready --json
+
```
+
+
**Create new issues:**
+
```bash
+
bd create "Issue title" -t bug|feature|task -p 0-4 --json
+
bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json
+
```
+
+
**Claim and update:**
+
```bash
+
bd update bd-42 --status in_progress --json
+
bd update bd-42 --priority 1 --json
+
```
+
+
**Complete work:**
+
```bash
+
bd close bd-42 --reason "Completed" --json
+
```
+
+
### Issue Types
+
+
- `bug` - Something broken
+
- `feature` - New functionality
+
- `task` - Work item (tests, docs, refactoring)
+
- `epic` - Large feature with subtasks
+
- `chore` - Maintenance (dependencies, tooling)
+
+
### Priorities
+
+
- `0` - Critical (security, data loss, broken builds)
+
- `1` - High (major features, important bugs)
+
- `2` - Medium (default, nice-to-have)
+
- `3` - Low (polish, optimization)
+
- `4` - Backlog (future ideas)
+
+
### Workflow for AI Agents
+
+
1. **Check ready work**: `bd ready` shows unblocked issues
+
2. **Claim your task**: `bd update <id> --status in_progress`
+
3. **Work on it**: Implement, test, document
+
4. **Discover new work?** Create linked issue:
+
- `bd create "Found bug" -p 1 --deps discovered-from:<parent-id>`
+
5. **Complete**: `bd close <id> --reason "Done"`
+
6. **Commit together**: Always commit the `.beads/issues.jsonl` file together with the code changes so issue state stays in sync with code state
+
+
### Auto-Sync
+
+
bd automatically syncs with git:
+
- Exports to `.beads/issues.jsonl` after changes (5s debounce)
+
- Imports from JSONL when newer (e.g., after `git pull`)
+
- No manual export/import needed!
+
+
### MCP Server (Recommended)
+
+
If using Claude or MCP-compatible clients, install the beads MCP server:
+
+
```bash
+
pip install beads-mcp
+
```
+
+
Add to MCP config (e.g., `~/.config/claude/config.json`):
+
```json
+
{
+
"beads": {
+
"command": "beads-mcp",
+
"args": []
+
}
+
}
+
```
+
+
Then use `mcp__beads__*` functions instead of CLI commands.
+
+
### Managing AI-Generated Planning Documents
+
+
AI assistants often create planning and design documents during development:
+
- PLAN.md, IMPLEMENTATION.md, ARCHITECTURE.md
+
- DESIGN.md, CODEBASE_SUMMARY.md, INTEGRATION_PLAN.md
+
- TESTING_GUIDE.md, TECHNICAL_DESIGN.md, and similar files
+
+
**Best Practice: Use a dedicated directory for these ephemeral files**
+
+
**Recommended approach:**
+
- Create a `history/` directory in the project root
+
- Store ALL AI-generated planning/design docs in `history/`
+
- Keep the repository root clean and focused on permanent project files
+
- Only access `history/` when explicitly asked to review past planning
+
+
**Example .gitignore entry (optional):**
+
```
+
# AI planning documents (ephemeral)
+
history/
+
```
+
+
**Benefits:**
+
- โœ… Clean repository root
+
- โœ… Clear separation between ephemeral and permanent documentation
+
- โœ… Easy to exclude from version control if desired
+
- โœ… Preserves planning history for archeological research
+
- โœ… Reduces noise when browsing the project
+
+
### Important Rules
+
+
- โœ… Use bd for ALL task tracking
+
- โœ… Always use `--json` flag for programmatic use
+
- โœ… Link discovered work with `discovered-from` dependencies
+
- โœ… Check `bd ready` before asking "what should I work on?"
+
- โœ… Store AI planning docs in `history/` directory
+
- โŒ Do NOT create markdown TODO lists
+
- โŒ Do NOT use external issue trackers
+
- โŒ Do NOT duplicate tracking systems
+
- โŒ Do NOT clutter repo root with planning documents
+
+
For more details, see the [beads repository](https://github.com/steveyegge/beads).
+5
internal/atproto/lexicon/social/coves/community/list.json
···
"type": "string",
"description": "Pagination cursor"
},
+
"visibility": {
+
"type": "string",
+
"knownValues": ["public", "unlisted", "private"],
+
"description": "Filter communities by visibility level"
+
},
"sort": {
"type": "string",
"knownValues": ["popular", "active", "new", "alphabetical"],
+2 -2
internal/core/communities/interfaces.go
···
UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error
// Listing & Search
-
List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) // Returns communities + total count
+
List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error)
Search(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
// Subscriptions (lightweight feed follows)
···
CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error)
GetCommunity(ctx context.Context, identifier string) (*Community, error) // identifier can be DID or handle
UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error)
-
ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error)
+
ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error)
SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
// Subscription operations (write-forward: creates record in user's PDS)
+57
scripts/backup.sh
···
+
#!/bin/bash
+
# Coves Database Backup Script
+
# Usage: ./scripts/backup.sh
+
#
+
# Creates timestamped PostgreSQL backups in ./backups/
+
# Retention: Keeps last 30 days of backups
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
BACKUP_DIR="$PROJECT_DIR/backups"
+
COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml"
+
+
# Load environment
+
set -a
+
source "$PROJECT_DIR/.env.prod"
+
set +a
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[BACKUP]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
+
# Create backup directory
+
mkdir -p "$BACKUP_DIR"
+
+
# Generate timestamp
+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+
BACKUP_FILE="$BACKUP_DIR/coves_${TIMESTAMP}.sql.gz"
+
+
log "Starting backup..."
+
+
# Run pg_dump inside container
+
docker compose -f "$COMPOSE_FILE" exec -T postgres \
+
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists \
+
| gzip > "$BACKUP_FILE"
+
+
# Get file size
+
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
+
+
log "โœ… Backup complete: $BACKUP_FILE ($SIZE)"
+
+
# Cleanup old backups (keep last 30 days)
+
log "Cleaning up backups older than 30 days..."
+
find "$BACKUP_DIR" -name "coves_*.sql.gz" -mtime +30 -delete
+
+
# List recent backups
+
log ""
+
log "Recent backups:"
+
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -5
+
+
log ""
+
log "To restore: gunzip -c $BACKUP_FILE | docker compose -f docker-compose.prod.yml exec -T postgres psql -U $POSTGRES_USER -d $POSTGRES_DB"
+133
scripts/deploy.sh
···
+
#!/bin/bash
+
# Coves Deployment Script
+
# Usage: ./scripts/deploy.sh [service]
+
#
+
# Examples:
+
# ./scripts/deploy.sh # Deploy all services
+
# ./scripts/deploy.sh appview # Deploy only AppView
+
# ./scripts/deploy.sh --pull # Pull from git first, then deploy
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml"
+
+
# Colors for output
+
RED='\033[0;31m'
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
NC='\033[0m' # No Color
+
+
log() {
+
echo -e "${GREEN}[DEPLOY]${NC} $1"
+
}
+
+
warn() {
+
echo -e "${YELLOW}[WARN]${NC} $1"
+
}
+
+
error() {
+
echo -e "${RED}[ERROR]${NC} $1"
+
exit 1
+
}
+
+
# Parse arguments
+
PULL_GIT=false
+
SERVICE=""
+
+
for arg in "$@"; do
+
case $arg in
+
--pull)
+
PULL_GIT=true
+
;;
+
*)
+
SERVICE="$arg"
+
;;
+
esac
+
done
+
+
cd "$PROJECT_DIR"
+
+
# Load environment variables
+
if [ ! -f ".env.prod" ]; then
+
error ".env.prod not found! Copy from .env.prod.example and configure secrets."
+
fi
+
+
log "Loading environment from .env.prod..."
+
set -a
+
source .env.prod
+
set +a
+
+
# Optional: Pull from git
+
if [ "$PULL_GIT" = true ]; then
+
log "Pulling latest code from git..."
+
git fetch origin
+
git pull origin main
+
fi
+
+
# Check database connectivity before deployment
+
log "Checking database connectivity..."
+
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
+
log "Database is ready"
+
else
+
warn "Database not ready yet - it will start with the deployment"
+
fi
+
+
# Build and deploy
+
if [ -n "$SERVICE" ]; then
+
log "Building $SERVICE..."
+
docker compose -f "$COMPOSE_FILE" build --no-cache "$SERVICE"
+
+
log "Deploying $SERVICE..."
+
docker compose -f "$COMPOSE_FILE" up -d "$SERVICE"
+
else
+
log "Building all services..."
+
docker compose -f "$COMPOSE_FILE" build --no-cache
+
+
log "Deploying all services..."
+
docker compose -f "$COMPOSE_FILE" up -d
+
fi
+
+
# Health check
+
log "Waiting for services to be healthy..."
+
sleep 10
+
+
# Wait for database to be ready before running migrations
+
log "Waiting for database..."
+
for i in {1..30}; do
+
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
+
break
+
fi
+
sleep 1
+
done
+
+
# Run database migrations
+
# The AppView runs migrations on startup, but we can also trigger them explicitly
+
log "Running database migrations..."
+
if docker compose -f "$COMPOSE_FILE" exec -T appview /app/coves-server migrate 2>/dev/null; then
+
log "โœ… Migrations completed"
+
else
+
warn "โš ๏ธ Migration command not available or failed - AppView will run migrations on startup"
+
fi
+
+
# Check AppView health
+
if docker compose -f "$COMPOSE_FILE" exec -T appview wget --spider -q http://localhost:8080/xrpc/_health 2>/dev/null; then
+
log "โœ… AppView is healthy"
+
else
+
warn "โš ๏ธ AppView health check failed - check logs with: docker compose -f docker-compose.prod.yml logs appview"
+
fi
+
+
# Check PDS health
+
if docker compose -f "$COMPOSE_FILE" exec -T pds wget --spider -q http://localhost:3000/xrpc/_health 2>/dev/null; then
+
log "โœ… PDS is healthy"
+
else
+
warn "โš ๏ธ PDS health check failed - check logs with: docker compose -f docker-compose.prod.yml logs pds"
+
fi
+
+
log "Deployment complete!"
+
log ""
+
log "Useful commands:"
+
log " View logs: docker compose -f docker-compose.prod.yml logs -f"
+
log " Check status: docker compose -f docker-compose.prod.yml ps"
+
log " Rollback: docker compose -f docker-compose.prod.yml down && git checkout HEAD~1 && ./scripts/deploy.sh"
+149
scripts/generate-did-keys.sh
···
+
#!/bin/bash
+
# Generate cryptographic keys for Coves did:web DID document
+
#
+
# This script generates a secp256k1 (K-256) key pair as required by atproto.
+
# Reference: https://atproto.com/specs/cryptography
+
#
+
# Key format:
+
# - Curve: secp256k1 (K-256) - same as Bitcoin/Ethereum
+
# - Type: Multikey
+
# - Encoding: publicKeyMultibase with base58btc ('z' prefix)
+
# - Multicodec: 0xe7 for secp256k1 compressed public key
+
#
+
# Output:
+
# - Private key (hex) for PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX
+
# - Public key (multibase) for did.json publicKeyMultibase field
+
# - Complete did.json file
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
OUTPUT_DIR="$PROJECT_DIR/static/.well-known"
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[KEYGEN]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+
# Check for required tools
+
if ! command -v openssl &> /dev/null; then
+
error "openssl is required but not installed"
+
fi
+
+
if ! command -v python3 &> /dev/null; then
+
error "python3 is required for base58 encoding"
+
fi
+
+
# Check for base58 library
+
if ! python3 -c "import base58" 2>/dev/null; then
+
warn "Installing base58 Python library..."
+
pip3 install base58 || error "Failed to install base58. Run: pip3 install base58"
+
fi
+
+
log "Generating secp256k1 key pair for did:web..."
+
+
# Generate private key
+
PRIVATE_KEY_PEM=$(mktemp)
+
openssl ecparam -name secp256k1 -genkey -noout -out "$PRIVATE_KEY_PEM" 2>/dev/null
+
+
# Extract private key as hex (for PDS config)
+
PRIVATE_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -text -noout 2>/dev/null | \
+
grep -A 3 "priv:" | tail -n 3 | tr -d ' :\n' | tr -d '\r')
+
+
# Extract public key as compressed format
+
# OpenSSL outputs the public key, we need to get the compressed form
+
PUBLIC_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+
tail -c 33 | xxd -p | tr -d '\n')
+
+
# Clean up temp file
+
rm -f "$PRIVATE_KEY_PEM"
+
+
# Encode public key as multibase with multicodec
+
# Multicodec 0xe7 = secp256k1 compressed public key
+
# Then base58btc encode with 'z' prefix
+
PUBLIC_KEY_MULTIBASE=$(python3 << EOF
+
import base58
+
+
# Compressed public key bytes
+
pub_hex = "$PUBLIC_KEY_HEX"
+
pub_bytes = bytes.fromhex(pub_hex)
+
+
# Prepend multicodec 0xe7 for secp256k1-pub
+
# 0xe7 as varint is just 0xe7 (single byte, < 128)
+
multicodec = bytes([0xe7, 0x01]) # 0xe701 for secp256k1-pub compressed
+
key_with_codec = multicodec + pub_bytes
+
+
# Base58btc encode
+
encoded = base58.b58encode(key_with_codec).decode('ascii')
+
+
# Add 'z' prefix for multibase
+
print('z' + encoded)
+
EOF
+
)
+
+
log "Keys generated successfully!"
+
echo ""
+
echo "============================================"
+
echo " PRIVATE KEY (keep secret!)"
+
echo "============================================"
+
echo ""
+
echo "Add this to your .env.prod file:"
+
echo ""
+
echo "PDS_ROTATION_KEY=$PRIVATE_KEY_HEX"
+
echo ""
+
echo "============================================"
+
echo " PUBLIC KEY (for did.json)"
+
echo "============================================"
+
echo ""
+
echo "publicKeyMultibase: $PUBLIC_KEY_MULTIBASE"
+
echo ""
+
+
# Generate the did.json file
+
log "Generating did.json..."
+
+
mkdir -p "$OUTPUT_DIR"
+
+
cat > "$OUTPUT_DIR/did.json" << EOF
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "$PUBLIC_KEY_MULTIBASE"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+
EOF
+
+
log "Created: $OUTPUT_DIR/did.json"
+
echo ""
+
echo "============================================"
+
echo " NEXT STEPS"
+
echo "============================================"
+
echo ""
+
echo "1. Copy the PDS_ROTATION_KEY value to your .env.prod file"
+
echo ""
+
echo "2. Verify the did.json looks correct:"
+
echo " cat $OUTPUT_DIR/did.json"
+
echo ""
+
echo "3. After deployment, verify it's accessible:"
+
echo " curl https://coves.social/.well-known/did.json"
+
echo ""
+
warn "IMPORTANT: Keep the private key secret! Only share the public key."
+
warn "The did.json file with the public key IS safe to commit to git."
+106
scripts/setup-production.sh
···
+
#!/bin/bash
+
# Coves Production Setup Script
+
# Run this once on a fresh server to set up everything
+
#
+
# Prerequisites:
+
# - Docker and docker-compose installed
+
# - Git installed
+
# - .env.prod file configured
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[SETUP]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+
cd "$PROJECT_DIR"
+
+
# Check prerequisites
+
log "Checking prerequisites..."
+
+
if ! command -v docker &> /dev/null; then
+
error "Docker is not installed. Install with: curl -fsSL https://get.docker.com | sh"
+
fi
+
+
if ! docker compose version &> /dev/null; then
+
error "docker compose is not available. Install with: apt install docker-compose-plugin"
+
fi
+
+
# Check for .env.prod
+
if [ ! -f ".env.prod" ]; then
+
error ".env.prod not found! Copy from .env.prod.example and configure secrets."
+
fi
+
+
# Load environment
+
set -a
+
source .env.prod
+
set +a
+
+
# Create required directories
+
log "Creating directories..."
+
mkdir -p backups
+
mkdir -p static/.well-known
+
+
# Check for did.json
+
if [ ! -f "static/.well-known/did.json" ]; then
+
warn "static/.well-known/did.json not found!"
+
warn "Run ./scripts/generate-did-keys.sh to create it."
+
fi
+
+
# Note: Caddy logs are written to Docker volume (caddy-data)
+
# If you need host-accessible logs, uncomment and run as root:
+
# mkdir -p /var/log/caddy && chown 1000:1000 /var/log/caddy
+
+
# Pull Docker images
+
log "Pulling Docker images..."
+
docker compose -f docker-compose.prod.yml pull postgres pds caddy
+
+
# Build AppView
+
log "Building AppView..."
+
docker compose -f docker-compose.prod.yml build appview
+
+
# Start services
+
log "Starting services..."
+
docker compose -f docker-compose.prod.yml up -d
+
+
# Wait for PostgreSQL
+
log "Waiting for PostgreSQL to be ready..."
+
until docker compose -f docker-compose.prod.yml exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
+
sleep 2
+
done
+
log "PostgreSQL is ready!"
+
+
# Run migrations
+
log "Running database migrations..."
+
# The AppView runs migrations on startup, but you can also run them manually:
+
# docker compose -f docker-compose.prod.yml exec appview /app/coves-server migrate
+
+
# Final status
+
log ""
+
log "============================================"
+
log " Coves Production Setup Complete!"
+
log "============================================"
+
log ""
+
log "Services running:"
+
docker compose -f docker-compose.prod.yml ps
+
log ""
+
log "Next steps:"
+
log " 1. Configure DNS for coves.social and coves.me"
+
log " 2. Run ./scripts/generate-did-keys.sh to create DID keys"
+
log " 3. Test health endpoints:"
+
log " curl https://coves.social/xrpc/_health"
+
log " curl https://coves.me/xrpc/_health"
+
log ""
+
log "Useful commands:"
+
log " View logs: docker compose -f docker-compose.prod.yml logs -f"
+
log " Deploy update: ./scripts/deploy.sh appview"
+
log " Backup DB: ./scripts/backup.sh"
+19
static/.well-known/did.json.template
···
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "REPLACE_WITH_YOUR_PUBLIC_KEY"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+18
static/client-metadata.json
···
+
{
+
"client_id": "https://coves.social/client-metadata.json",
+
"client_name": "Coves",
+
"client_uri": "https://coves.social",
+
"logo_uri": "https://coves.social/logo.png",
+
"tos_uri": "https://coves.social/terms",
+
"policy_uri": "https://coves.social/privacy",
+
"redirect_uris": [
+
"https://coves.social/oauth/callback",
+
"social.coves:/oauth/callback"
+
],
+
"scope": "atproto transition:generic",
+
"grant_types": ["authorization_code", "refresh_token"],
+
"response_types": ["code"],
+
"application_type": "native",
+
"token_endpoint_auth_method": "none",
+
"dpop_bound_access_tokens": true
+
}
+97
static/oauth/callback.html
···
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
+
<title>Authorization Successful - Coves</title>
+
<style>
+
body {
+
font-family: system-ui, -apple-system, sans-serif;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
min-height: 100vh;
+
margin: 0;
+
background: #f5f5f5;
+
}
+
.container {
+
text-align: center;
+
padding: 2rem;
+
background: white;
+
border-radius: 8px;
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+
max-width: 400px;
+
}
+
.success { color: #22c55e; font-size: 3rem; margin-bottom: 1rem; }
+
h1 { margin: 0 0 0.5rem; color: #1f2937; font-size: 1.5rem; }
+
p { color: #6b7280; margin: 0.5rem 0; }
+
a {
+
display: inline-block;
+
margin-top: 1rem;
+
padding: 0.75rem 1.5rem;
+
background: #3b82f6;
+
color: white;
+
text-decoration: none;
+
border-radius: 6px;
+
font-weight: 500;
+
}
+
a:hover { background: #2563eb; }
+
</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="success">โœ“</div>
+
<h1>Authorization Successful!</h1>
+
<p id="status">Returning to Coves...</p>
+
<a href="#" id="manualLink">Open Coves</a>
+
</div>
+
<script>
+
(function() {
+
// Parse and sanitize query params - only allow expected OAuth parameters
+
const urlParams = new URLSearchParams(window.location.search);
+
const safeParams = new URLSearchParams();
+
+
// Whitelist only expected OAuth callback parameters
+
const code = urlParams.get('code');
+
const state = urlParams.get('state');
+
const error = urlParams.get('error');
+
const errorDescription = urlParams.get('error_description');
+
const iss = urlParams.get('iss');
+
+
if (code) safeParams.set('code', code);
+
if (state) safeParams.set('state', state);
+
if (error) safeParams.set('error', error);
+
if (errorDescription) safeParams.set('error_description', errorDescription);
+
if (iss) safeParams.set('iss', iss);
+
+
const sanitizedQuery = safeParams.toString() ? '?' + safeParams.toString() : '';
+
+
const userAgent = navigator.userAgent || '';
+
const isAndroid = /Android/i.test(userAgent);
+
+
// Build deep link based on platform
+
let deepLink;
+
if (isAndroid) {
+
// Android: Intent URL format
+
const pathAndQuery = '/oauth/callback' + sanitizedQuery;
+
deepLink = 'intent:/' + pathAndQuery + '#Intent;scheme=social.coves;package=social.coves;end';
+
} else {
+
// iOS: Custom scheme
+
deepLink = 'social.coves:/oauth/callback' + sanitizedQuery;
+
}
+
+
// Update manual link
+
document.getElementById('manualLink').href = deepLink;
+
+
// Attempt automatic redirect
+
window.location.href = deepLink;
+
+
// Update status after 2 seconds if redirect didn't work
+
setTimeout(function() {
+
document.getElementById('status').textContent = 'Click the button above to continue';
+
}, 2000);
+
})();
+
</script>
+
</body>
+
</html>
+2 -1
Dockerfile
···
COPY --from=builder /build/coves-server /app/coves-server
# Copy migrations (needed for goose)
-
COPY --from=builder /build/internal/db/migrations /app/migrations
+
# Must maintain path structure as app looks for internal/db/migrations
+
COPY --from=builder /build/internal/db/migrations /app/internal/db/migrations
# Set ownership
RUN chown -R coves:coves /app
+187
scripts/derive-did-from-key.sh
···
+
#!/bin/bash
+
# Derive public key from existing PDS_ROTATION_KEY and create did.json
+
#
+
# This script takes your existing private key and derives the public key from it.
+
# Use this if you already have a PDS running with a rotation key but need to
+
# create/fix the did.json file.
+
#
+
# Usage: ./scripts/derive-did-from-key.sh
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
OUTPUT_DIR="$PROJECT_DIR/static/.well-known"
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[DERIVE]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+
# Check for required tools
+
if ! command -v openssl &> /dev/null; then
+
error "openssl is required but not installed"
+
fi
+
+
if ! command -v python3 &> /dev/null; then
+
error "python3 is required for base58 encoding"
+
fi
+
+
# Check for base58 library
+
if ! python3 -c "import base58" 2>/dev/null; then
+
warn "Installing base58 Python library..."
+
pip3 install base58 || error "Failed to install base58. Run: pip3 install base58"
+
fi
+
+
# Load environment to get the existing key
+
if [ -f "$PROJECT_DIR/.env.prod" ]; then
+
source "$PROJECT_DIR/.env.prod"
+
elif [ -f "$PROJECT_DIR/.env" ]; then
+
source "$PROJECT_DIR/.env"
+
else
+
error "No .env.prod or .env file found"
+
fi
+
+
if [ -z "$PDS_ROTATION_KEY" ]; then
+
error "PDS_ROTATION_KEY not found in environment"
+
fi
+
+
# Validate key format (should be 64 hex chars)
+
if [[ ! "$PDS_ROTATION_KEY" =~ ^[0-9a-fA-F]{64}$ ]]; then
+
error "PDS_ROTATION_KEY is not a valid 64-character hex string"
+
fi
+
+
log "Deriving public key from existing PDS_ROTATION_KEY..."
+
+
# Create a temporary PEM file from the hex private key
+
TEMP_DIR=$(mktemp -d)
+
PRIVATE_KEY_HEX="$PDS_ROTATION_KEY"
+
+
# Convert hex private key to PEM format
+
# secp256k1 curve OID: 1.3.132.0.10
+
python3 > "$TEMP_DIR/private.pem" << EOF
+
import binascii
+
+
# Private key in hex
+
priv_hex = "$PRIVATE_KEY_HEX"
+
priv_bytes = binascii.unhexlify(priv_hex)
+
+
# secp256k1 OID
+
oid = bytes([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a])
+
+
# Build the EC private key structure
+
# SEQUENCE { version INTEGER, privateKey OCTET STRING, [0] OID, [1] publicKey }
+
# We'll use a simpler approach: just the private key with curve params
+
+
# EC PARAMETERS for secp256k1
+
ec_params = bytes([
+
0x30, 0x07, # SEQUENCE, 7 bytes
+
0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a # OID for secp256k1
+
])
+
+
# EC PRIVATE KEY structure
+
# SEQUENCE { version, privateKey, [0] parameters }
+
inner = bytes([0x02, 0x01, 0x01]) # version = 1
+
inner += bytes([0x04, 0x20]) + priv_bytes # OCTET STRING with 32-byte key
+
inner += bytes([0xa0, 0x07]) + bytes([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a]) # [0] OID
+
+
# Wrap in SEQUENCE
+
key_der = bytes([0x30, len(inner)]) + inner
+
+
# Base64 encode
+
import base64
+
key_b64 = base64.b64encode(key_der).decode('ascii')
+
+
# Format as PEM
+
print("-----BEGIN EC PRIVATE KEY-----")
+
for i in range(0, len(key_b64), 64):
+
print(key_b64[i:i+64])
+
print("-----END EC PRIVATE KEY-----")
+
EOF
+
+
# Extract the compressed public key
+
PUBLIC_KEY_HEX=$(openssl ec -in "$TEMP_DIR/private.pem" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+
tail -c 33 | xxd -p | tr -d '\n')
+
+
# Clean up
+
rm -rf "$TEMP_DIR"
+
+
if [ -z "$PUBLIC_KEY_HEX" ] || [ ${#PUBLIC_KEY_HEX} -ne 66 ]; then
+
error "Failed to derive public key. Got: $PUBLIC_KEY_HEX"
+
fi
+
+
log "Derived public key: ${PUBLIC_KEY_HEX:0:8}...${PUBLIC_KEY_HEX: -8}"
+
+
# Encode public key as multibase with multicodec
+
PUBLIC_KEY_MULTIBASE=$(python3 << EOF
+
import base58
+
+
# Compressed public key bytes
+
pub_hex = "$PUBLIC_KEY_HEX"
+
pub_bytes = bytes.fromhex(pub_hex)
+
+
# Prepend multicodec 0xe7 for secp256k1-pub
+
# 0xe7 as varint is just 0xe7 (single byte, < 128)
+
multicodec = bytes([0xe7, 0x01]) # 0xe701 for secp256k1-pub compressed
+
key_with_codec = multicodec + pub_bytes
+
+
# Base58btc encode
+
encoded = base58.b58encode(key_with_codec).decode('ascii')
+
+
# Add 'z' prefix for multibase
+
print('z' + encoded)
+
EOF
+
)
+
+
log "Public key multibase: $PUBLIC_KEY_MULTIBASE"
+
+
# Generate the did.json file
+
log "Generating did.json..."
+
+
mkdir -p "$OUTPUT_DIR"
+
+
cat > "$OUTPUT_DIR/did.json" << EOF
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "$PUBLIC_KEY_MULTIBASE"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+
EOF
+
+
log "Created: $OUTPUT_DIR/did.json"
+
echo ""
+
echo "============================================"
+
echo " DID Document Generated Successfully!"
+
echo "============================================"
+
echo ""
+
echo "Public key multibase: $PUBLIC_KEY_MULTIBASE"
+
echo ""
+
echo "Next steps:"
+
echo " 1. Copy this file to your production server:"
+
echo " scp $OUTPUT_DIR/did.json user@server:/opt/coves/static/.well-known/"
+
echo ""
+
echo " 2. Or if running on production, restart Caddy:"
+
echo " docker compose -f docker-compose.prod.yml restart caddy"
+
echo ""
+
echo " 3. Verify it's accessible:"
+
echo " curl https://coves.social/.well-known/did.json"
+
echo ""
+1 -2
internal/api/handlers/aggregator/errors.go
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/aggregators"
)
// ErrorResponse represents an XRPC error response
+1 -2
internal/api/handlers/comments/errors.go
···
package comments
import (
+
"Coves/internal/core/comments"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/comments"
)
// errorResponse represents a standardized JSON error response
+1 -2
internal/api/handlers/comments/service_adapter.go
···
package comments
import (
-
"net/http"
-
"Coves/internal/core/comments"
+
"net/http"
)
// ServiceAdapter adapts the core comments.Service to the handler's Service interface
+2 -3
internal/api/handlers/community/block.go
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// BlockHandler handles community blocking operations
+1 -2
internal/api/handlers/community/list.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
"strconv"
-
-
"Coves/internal/core/communities"
)
// ListHandler handles listing communities
+2 -3
internal/api/handlers/community/subscribe.go
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// SubscribeHandler handles community subscriptions
+1 -2
internal/api/handlers/communityFeed/errors.go
···
package communityFeed
import (
+
"Coves/internal/core/communityFeeds"
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/communityFeeds"
)
// ErrorResponse represents an XRPC error response
+1 -2
internal/api/handlers/discover/errors.go
···
package discover
import (
+
"Coves/internal/core/discover"
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/discover"
)
// XRPCError represents an XRPC error response
+2 -3
internal/api/handlers/post/errors.go
···
package post
import (
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/posts"
)
type errorResponse struct {
+1 -2
internal/api/handlers/timeline/errors.go
···
package timeline
import (
+
"Coves/internal/core/timeline"
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/timeline"
)
// XRPCError represents an XRPC error response
+1 -2
internal/atproto/jetstream/aggregator_consumer.go
···
package jetstream
import (
+
"Coves/internal/core/aggregators"
"context"
"encoding/json"
"fmt"
"log"
"time"
-
-
"Coves/internal/core/aggregators"
)
// AggregatorEventConsumer consumes aggregator-related events from Jetstream
+2 -3
internal/atproto/jetstream/comment_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/comments"
"context"
"database/sql"
"encoding/json"
···
"strings"
"time"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/comments"
-
"github.com/lib/pq"
)
+3 -4
internal/atproto/jetstream/community_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
···
"strings"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
-
lru "github.com/hashicorp/golang-lru/v2"
"golang.org/x/net/publicsuffix"
"golang.org/x/time/rate"
+2 -3
internal/atproto/jetstream/user_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"fmt"
···
"sync"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/core/users"
-
"github.com/gorilla/websocket"
)
+1 -2
internal/core/aggregators/service.go
···
package aggregators
import (
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
"time"
-
"Coves/internal/core/communities"
-
"github.com/xeipuuv/gojsonschema"
)
+1 -2
internal/core/blobs/service.go
···
package blobs
import (
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"log"
"net/http"
"time"
-
-
"Coves/internal/core/communities"
)
// Service defines the interface for blob operations
+3 -4
internal/core/comments/comment_service.go
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"errors"
···
"net/url"
"strings"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
)
const (
+5 -6
internal/core/comments/comment_service_test.go
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"errors"
"testing"
"time"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"github.com/stretchr/testify/assert"
)
···
return nil
}
-
func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {
-
return nil, 0, nil
+
func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+
return nil, nil
}
func (m *mockCommunityRepo) Search(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
+1 -2
internal/core/communities/service.go
···
package communities
import (
+
"Coves/internal/atproto/utils"
"bytes"
"context"
"encoding/json"
···
"strings"
"sync"
"time"
-
-
"Coves/internal/atproto/utils"
)
// Community handle validation regex (DNS-valid handle: name.community.instance.com)
+1 -2
internal/core/communityFeeds/service.go
···
package communityFeeds
import (
+
"Coves/internal/core/communities"
"context"
"fmt"
-
-
"Coves/internal/core/communities"
)
type feedService struct {
+1 -2
internal/core/communityFeeds/types.go
···
package communityFeeds
import (
-
"time"
-
"Coves/internal/core/posts"
+
"time"
)
// GetCommunityFeedRequest represents input for fetching a community feed
+1 -2
internal/core/discover/types.go
···
package discover
import (
+
"Coves/internal/core/posts"
"context"
"errors"
-
-
"Coves/internal/core/posts"
)
// Repository defines discover data access interface
+5 -6
internal/core/posts/service.go
···
package posts
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/blobs"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/unfurl"
"bytes"
"context"
"encoding/json"
···
"net/http"
"os"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/blobs"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/unfurl"
)
type postService struct {
+1 -2
internal/core/timeline/types.go
···
package timeline
import (
+
"Coves/internal/core/posts"
"context"
"errors"
"time"
-
-
"Coves/internal/core/posts"
)
// Repository defines timeline data access interface
+1 -2
internal/db/postgres/aggregator_repo.go
···
package postgres
import (
+
"Coves/internal/core/aggregators"
"context"
"database/sql"
"fmt"
"strings"
"time"
-
-
"Coves/internal/core/aggregators"
)
type postgresAggregatorRepo struct {
+1 -2
internal/db/postgres/comment_repo.go
···
package postgres
import (
+
"Coves/internal/core/comments"
"context"
"database/sql"
"encoding/base64"
···
"log"
"strings"
-
"Coves/internal/core/comments"
-
"github.com/lib/pq"
)
+2 -4
internal/db/postgres/community_repo.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
"Coves/internal/core/communities"
-
"github.com/lib/pq"
)
···
}
// Build sort clause - map sort enum to DB columns
-
sortColumn := "subscriber_count" // default: popular
-
sortOrder := "DESC"
+
var sortColumn, sortOrder string
switch req.Sort {
case "popular":
+1 -2
internal/db/postgres/community_repo_blocks.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
-
-
"Coves/internal/core/communities"
)
// BlockCommunity creates a new block record (idempotent)
+1 -2
internal/db/postgres/community_repo_subscriptions.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/communities"
)
// Subscribe creates a new subscription record
+1 -2
internal/db/postgres/discover_repo.go
···
package postgres
import (
+
"Coves/internal/core/discover"
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/discover"
)
type postgresDiscoverRepo struct {
+1 -2
internal/db/postgres/timeline_repo.go
···
package postgres
import (
+
"Coves/internal/core/timeline"
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/timeline"
)
type postgresTimelineRepo struct {
+7 -8
tests/e2e/error_recovery_test.go
···
package e2e
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"fmt"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
)
···
testCases := []struct {
name string
-
event jetstream.JetstreamEvent
shouldLog string
+
event jetstream.JetstreamEvent
}{
{
name: "Nil identity data",
···
if shouldFail.Load() {
t.Logf("Mock PDS: Simulating unavailability (request #%d)", requestCount.Load())
w.WriteHeader(http.StatusServiceUnavailable)
-
w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
+
_, _ = w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
return
}
t.Logf("Mock PDS: Serving request successfully (request #%d)", requestCount.Load())
// Simulate successful PDS response
w.WriteHeader(http.StatusOK)
-
w.Write([]byte(`{"did":"did:plc:pdstest123","handle":"pds.test"}`))
+
_, _ = w.Write([]byte(`{"did":"did:plc:pdstest123","handle":"pds.test"}`))
}))
defer mockPDS.Close()
+3 -4
tests/integration/aggregator_test.go
···
package integration
import (
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestAggregatorRepository_Create tests basic aggregator creation
+13 -14
tests/integration/blob_upload_e2e_test.go
···
package integration
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/blobs"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/blobs"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
···
if err != nil {
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
}
-
defer healthResp.Body.Close()
+
defer func() { _ = healthResp.Body.Close() }()
if healthResp.StatusCode != http.StatusOK {
t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
}
···
if err != nil {
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
}
-
defer healthResp.Body.Close()
+
defer func() { _ = healthResp.Body.Close() }()
if healthResp.StatusCode != http.StatusOK {
t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
}
···
t.Run("Accept matching image formats with correct MIME types", func(t *testing.T) {
testCases := []struct {
+
createFunc func(*testing.T, int, int, color.Color) []byte
format string
mimeType string
-
createFunc func(*testing.T, int, int, color.Color) []byte
}{
-
{"PNG", "image/png", createTestPNG},
-
{"JPEG", "image/jpeg", createTestJPEG},
+
{createTestPNG, "PNG", "image/png"},
+
{createTestJPEG, "JPEG", "image/jpeg"},
// Note: WebP requires external library (golang.org/x/image/webp)
// For now, we test that the MIME type is accepted even with PNG data
// In production, actual WebP validation would happen at PDS
-
{"WebP (MIME only)", "image/webp", createTestPNG},
+
{createTestPNG, "WebP (MIME only)", "image/webp"},
}
for _, tc := range testCases {
+14 -14
tests/integration/block_handle_resolution_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/community"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"net/http/httptest"
"testing"
-
"Coves/internal/api/handlers/community"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
postgresRepo "Coves/internal/db/postgres"
)
···
// We expect 401 (no auth) but verify the error is NOT "Community not found"
// If handle resolution worked, we'd get past that validation
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("Handle resolution failed - got 404 CommunityNotFound")
···
// Expected: 401 Unauthorized (because we didn't add auth context)
if resp.StatusCode != http.StatusUnauthorized {
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
t.Logf("Response status: %d, body: %+v", resp.StatusCode, errorResp)
}
})
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("@-prefixed handle resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("Scoped format resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("DID resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Should return 400 Bad Request for validation errors
if resp.StatusCode != http.StatusBadRequest {
···
}
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
if errorCode, ok := errorResp["error"].(string); !ok || errorCode != "InvalidRequest" {
t.Errorf("Expected error code 'InvalidRequest', got %v", errorResp["error"])
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Expected: 401 (auth check happens before resolution)
// In a real scenario with auth, invalid handle would return 404
···
blockHandler.HandleUnblock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Should NOT be 404 (handle resolution should work)
if resp.StatusCode == http.StatusNotFound {
···
// Expected: 401 (no auth context)
if resp.StatusCode != http.StatusUnauthorized {
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
t.Logf("Response: status=%d, body=%+v", resp.StatusCode, errorResp)
}
})
···
blockHandler.HandleUnblock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Expected: 401 (auth check happens before resolution)
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound {
+3 -4
tests/integration/comment_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
)
func TestCommentConsumer_CreateComment(t *testing.T) {
+3 -4
tests/integration/comment_query_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"encoding/json"
···
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+4 -5
tests/integration/comment_vote_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"testing"
-
"time"
-
"Coves/internal/atproto/jetstream"
"Coves/internal/core/comments"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"testing"
+
"time"
)
// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates
+2 -3
tests/integration/community_blocking_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
postgresRepo "Coves/internal/db/postgres"
)
+4 -5
tests/integration/community_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"errors"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityConsumer_HandleCommunityProfile(t *testing.T) {
+4 -5
tests/integration/community_hostedby_security_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"net/http"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/db/postgres"
)
// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain
···
// Return a DID document with matching alsoKnownAs
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
-
fmt.Fprintf(w, `{
+
_, _ = fmt.Fprintf(w, `{
"id": "did:web:example.com",
"alsoKnownAs": ["at://example.com"],
"verificationMethod": [],
···
// Return a DID document WITHOUT alsoKnownAs field
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
-
fmt.Fprintf(w, `{
+
_, _ = fmt.Fprintf(w, `{
"id": "did:web:example.com",
"verificationMethod": [],
"service": []
+2 -3
tests/integration/community_identifier_resolution_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"os"
···
"testing"
"time"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+2 -3
tests/integration/community_provisioning_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityRepository_PasswordEncryption verifies P0 fix:
+2 -3
tests/integration/community_service_integration_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityService_CreateWithRealPDS tests the complete service layer flow
+6 -7
tests/integration/concurrent_scenarios_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"sync"
-
"testing"
-
"time"
-
"Coves/internal/atproto/jetstream"
"Coves/internal/core/comments"
"Coves/internal/core/communities"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"sync"
+
"testing"
+
"time"
)
// TestConcurrentVoting_MultipleUsersOnSamePost tests race conditions when multiple users
···
wg.Add(numAttempts)
type result struct {
-
success bool
err error
+
success bool
}
results := make(chan result, numAttempts)
+4 -5
tests/integration/feed_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/communityFeed"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/communityFeeds"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"testing"
"time"
-
"Coves/internal/api/handlers/communityFeed"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/communityFeeds"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+2 -3
tests/integration/helpers.go
···
package integration
import (
+
"Coves/internal/atproto/auth"
+
"Coves/internal/core/users"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/atproto/auth"
-
"Coves/internal/core/users"
-
"github.com/golang-jwt/jwt/v5"
)
+3 -4
tests/integration/jetstream_consumer_test.go
···
package integration
import (
-
"context"
-
"testing"
-
"time"
-
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"testing"
+
"time"
)
func TestUserIndexingFromJetstream(t *testing.T) {
+3 -4
tests/integration/jwt_verification_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/auth"
"fmt"
"net/http"
"net/http/httptest"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/auth"
)
// TestJWTSignatureVerification tests end-to-end JWT signature verification
···
// Check if JWKS is available (production PDS) or symmetric secret (dev PDS)
jwksResp, _ := http.Get(pdsURL + "/oauth/jwks")
if jwksResp != nil {
-
defer jwksResp.Body.Close()
+
defer func() { _ = jwksResp.Body.Close() }()
}
t.Run("JWT parsing and middleware integration", func(t *testing.T) {
+3 -4
tests/integration/post_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
)
// TestPostConsumer_CommentCountReconciliation tests that post comment_count
+8 -9
tests/integration/post_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
+5 -6
tests/integration/post_handler_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/db/postgres"
"bytes"
"encoding/json"
"net/http"
···
"strings"
"testing"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+5 -6
tests/integration/post_thumb_validation_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"net/http/httptest"
"testing"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+5 -6
tests/integration/post_unfurl_test.go
···
package integration
import (
-
"context"
-
"encoding/json"
-
"fmt"
-
"testing"
-
"time"
-
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
···
"Coves/internal/core/unfurl"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"encoding/json"
+
"fmt"
+
"testing"
+
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+2 -3
tests/integration/subscription_indexing_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
postgresRepo "Coves/internal/db/postgres"
)
+11 -11
tests/integration/user_journey_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/middleware"
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
timelineCore "Coves/internal/core/timeline"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Community creation should succeed")
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Post creation should succeed")
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Subscription should succeed")
+23
static/.well-known/did.json
···
+
{
+
"@context": [
+
"https://www.w3.org/ns/did/v1",
+
"https://w3id.org/security/multikey/v1"
+
],
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "zQ3shu1T3Y3MYoC1n7fCqkZqyrk8FiY3PV3BYM2JwyqcXFY6s"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://pds.coves.me"
+
}
+
]
+
}
+1 -1
docs/E2E_TESTING.md
···
Query via API:
```bash
-
curl "http://localhost:8081/xrpc/social.coves.actor.getProfile?actor=alice.local.coves.dev"
+
curl "http://localhost:8081/xrpc/social.coves.actor.getprofile?actor=alice.local.coves.dev"
```
Expected response:
+3 -3
internal/api/routes/user.go
···
func RegisterUserRoutes(r chi.Router, service users.UserService) {
h := NewUserHandler(service)
-
// social.coves.actor.getProfile - query endpoint
-
r.Get("/xrpc/social.coves.actor.getProfile", h.GetProfile)
+
// social.coves.actor.getprofile - query endpoint
+
r.Get("/xrpc/social.coves.actor.getprofile", h.GetProfile)
// social.coves.actor.signup - procedure endpoint
r.Post("/xrpc/social.coves.actor.signup", h.Signup)
}
-
// GetProfile handles social.coves.actor.getProfile
+
// GetProfile handles social.coves.actor.getprofile
// Query endpoint that retrieves a user profile by DID or handle
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+44 -5
internal/atproto/lexicon/social/coves/embed/external.json
···
"defs": {
"main": {
"type": "object",
-
"description": "External link embed with preview metadata and provider support",
+
"description": "External link embed with optional aggregated sources for megathreads",
"required": ["external"],
"properties": {
"external": {
···
},
"external": {
"type": "object",
-
"description": "External link metadata",
+
"description": "Primary external link metadata",
"required": ["uri"],
"properties": {
"uri": {
"type": "string",
"format": "uri",
-
"description": "URI of the external content"
+
"description": "URI of the primary external content"
},
"title": {
"type": "string",
···
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 1000000,
-
"description": "Thumbnail image for the link"
+
"description": "Thumbnail image for the post (applies to primary link)"
},
"domain": {
"type": "string",
-
"description": "Domain of the linked content"
+
"maxLength": 253,
+
"description": "Domain of the linked content (e.g., nytimes.com)"
},
"embedType": {
"type": "string",
···
},
"provider": {
"type": "string",
+
"maxLength": 100,
"description": "Service provider name (e.g., imgur, streamable)"
},
"images": {
···
"type": "integer",
"minimum": 0,
"description": "Total number of items if more than displayed (for galleries)"
+
},
+
"sources": {
+
"type": "array",
+
"description": "Aggregated source links for megathreads. Each source references an original article and optionally the Coves post that shared it",
+
"maxLength": 50,
+
"items": {
+
"type": "ref",
+
"ref": "#source"
+
}
+
}
+
}
+
},
+
"source": {
+
"type": "object",
+
"description": "A source link aggregated into a megathread",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "uri",
+
"description": "URI of the source article"
+
},
+
"title": {
+
"type": "string",
+
"maxLength": 500,
+
"maxGraphemes": 500,
+
"description": "Title of the source article"
+
},
+
"domain": {
+
"type": "string",
+
"maxLength": 253,
+
"description": "Domain of the source (e.g., nytimes.com)"
+
},
+
"sourcePost": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Reference to the Coves post that originally shared this link. Used for feed deprioritization of rolled-up posts"
}
}
}
+52
internal/atproto/auth/combined_key_fetcher.go
···
+
package auth
+
+
import (
+
"context"
+
"fmt"
+
"strings"
+
+
indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
+
)
+
+
// CombinedKeyFetcher handles JWT public key fetching for both:
+
// - DID issuers (did:plc:, did:web:) โ†’ resolves via DID document
+
// - URL issuers (https://) โ†’ fetches via JWKS endpoint (legacy/fallback)
+
//
+
// For atproto service authentication, the issuer is typically the user's DID,
+
// and the signing key is published in their DID document.
+
type CombinedKeyFetcher struct {
+
didFetcher *DIDKeyFetcher
+
jwksFetcher JWKSFetcher
+
}
+
+
// NewCombinedKeyFetcher creates a key fetcher that supports both DID and URL issuers.
+
// Parameters:
+
// - directory: Indigo's identity directory for DID resolution
+
// - jwksFetcher: fallback JWKS fetcher for URL issuers (can be nil if not needed)
+
func NewCombinedKeyFetcher(directory indigoIdentity.Directory, jwksFetcher JWKSFetcher) *CombinedKeyFetcher {
+
return &CombinedKeyFetcher{
+
didFetcher: NewDIDKeyFetcher(directory),
+
jwksFetcher: jwksFetcher,
+
}
+
}
+
+
// FetchPublicKey fetches the public key for verifying a JWT.
+
// Routes to the appropriate fetcher based on issuer format:
+
// - DID (did:plc:, did:web:) โ†’ DIDKeyFetcher
+
// - URL (https://) โ†’ JWKSFetcher
+
func (f *CombinedKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+
// Check if issuer is a DID
+
if strings.HasPrefix(issuer, "did:") {
+
return f.didFetcher.FetchPublicKey(ctx, issuer, token)
+
}
+
+
// Check if issuer is a URL (https:// or http:// in dev)
+
if strings.HasPrefix(issuer, "https://") || strings.HasPrefix(issuer, "http://") {
+
if f.jwksFetcher == nil {
+
return nil, fmt.Errorf("URL issuer %s requires JWKS fetcher, but none configured", issuer)
+
}
+
return f.jwksFetcher.FetchPublicKey(ctx, issuer, token)
+
}
+
+
return nil, fmt.Errorf("unsupported issuer format: %s (expected DID or URL)", issuer)
+
}
+116
internal/atproto/auth/did_key_fetcher.go
···
+
package auth
+
+
import (
+
"context"
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"encoding/base64"
+
"fmt"
+
"math/big"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// DIDKeyFetcher fetches public keys from DID documents for JWT verification.
+
// This is the primary method for atproto service authentication, where:
+
// - The JWT issuer is the user's DID (e.g., did:plc:abc123)
+
// - The signing key is published in the user's DID document
+
// - Verification happens by resolving the DID and checking the signature
+
type DIDKeyFetcher struct {
+
directory indigoIdentity.Directory
+
}
+
+
// NewDIDKeyFetcher creates a new DID-based key fetcher.
+
func NewDIDKeyFetcher(directory indigoIdentity.Directory) *DIDKeyFetcher {
+
return &DIDKeyFetcher{
+
directory: directory,
+
}
+
}
+
+
// FetchPublicKey fetches the public key for verifying a JWT from the issuer's DID document.
+
// For DID issuers (did:plc: or did:web:), resolves the DID and extracts the signing key.
+
// Returns an *ecdsa.PublicKey suitable for use with jwt-go.
+
func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+
// Only handle DID issuers
+
if !strings.HasPrefix(issuer, "did:") {
+
return nil, fmt.Errorf("DIDKeyFetcher only handles DID issuers, got: %s", issuer)
+
}
+
+
// Parse the DID
+
did, err := syntax.ParseDID(issuer)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DID format: %w", err)
+
}
+
+
// Resolve the DID to get the identity (includes public keys)
+
ident, err := f.directory.LookupDID(ctx, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve DID %s: %w", issuer, err)
+
}
+
+
// Get the atproto signing key from the DID document
+
pubKey, err := ident.PublicKey()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get public key from DID document: %w", err)
+
}
+
+
// Convert to JWK format to extract coordinates
+
jwk, err := pubKey.JWK()
+
if err != nil {
+
return nil, fmt.Errorf("failed to convert public key to JWK: %w", err)
+
}
+
+
// Convert atcrypto JWK to Go ecdsa.PublicKey
+
return atcryptoJWKToECDSA(jwk)
+
}
+
+
// atcryptoJWKToECDSA converts an atcrypto.JWK to a Go ecdsa.PublicKey
+
func atcryptoJWKToECDSA(jwk *atcrypto.JWK) (*ecdsa.PublicKey, error) {
+
if jwk.KeyType != "EC" {
+
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
}
+
+
// Decode X and Y coordinates (base64url, no padding)
+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK X coordinate encoding: %w", err)
+
}
+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK Y coordinate encoding: %w", err)
+
}
+
+
var ecCurve elliptic.Curve
+
switch jwk.Curve {
+
case "P-256":
+
ecCurve = elliptic.P256()
+
case "P-384":
+
ecCurve = elliptic.P384()
+
case "P-521":
+
ecCurve = elliptic.P521()
+
case "secp256k1":
+
// secp256k1 (K-256) is used by some atproto implementations
+
// Go's standard library doesn't include secp256k1, but we can still
+
// construct the key - jwt-go may not support it directly
+
return nil, fmt.Errorf("secp256k1 curve requires special handling for JWT verification")
+
default:
+
return nil, fmt.Errorf("unsupported JWK curve: %s", jwk.Curve)
+
}
+
+
// Create the public key
+
pubKey := &ecdsa.PublicKey{
+
Curve: ecCurve,
+
X: new(big.Int).SetBytes(xBytes),
+
Y: new(big.Int).SetBytes(yBytes),
+
}
+
+
// Validate point is on curve
+
if !ecCurve.IsOnCurve(pubKey.X, pubKey.Y) {
+
return nil, fmt.Errorf("invalid public key: point not on curve")
+
}
+
+
return pubKey, nil
+
}
+5
.env.dev
···
# When false, verifies JWT signature against issuer's JWKS
AUTH_SKIP_VERIFY=true
+
# HS256 Issuers: PDSes allowed to use HS256 (shared secret) authentication
+
# Must share PDS_JWT_SECRET with Coves instance. External PDSes use ES256 via DID resolution.
+
# For local dev, allow the local PDS or turn AUTH_SKIP_VERIFY = true
+
HS256_ISSUERS=http://localhost:3001
+
# Logging
LOG_LEVEL=debug
LOG_ENABLED=true
+484
internal/atproto/auth/dpop.go
···
+
package auth
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"math/big"
+
"strings"
+
"sync"
+
"time"
+
+
indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/golang-jwt/jwt/v5"
+
)
+
+
// NonceCache provides replay protection for DPoP proofs by tracking seen jti values.
+
// This prevents an attacker from reusing a captured DPoP proof within the validity window.
+
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks.
+
type NonceCache struct {
+
seen map[string]time.Time // jti -> expiration time
+
stopCh chan struct{}
+
maxAge time.Duration // How long to keep entries
+
cleanup time.Duration // How often to clean up expired entries
+
mu sync.RWMutex
+
}
+
+
// NewNonceCache creates a new nonce cache for DPoP replay protection.
+
// maxAge should match or exceed DPoPVerifier.MaxProofAge.
+
func NewNonceCache(maxAge time.Duration) *NonceCache {
+
nc := &NonceCache{
+
seen: make(map[string]time.Time),
+
maxAge: maxAge,
+
cleanup: maxAge / 2, // Clean up at half the max age
+
stopCh: make(chan struct{}),
+
}
+
+
// Start background cleanup goroutine
+
go nc.cleanupLoop()
+
+
return nc
+
}
+
+
// CheckAndStore checks if a jti has been seen before and stores it if not.
+
// Returns true if the jti is fresh (not a replay), false if it's a replay.
+
func (nc *NonceCache) CheckAndStore(jti string) bool {
+
nc.mu.Lock()
+
defer nc.mu.Unlock()
+
+
now := time.Now()
+
expiry := now.Add(nc.maxAge)
+
+
// Check if already seen
+
if existingExpiry, seen := nc.seen[jti]; seen {
+
// Still valid (not expired) - this is a replay
+
if existingExpiry.After(now) {
+
return false
+
}
+
// Expired entry - allow reuse and update expiry
+
}
+
+
// Store the new jti
+
nc.seen[jti] = expiry
+
return true
+
}
+
+
// cleanupLoop periodically removes expired entries from the cache
+
func (nc *NonceCache) cleanupLoop() {
+
ticker := time.NewTicker(nc.cleanup)
+
defer ticker.Stop()
+
+
for {
+
select {
+
case <-ticker.C:
+
nc.cleanupExpired()
+
case <-nc.stopCh:
+
return
+
}
+
}
+
}
+
+
// cleanupExpired removes expired entries from the cache
+
func (nc *NonceCache) cleanupExpired() {
+
nc.mu.Lock()
+
defer nc.mu.Unlock()
+
+
now := time.Now()
+
for jti, expiry := range nc.seen {
+
if expiry.Before(now) {
+
delete(nc.seen, jti)
+
}
+
}
+
}
+
+
// Stop stops the cleanup goroutine. Call this when done with the cache.
+
func (nc *NonceCache) Stop() {
+
close(nc.stopCh)
+
}
+
+
// Size returns the number of entries in the cache (for testing/monitoring)
+
func (nc *NonceCache) Size() int {
+
nc.mu.RLock()
+
defer nc.mu.RUnlock()
+
return len(nc.seen)
+
}
+
+
// DPoPClaims represents the claims in a DPoP proof JWT (RFC 9449)
+
type DPoPClaims struct {
+
jwt.RegisteredClaims
+
+
// HTTP method of the request (e.g., "GET", "POST")
+
HTTPMethod string `json:"htm"`
+
+
// HTTP URI of the request (without query and fragment parts)
+
HTTPURI string `json:"htu"`
+
+
// Access token hash (optional, for token binding)
+
AccessTokenHash string `json:"ath,omitempty"`
+
}
+
+
// DPoPProof represents a parsed and verified DPoP proof
+
type DPoPProof struct {
+
RawPublicJWK map[string]interface{}
+
Claims *DPoPClaims
+
PublicKey interface{} // *ecdsa.PublicKey or similar
+
Thumbprint string // JWK thumbprint (base64url)
+
}
+
+
// DPoPVerifier verifies DPoP proofs for OAuth token binding
+
type DPoPVerifier struct {
+
// Optional: custom nonce validation function (for server-issued nonces)
+
ValidateNonce func(nonce string) bool
+
+
// NonceCache for replay protection (optional but recommended)
+
// If nil, jti replay protection is disabled
+
NonceCache *NonceCache
+
+
// Maximum allowed clock skew for timestamp validation
+
MaxClockSkew time.Duration
+
+
// Maximum age of DPoP proof (prevents replay with old proofs)
+
MaxProofAge time.Duration
+
}
+
+
// NewDPoPVerifier creates a DPoP verifier with sensible defaults including replay protection
+
func NewDPoPVerifier() *DPoPVerifier {
+
maxProofAge := 5 * time.Minute
+
return &DPoPVerifier{
+
MaxClockSkew: 30 * time.Second,
+
MaxProofAge: maxProofAge,
+
NonceCache: NewNonceCache(maxProofAge),
+
}
+
}
+
+
// NewDPoPVerifierWithoutReplayProtection creates a DPoP verifier without replay protection.
+
// This should only be used in testing or when replay protection is handled externally.
+
func NewDPoPVerifierWithoutReplayProtection() *DPoPVerifier {
+
return &DPoPVerifier{
+
MaxClockSkew: 30 * time.Second,
+
MaxProofAge: 5 * time.Minute,
+
NonceCache: nil, // No replay protection
+
}
+
}
+
+
// Stop stops background goroutines. Call this when shutting down.
+
func (v *DPoPVerifier) Stop() {
+
if v.NonceCache != nil {
+
v.NonceCache.Stop()
+
}
+
}
+
+
// VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof
+
func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) {
+
// Parse the DPoP JWT without verification first to extract the header
+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+
token, _, err := parser.ParseUnverified(dpopProof, &DPoPClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse DPoP proof: %w", err)
+
}
+
+
// Extract and validate the header
+
header, ok := token.Header["typ"].(string)
+
if !ok || header != "dpop+jwt" {
+
return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", header)
+
}
+
+
alg, ok := token.Header["alg"].(string)
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: missing alg header")
+
}
+
+
// Extract the JWK from the header
+
jwkRaw, ok := token.Header["jwk"]
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: missing jwk header")
+
}
+
+
jwkMap, ok := jwkRaw.(map[string]interface{})
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object")
+
}
+
+
// Parse the public key from JWK
+
publicKey, err := parseJWKToPublicKey(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err)
+
}
+
+
// Calculate the JWK thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
+
}
+
+
// Now verify the signature
+
verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) {
+
// Verify the signing method matches what we expect
+
switch alg {
+
case "ES256":
+
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
case "ES384", "ES512":
+
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
+
// RSA methods - we primarily support ES256 for atproto
+
return nil, fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg)
+
default:
+
return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg)
+
}
+
return publicKey, nil
+
})
+
if err != nil {
+
return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err)
+
}
+
+
claims, ok := verifiedToken.Claims.(*DPoPClaims)
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP claims type")
+
}
+
+
// Validate the claims
+
if err := v.validateDPoPClaims(claims, httpMethod, httpURI); err != nil {
+
return nil, err
+
}
+
+
return &DPoPProof{
+
Claims: claims,
+
PublicKey: publicKey,
+
Thumbprint: thumbprint,
+
RawPublicJWK: jwkMap,
+
}, nil
+
}
+
+
// validateDPoPClaims validates the DPoP proof claims
+
func (v *DPoPVerifier) validateDPoPClaims(claims *DPoPClaims, expectedMethod, expectedURI string) error {
+
// Validate jti (unique identifier) is present
+
if claims.ID == "" {
+
return fmt.Errorf("DPoP proof missing jti claim")
+
}
+
+
// Validate htm (HTTP method)
+
if !strings.EqualFold(claims.HTTPMethod, expectedMethod) {
+
return fmt.Errorf("DPoP proof htm mismatch: expected %s, got %s", expectedMethod, claims.HTTPMethod)
+
}
+
+
// Validate htu (HTTP URI) - compare without query/fragment
+
expectedURIBase := stripQueryFragment(expectedURI)
+
claimURIBase := stripQueryFragment(claims.HTTPURI)
+
if expectedURIBase != claimURIBase {
+
return fmt.Errorf("DPoP proof htu mismatch: expected %s, got %s", expectedURIBase, claimURIBase)
+
}
+
+
// Validate iat (issued at) is present and recent
+
if claims.IssuedAt == nil {
+
return fmt.Errorf("DPoP proof missing iat claim")
+
}
+
+
now := time.Now()
+
iat := claims.IssuedAt.Time
+
+
// Check clock skew (not too far in the future)
+
if iat.After(now.Add(v.MaxClockSkew)) {
+
return fmt.Errorf("DPoP proof iat is in the future")
+
}
+
+
// Check proof age (not too old)
+
if now.Sub(iat) > v.MaxProofAge {
+
return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)
+
}
+
+
// SECURITY: Check for replay attack using jti
+
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks
+
if v.NonceCache != nil {
+
if !v.NonceCache.CheckAndStore(claims.ID) {
+
return fmt.Errorf("DPoP proof replay detected: jti %s already used", claims.ID)
+
}
+
}
+
+
return nil
+
}
+
+
// VerifyTokenBinding verifies that the DPoP proof binds to the access token
+
// by comparing the proof's thumbprint to the token's cnf.jkt claim
+
func (v *DPoPVerifier) VerifyTokenBinding(proof *DPoPProof, expectedThumbprint string) error {
+
if proof.Thumbprint != expectedThumbprint {
+
return fmt.Errorf("DPoP proof thumbprint mismatch: token expects %s, proof has %s",
+
expectedThumbprint, proof.Thumbprint)
+
}
+
return nil
+
}
+
+
// CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638
+
// The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation
+
func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) {
+
kty, ok := jwk["kty"].(string)
+
if !ok {
+
return "", fmt.Errorf("JWK missing kty")
+
}
+
+
// Build the canonical JWK representation based on key type
+
// Per RFC 7638, only specific members are included, in lexicographic order
+
var canonical map[string]string
+
+
switch kty {
+
case "EC":
+
crv, ok := jwk["crv"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing crv")
+
}
+
x, ok := jwk["x"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing x")
+
}
+
y, ok := jwk["y"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing y")
+
}
+
// Lexicographic order: crv, kty, x, y
+
canonical = map[string]string{
+
"crv": crv,
+
"kty": kty,
+
"x": x,
+
"y": y,
+
}
+
case "RSA":
+
e, ok := jwk["e"].(string)
+
if !ok {
+
return "", fmt.Errorf("RSA JWK missing e")
+
}
+
n, ok := jwk["n"].(string)
+
if !ok {
+
return "", fmt.Errorf("RSA JWK missing n")
+
}
+
// Lexicographic order: e, kty, n
+
canonical = map[string]string{
+
"e": e,
+
"kty": kty,
+
"n": n,
+
}
+
case "OKP":
+
crv, ok := jwk["crv"].(string)
+
if !ok {
+
return "", fmt.Errorf("OKP JWK missing crv")
+
}
+
x, ok := jwk["x"].(string)
+
if !ok {
+
return "", fmt.Errorf("OKP JWK missing x")
+
}
+
// Lexicographic order: crv, kty, x
+
canonical = map[string]string{
+
"crv": crv,
+
"kty": kty,
+
"x": x,
+
}
+
default:
+
return "", fmt.Errorf("unsupported JWK key type: %s", kty)
+
}
+
+
// Serialize to JSON (Go's json.Marshal produces lexicographically ordered keys for map[string]string)
+
canonicalJSON, err := json.Marshal(canonical)
+
if err != nil {
+
return "", fmt.Errorf("failed to serialize canonical JWK: %w", err)
+
}
+
+
// SHA-256 hash
+
hash := sha256.Sum256(canonicalJSON)
+
+
// Base64url encode (no padding)
+
thumbprint := base64.RawURLEncoding.EncodeToString(hash[:])
+
+
return thumbprint, nil
+
}
+
+
// parseJWKToPublicKey parses a JWK map to a Go public key
+
func parseJWKToPublicKey(jwkMap map[string]interface{}) (interface{}, error) {
+
// Convert map to JSON bytes for indigo's parser
+
jwkBytes, err := json.Marshal(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("failed to serialize JWK: %w", err)
+
}
+
+
// Try to parse with indigo's crypto package
+
pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse JWK: %w", err)
+
}
+
+
// Convert indigo's PublicKey to Go's ecdsa.PublicKey
+
jwk, err := pubKey.JWK()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get JWK from public key: %w", err)
+
}
+
+
// Use our existing conversion function
+
return atcryptoJWKToECDSAFromIndigoJWK(jwk)
+
}
+
+
// atcryptoJWKToECDSAFromIndigoJWK converts an indigo JWK to Go ecdsa.PublicKey
+
func atcryptoJWKToECDSAFromIndigoJWK(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) {
+
if jwk.KeyType != "EC" {
+
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
}
+
+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK X coordinate: %w", err)
+
}
+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err)
+
}
+
+
var curve ecdsa.PublicKey
+
switch jwk.Curve {
+
case "P-256":
+
curve.Curve = ecdsaP256Curve()
+
case "P-384":
+
curve.Curve = ecdsaP384Curve()
+
case "P-521":
+
curve.Curve = ecdsaP521Curve()
+
default:
+
return nil, fmt.Errorf("unsupported curve: %s", jwk.Curve)
+
}
+
+
curve.X = new(big.Int).SetBytes(xBytes)
+
curve.Y = new(big.Int).SetBytes(yBytes)
+
+
return &curve, nil
+
}
+
+
// Helper functions for elliptic curves
+
func ecdsaP256Curve() elliptic.Curve { return elliptic.P256() }
+
func ecdsaP384Curve() elliptic.Curve { return elliptic.P384() }
+
func ecdsaP521Curve() elliptic.Curve { return elliptic.P521() }
+
+
// stripQueryFragment removes query and fragment from a URI
+
func stripQueryFragment(uri string) string {
+
if idx := strings.Index(uri, "?"); idx != -1 {
+
uri = uri[:idx]
+
}
+
if idx := strings.Index(uri, "#"); idx != -1 {
+
uri = uri[:idx]
+
}
+
return uri
+
}
+
+
// ExtractCnfJkt extracts the cnf.jkt (confirmation key thumbprint) from JWT claims
+
func ExtractCnfJkt(claims *Claims) (string, error) {
+
if claims.Confirmation == nil {
+
return "", fmt.Errorf("token missing cnf claim (no DPoP binding)")
+
}
+
+
jkt, ok := claims.Confirmation["jkt"].(string)
+
if !ok || jkt == "" {
+
return "", fmt.Errorf("token cnf claim missing jkt (DPoP key thumbprint)")
+
}
+
+
return jkt, nil
+
}
+921
internal/atproto/auth/dpop_test.go
···
+
package auth
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/golang-jwt/jwt/v5"
+
"github.com/google/uuid"
+
)
+
+
// === Test Helpers ===
+
+
// testECKey holds a test ES256 key pair
+
type testECKey struct {
+
privateKey *ecdsa.PrivateKey
+
publicKey *ecdsa.PublicKey
+
jwk map[string]interface{}
+
thumbprint string
+
}
+
+
// generateTestES256Key generates a test ES256 key pair and JWK
+
func generateTestES256Key(t *testing.T) *testECKey {
+
t.Helper()
+
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("Failed to generate test key: %v", err)
+
}
+
+
// Encode public key coordinates as base64url
+
xBytes := privateKey.PublicKey.X.Bytes()
+
yBytes := privateKey.PublicKey.Y.Bytes()
+
+
// P-256 coordinates must be 32 bytes (pad if needed)
+
xBytes = padTo32Bytes(xBytes)
+
yBytes = padTo32Bytes(yBytes)
+
+
x := base64.RawURLEncoding.EncodeToString(xBytes)
+
y := base64.RawURLEncoding.EncodeToString(yBytes)
+
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": x,
+
"y": y,
+
}
+
+
// Calculate thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Failed to calculate thumbprint: %v", err)
+
}
+
+
return &testECKey{
+
privateKey: privateKey,
+
publicKey: &privateKey.PublicKey,
+
jwk: jwk,
+
thumbprint: thumbprint,
+
}
+
}
+
+
// padTo32Bytes pads a byte slice to 32 bytes (required for P-256 coordinates)
+
func padTo32Bytes(b []byte) []byte {
+
if len(b) >= 32 {
+
return b
+
}
+
padded := make([]byte, 32)
+
copy(padded[32-len(b):], b)
+
return padded
+
}
+
+
// createDPoPProof creates a DPoP proof JWT for testing
+
func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {
+
t.Helper()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
tokenString, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create DPoP proof: %v", err)
+
}
+
+
return tokenString
+
}
+
+
// === JWK Thumbprint Tests (RFC 7638) ===
+
+
func TestCalculateJWKThumbprint_EC_P256(t *testing.T) {
+
// Test with known values from RFC 7638 Appendix A (adapted for P-256)
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis",
+
"y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint")
+
}
+
+
// Verify it's valid base64url
+
_, err = base64.RawURLEncoding.DecodeString(thumbprint)
+
if err != nil {
+
t.Errorf("Thumbprint is not valid base64url: %v", err)
+
}
+
+
// Verify length (SHA-256 produces 32 bytes = 43 base64url chars)
+
if len(thumbprint) != 43 {
+
t.Errorf("Expected thumbprint length 43, got %d", len(thumbprint))
+
}
+
}
+
+
func TestCalculateJWKThumbprint_Deterministic(t *testing.T) {
+
// Same key should produce same thumbprint
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "test-x-coordinate",
+
"y": "test-y-coordinate",
+
}
+
+
thumbprint1, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("First CalculateJWKThumbprint failed: %v", err)
+
}
+
+
thumbprint2, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Second CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint1 != thumbprint2 {
+
t.Errorf("Thumbprints are not deterministic: %s != %s", thumbprint1, thumbprint2)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_DifferentKeys(t *testing.T) {
+
// Different keys should produce different thumbprints
+
jwk1 := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "coordinate-x-1",
+
"y": "coordinate-y-1",
+
}
+
+
jwk2 := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "coordinate-x-2",
+
"y": "coordinate-y-2",
+
}
+
+
thumbprint1, err := CalculateJWKThumbprint(jwk1)
+
if err != nil {
+
t.Fatalf("First CalculateJWKThumbprint failed: %v", err)
+
}
+
+
thumbprint2, err := CalculateJWKThumbprint(jwk2)
+
if err != nil {
+
t.Fatalf("Second CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint1 == thumbprint2 {
+
t.Error("Different keys produced same thumbprint (collision)")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_MissingKty(t *testing.T) {
+
jwk := map[string]interface{}{
+
"crv": "P-256",
+
"x": "test-x",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing kty, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing kty") {
+
t.Errorf("Expected error about missing kty, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingCrv(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"x": "test-x",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing crv, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing crv") {
+
t.Errorf("Expected error about missing crv, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingX(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing x, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing x") {
+
t.Errorf("Expected error about missing x, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingY(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "test-x",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing y, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing y") {
+
t.Errorf("Expected error about missing y, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_RSA(t *testing.T) {
+
// Test RSA key thumbprint calculation
+
jwk := map[string]interface{}{
+
"kty": "RSA",
+
"e": "AQAB",
+
"n": "test-modulus",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for RSA: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for RSA key")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_OKP(t *testing.T) {
+
// Test OKP (Octet Key Pair) thumbprint calculation
+
jwk := map[string]interface{}{
+
"kty": "OKP",
+
"crv": "Ed25519",
+
"x": "test-x-coordinate",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for OKP: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for OKP key")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_UnsupportedKeyType(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "UNKNOWN",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for unsupported key type, got nil")
+
}
+
if err != nil && !contains(err.Error(), "unsupported JWK key type") {
+
t.Errorf("Expected error about unsupported key type, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_CanonicalJSON(t *testing.T) {
+
// RFC 7638 requires lexicographic ordering of keys in canonical JSON
+
// This test verifies that the canonical JSON is correctly ordered
+
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "x-coord",
+
"y": "y-coord",
+
}
+
+
// The canonical JSON should be: {"crv":"P-256","kty":"EC","x":"x-coord","y":"y-coord"}
+
// (lexicographically ordered: crv, kty, x, y)
+
+
canonical := map[string]string{
+
"crv": "P-256",
+
"kty": "EC",
+
"x": "x-coord",
+
"y": "y-coord",
+
}
+
+
canonicalJSON, err := json.Marshal(canonical)
+
if err != nil {
+
t.Fatalf("Failed to marshal canonical JSON: %v", err)
+
}
+
+
expectedHash := sha256.Sum256(canonicalJSON)
+
expectedThumbprint := base64.RawURLEncoding.EncodeToString(expectedHash[:])
+
+
actualThumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if actualThumbprint != expectedThumbprint {
+
t.Errorf("Thumbprint doesn't match expected canonical JSON hash\nExpected: %s\nGot: %s",
+
expectedThumbprint, actualThumbprint)
+
}
+
}
+
+
// === DPoP Proof Verification Tests ===
+
+
func TestVerifyDPoPProof_Valid(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for valid proof: %v", err)
+
}
+
+
if result == nil {
+
t.Fatal("Expected non-nil proof result")
+
}
+
+
if result.Claims.HTTPMethod != method {
+
t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod)
+
}
+
+
if result.Claims.HTTPURI != uri {
+
t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI)
+
}
+
+
if result.Claims.ID != jti {
+
t.Errorf("Expected jti %s, got %s", jti, result.Claims.ID)
+
}
+
+
if result.Thumbprint != key.thumbprint {
+
t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint)
+
}
+
}
+
+
func TestVerifyDPoPProof_InvalidSignature(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
wrongKey := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
// Create proof with one key
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
// Parse and modify to use wrong key's JWK in header (signature won't match)
+
parts := splitJWT(proof)
+
header := parseJWTHeader(t, parts[0])
+
header["jwk"] = wrongKey.jwk
+
modifiedHeader := encodeJSON(t, header)
+
tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2]
+
+
_, err := verifier.VerifyDPoPProof(tamperedProof, method, uri)
+
if err == nil {
+
t.Error("Expected error for invalid signature, got nil")
+
}
+
if err != nil && !contains(err.Error(), "signature verification failed") {
+
t.Errorf("Expected signature verification error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongHTTPMethod(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
wrongMethod := "GET"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, wrongMethod, uri)
+
if err == nil {
+
t.Error("Expected error for HTTP method mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "htm mismatch") {
+
t.Errorf("Expected htm mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongURI(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
wrongURI := "https://api.example.com/different"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, wrongURI)
+
if err == nil {
+
t.Error("Expected error for URI mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "htu mismatch") {
+
t.Errorf("Expected htu mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_URIWithQuery(t *testing.T) {
+
// URI comparison should strip query and fragment
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
baseURI := "https://api.example.com/resource"
+
uriWithQuery := baseURI + "?param=value"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, baseURI, iat, jti)
+
+
// Should succeed because query is stripped
+
_, err := verifier.VerifyDPoPProof(proof, method, uriWithQuery)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for URI with query: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_URIWithFragment(t *testing.T) {
+
// URI comparison should strip query and fragment
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
baseURI := "https://api.example.com/resource"
+
uriWithFragment := baseURI + "#section"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, baseURI, iat, jti)
+
+
// Should succeed because fragment is stripped
+
_, err := verifier.VerifyDPoPProof(proof, method, uriWithFragment)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for URI with fragment: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_ExpiredProof(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 10 minutes ago (exceeds default MaxProofAge of 5 minutes)
+
iat := time.Now().Add(-10 * time.Minute)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for expired proof, got nil")
+
}
+
if err != nil && !contains(err.Error(), "too old") {
+
t.Errorf("Expected 'too old' error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_FutureProof(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 1 minute in the future (exceeds MaxClockSkew)
+
iat := time.Now().Add(1 * time.Minute)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for future proof, got nil")
+
}
+
if err != nil && !contains(err.Error(), "in the future") {
+
t.Errorf("Expected 'in the future' error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WithinClockSkew(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 15 seconds in the future (within MaxClockSkew of 30s)
+
iat := time.Now().Add(15 * time.Second)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for proof within clock skew: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingJti(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
// No ID (jti)
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing jti, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jti") {
+
t.Errorf("Expected missing jti error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingTypHeader(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
// Don't set typ header
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing typ header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") {
+
t.Errorf("Expected typ header error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongTypHeader(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "JWT" // Wrong typ
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for wrong typ header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") {
+
t.Errorf("Expected typ header error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingJWK(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
// Don't include JWK
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing jwk header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jwk") {
+
t.Errorf("Expected missing jwk error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_CustomTimeSettings(t *testing.T) {
+
verifier := &DPoPVerifier{
+
MaxClockSkew: 1 * time.Minute,
+
MaxProofAge: 10 * time.Minute,
+
}
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 50 seconds in the future (within custom MaxClockSkew)
+
iat := time.Now().Add(50 * time.Second)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed with custom time settings: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_HTTPMethodCaseInsensitive(t *testing.T) {
+
// HTTP method comparison should be case-insensitive per spec
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "post"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
// Verify with uppercase method
+
_, err := verifier.VerifyDPoPProof(proof, "POST", uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for case-insensitive method: %v", err)
+
}
+
}
+
+
// === Token Binding Verification Tests ===
+
+
func TestVerifyTokenBinding_Matching(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed: %v", err)
+
}
+
+
// Verify token binding with matching thumbprint
+
err = verifier.VerifyTokenBinding(result, key.thumbprint)
+
if err != nil {
+
t.Fatalf("VerifyTokenBinding failed for matching thumbprint: %v", err)
+
}
+
}
+
+
func TestVerifyTokenBinding_Mismatch(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
wrongKey := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed: %v", err)
+
}
+
+
// Verify token binding with wrong thumbprint
+
err = verifier.VerifyTokenBinding(result, wrongKey.thumbprint)
+
if err == nil {
+
t.Error("Expected error for thumbprint mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "thumbprint mismatch") {
+
t.Errorf("Expected thumbprint mismatch error, got: %v", err)
+
}
+
}
+
+
// === ExtractCnfJkt Tests ===
+
+
func TestExtractCnfJkt_Valid(t *testing.T) {
+
expectedJkt := "test-thumbprint-123"
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": expectedJkt,
+
},
+
}
+
+
jkt, err := ExtractCnfJkt(claims)
+
if err != nil {
+
t.Fatalf("ExtractCnfJkt failed for valid claims: %v", err)
+
}
+
+
if jkt != expectedJkt {
+
t.Errorf("Expected jkt %s, got %s", expectedJkt, jkt)
+
}
+
}
+
+
func TestExtractCnfJkt_MissingCnf(t *testing.T) {
+
claims := &Claims{
+
// No Confirmation
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for missing cnf, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing cnf claim") {
+
t.Errorf("Expected missing cnf error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_NilCnf(t *testing.T) {
+
claims := &Claims{
+
Confirmation: nil,
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for nil cnf, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing cnf claim") {
+
t.Errorf("Expected missing cnf error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_MissingJkt(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"other": "value",
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for missing jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_EmptyJkt(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": "",
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for empty jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_WrongType(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": 123, // Not a string
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for wrong type jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
// === Helper Functions for Tests ===
+
+
// splitJWT splits a JWT into its three parts
+
func splitJWT(token string) []string {
+
return []string{
+
token[:strings.IndexByte(token, '.')],
+
token[strings.IndexByte(token, '.')+1 : strings.LastIndexByte(token, '.')],
+
token[strings.LastIndexByte(token, '.')+1:],
+
}
+
}
+
+
// parseJWTHeader parses a base64url-encoded JWT header
+
func parseJWTHeader(t *testing.T, encoded string) map[string]interface{} {
+
t.Helper()
+
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
+
if err != nil {
+
t.Fatalf("Failed to decode header: %v", err)
+
}
+
+
var header map[string]interface{}
+
if err := json.Unmarshal(decoded, &header); err != nil {
+
t.Fatalf("Failed to unmarshal header: %v", err)
+
}
+
+
return header
+
}
+
+
// encodeJSON encodes a value to base64url-encoded JSON
+
func encodeJSON(t *testing.T, v interface{}) string {
+
t.Helper()
+
data, err := json.Marshal(v)
+
if err != nil {
+
t.Fatalf("Failed to marshal JSON: %v", err)
+
}
+
return base64.RawURLEncoding.EncodeToString(data)
+
}
+148 -6
internal/api/middleware/auth.go
···
import (
"Coves/internal/atproto/auth"
"context"
+
"fmt"
"log"
"net/http"
"strings"
···
UserDIDKey contextKey = "user_did"
JWTClaimsKey contextKey = "jwt_claims"
UserAccessToken contextKey = "user_access_token"
+
DPoPProofKey contextKey = "dpop_proof"
)
// AtProtoAuthMiddleware enforces atProto OAuth authentication for protected routes
// Validates JWT Bearer tokens from the Authorization header
+
// Supports DPoP (RFC 9449) for token binding verification
type AtProtoAuthMiddleware struct {
-
jwksFetcher auth.JWKSFetcher
-
skipVerify bool // For Phase 1 testing only
+
jwksFetcher auth.JWKSFetcher
+
dpopVerifier *auth.DPoPVerifier
+
skipVerify bool // For Phase 1 testing only
}
// NewAtProtoAuthMiddleware creates a new atProto auth middleware
// skipVerify: if true, only parses JWT without signature verification (Phase 1)
//
// if false, performs full signature verification (Phase 2)
+
//
+
// IMPORTANT: Call Stop() when shutting down to clean up background goroutines.
func NewAtProtoAuthMiddleware(jwksFetcher auth.JWKSFetcher, skipVerify bool) *AtProtoAuthMiddleware {
return &AtProtoAuthMiddleware{
-
jwksFetcher: jwksFetcher,
-
skipVerify: skipVerify,
+
jwksFetcher: jwksFetcher,
+
dpopVerifier: auth.NewDPoPVerifier(),
+
skipVerify: skipVerify,
+
}
+
}
+
+
// Stop stops background goroutines. Call this when shutting down the server.
+
// This prevents goroutine leaks from the DPoP verifier's replay protection cache.
+
func (m *AtProtoAuthMiddleware) Stop() {
+
if m.dpopVerifier != nil {
+
m.dpopVerifier.Stop()
}
}
···
}
} else {
// Phase 2: Full verification with signature check
+
//
+
// SECURITY: The access token MUST be verified before trusting any claims.
+
// DPoP is an ADDITIONAL security layer, not a replacement for signature verification.
claims, err = auth.VerifyJWT(r.Context(), token, m.jwksFetcher)
if err != nil {
-
// Try to extract issuer for better logging
+
// Token verification failed - REJECT
+
// DO NOT fall back to DPoP-only verification, as that would trust unverified claims
issuer := "unknown"
if parsedClaims, parseErr := auth.ParseJWT(token); parseErr == nil {
issuer = parsedClaims.Issuer
···
writeAuthError(w, "Invalid or expired token")
return
}
+
+
// Token signature verified - now check if DPoP binding is required
+
// If the token has a cnf.jkt claim, DPoP proof is REQUIRED
+
dpopHeader := r.Header.Get("DPoP")
+
hasCnfJkt := claims.Confirmation != nil && claims.Confirmation["jkt"] != nil
+
+
if hasCnfJkt {
+
// Token has DPoP binding - REQUIRE valid DPoP proof
+
if dpopHeader == "" {
+
log.Printf("[AUTH_FAILURE] type=missing_dpop ip=%s method=%s path=%s error=token has cnf.jkt but no DPoP header",
+
r.RemoteAddr, r.Method, r.URL.Path)
+
writeAuthError(w, "DPoP proof required")
+
return
+
}
+
+
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader)
+
if err != nil {
+
log.Printf("[AUTH_FAILURE] type=dpop_verification_failed ip=%s method=%s path=%s error=%v",
+
r.RemoteAddr, r.Method, r.URL.Path, err)
+
writeAuthError(w, "Invalid DPoP proof")
+
return
+
}
+
+
// Store verified DPoP proof in context
+
ctx := context.WithValue(r.Context(), DPoPProofKey, proof)
+
r = r.WithContext(ctx)
+
} else if dpopHeader != "" {
+
// DPoP header present but token doesn't have cnf.jkt - this is suspicious
+
// Log warning but don't reject (could be a misconfigured client)
+
log.Printf("[AUTH_WARNING] type=unexpected_dpop ip=%s method=%s path=%s warning=DPoP header present but token has no cnf.jkt",
+
r.RemoteAddr, r.Method, r.URL.Path)
+
}
}
// Extract user DID from 'sub' claim
···
claims, err = auth.ParseJWT(token)
} else {
// Phase 2: Full verification
+
// SECURITY: Token MUST be verified before trusting claims
claims, err = auth.VerifyJWT(r.Context(), token, m.jwksFetcher)
}
···
return
}
-
// Inject user info and access token into context
+
// Check DPoP binding if token has cnf.jkt (after successful verification)
+
// SECURITY: If token has cnf.jkt but no DPoP header, we cannot trust it
+
// (could be a stolen token). Continue as unauthenticated.
+
if !m.skipVerify {
+
dpopHeader := r.Header.Get("DPoP")
+
hasCnfJkt := claims.Confirmation != nil && claims.Confirmation["jkt"] != nil
+
+
if hasCnfJkt {
+
if dpopHeader == "" {
+
// Token requires DPoP binding but no proof provided
+
// Cannot trust this token - continue without auth
+
log.Printf("[AUTH_WARNING] Optional auth: token has cnf.jkt but no DPoP header - treating as unauthenticated (potential token theft)")
+
next.ServeHTTP(w, r)
+
return
+
}
+
+
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader)
+
if err != nil {
+
// DPoP verification failed - cannot trust this token
+
log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err)
+
next.ServeHTTP(w, r)
+
return
+
}
+
+
// DPoP verified - inject proof into context
+
ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
+
ctx = context.WithValue(ctx, JWTClaimsKey, claims)
+
ctx = context.WithValue(ctx, UserAccessToken, token)
+
ctx = context.WithValue(ctx, DPoPProofKey, proof)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
return
+
}
+
}
+
+
// No DPoP binding required - inject user info and access token into context
ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
ctx = context.WithValue(ctx, JWTClaimsKey, claims)
ctx = context.WithValue(ctx, UserAccessToken, token)
···
return token
}
+
// GetDPoPProof extracts the DPoP proof from the request context
+
// Returns nil if no DPoP proof was verified
+
func GetDPoPProof(r *http.Request) *auth.DPoPProof {
+
proof, _ := r.Context().Value(DPoPProofKey).(*auth.DPoPProof)
+
return proof
+
}
+
+
// verifyDPoPBinding verifies DPoP proof binding for an ALREADY VERIFIED token.
+
//
+
// SECURITY: This function ONLY verifies the DPoP proof and its binding to the token.
+
// The access token MUST be signature-verified BEFORE calling this function.
+
// DPoP is an ADDITIONAL security layer, not a replacement for signature verification.
+
//
+
// This prevents token theft attacks by proving the client possesses the private key
+
// corresponding to the public key thumbprint in the token's cnf.jkt claim.
+
func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader string) (*auth.DPoPProof, error) {
+
// Extract the cnf.jkt claim from the already-verified token
+
jkt, err := auth.ExtractCnfJkt(claims)
+
if err != nil {
+
return nil, fmt.Errorf("token requires DPoP but missing cnf.jkt: %w", err)
+
}
+
+
// Build the HTTP URI for DPoP verification
+
// Use the full URL including scheme and host
+
scheme := strings.TrimSpace(r.URL.Scheme)
+
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
+
// Forwarded proto may contain a comma-separated list; use the first entry
+
parts := strings.Split(forwardedProto, ",")
+
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
+
scheme = strings.ToLower(strings.TrimSpace(parts[0]))
+
}
+
}
+
if scheme == "" {
+
if r.TLS != nil {
+
scheme = "https"
+
} else {
+
scheme = "http"
+
}
+
}
+
scheme = strings.ToLower(scheme)
+
httpURI := scheme + "://" + r.Host + r.URL.Path
+
+
// Verify the DPoP proof
+
proof, err := m.dpopVerifier.VerifyDPoPProof(dpopProofHeader, r.Method, httpURI)
+
if err != nil {
+
return nil, fmt.Errorf("DPoP proof verification failed: %w", err)
+
}
+
+
// Verify the binding between the proof and the token
+
if err := m.dpopVerifier.VerifyTokenBinding(proof, jkt); err != nil {
+
return nil, fmt.Errorf("DPoP binding verification failed: %w", err)
+
}
+
+
return proof, nil
+
}
+
// writeAuthError writes a JSON error response for authentication failures
func writeAuthError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
+416
internal/api/middleware/auth_test.go
···
package middleware
import (
+
"Coves/internal/atproto/auth"
"context"
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
···
"time"
"github.com/golang-jwt/jwt/v5"
+
"github.com/google/uuid"
)
// mockJWKSFetcher is a test double for JWKSFetcher
···
t.Errorf("expected nil claims, got %+v", claims)
}
}
+
+
// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified
+
func TestGetDPoPProof_NotAuthenticated(t *testing.T) {
+
req := httptest.NewRequest("GET", "/test", nil)
+
proof := GetDPoPProof(req)
+
+
if proof != nil {
+
t.Errorf("expected nil proof, got %+v", proof)
+
}
+
}
+
+
// TestRequireAuth_WithDPoP_SecurityModel tests the correct DPoP security model:
+
// Token MUST be verified first, then DPoP is checked as an additional layer.
+
// DPoP is NOT a fallback for failed token verification.
+
func TestRequireAuth_WithDPoP_SecurityModel(t *testing.T) {
+
// Generate an ECDSA key pair for DPoP
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
// Calculate JWK thumbprint for cnf.jkt
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("failed to calculate thumbprint: %v", err)
+
}
+
+
t.Run("DPoP_is_NOT_fallback_for_failed_verification", func(t *testing.T) {
+
// SECURITY TEST: When token verification fails, DPoP should NOT be used as fallback
+
// This prevents an attacker from forging a token with their own cnf.jkt
+
+
// Create a DPoP-bound access token (unsigned - will fail verification)
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:attacker",
+
Issuer: "https://external.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// Create valid DPoP proof (attacker has the private key)
+
dpopProof := createDPoPProof(t, privateKey, "GET", "https://test.local/api/endpoint")
+
+
// Mock fetcher that fails (simulating external PDS without JWKS)
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false
+
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Error("SECURITY VULNERABILITY: handler was called despite token verification failure")
+
}))
+
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("DPoP", dpopProof)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// MUST reject - token verification failed, DPoP cannot substitute for signature verification
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("SECURITY: expected 401 for unverified token, got %d", w.Code)
+
}
+
})
+
+
t.Run("DPoP_required_when_cnf_jkt_present_in_verified_token", func(t *testing.T) {
+
// When token has cnf.jkt, DPoP header MUST be present
+
// This test uses skipVerify=true to simulate a verified token
+
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:test123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// NO DPoP header - should fail when skipVerify is false
+
// Note: with skipVerify=true, DPoP is not checked
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true for parsing
+
+
handlerCalled := false
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// No DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// With skipVerify=true, DPoP is not checked, so this should succeed
+
if !handlerCalled {
+
t.Error("handler should be called when skipVerify=true")
+
}
+
})
+
}
+
+
// TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback is the key security test.
+
// It ensures that DPoP cannot be used as a fallback when token signature verification fails.
+
func TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback(t *testing.T) {
+
// Generate a key pair (attacker's key)
+
attackerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
jwk := ecdsaPublicKeyToJWK(&attackerKey.PublicKey)
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
+
+
// Create a FORGED token claiming to be the victim
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:victim_user", // Attacker claims to be victim
+
Issuer: "https://untrusted.pds",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint, // Attacker uses their own key
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// Attacker creates a valid DPoP proof with their key
+
dpopProof := createDPoPProof(t, attackerKey, "POST", "https://api.example.com/protected")
+
+
// Fetcher fails (external PDS without JWKS)
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification
+
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Fatalf("CRITICAL SECURITY FAILURE: Request authenticated as %s despite forged token!",
+
GetUserDID(r))
+
}))
+
+
req := httptest.NewRequest("POST", "https://api.example.com/protected", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("DPoP", dpopProof)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// MUST reject - the token signature was never verified
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)
+
}
+
}
+
+
// TestVerifyDPoPBinding_UsesForwardedProto ensures we honor the external HTTPS
+
// scheme when TLS is terminated upstream and X-Forwarded-Proto is present.
+
func TestVerifyDPoPBinding_UsesForwardedProto(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("failed to calculate thumbprint: %v", err)
+
}
+
+
claims := &auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:test123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
+
externalURI := "https://api.example.com/protected/resource"
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
+
+
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
+
req.Host = "api.example.com"
+
req.Header.Set("X-Forwarded-Proto", "https")
+
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof)
+
if err != nil {
+
t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err)
+
}
+
+
if proof == nil || proof.Claims == nil {
+
t.Fatal("expected DPoP proof to be returned")
+
}
+
}
+
+
// TestMiddlewareStop tests that the middleware can be stopped properly
+
func TestMiddlewareStop(t *testing.T) {
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
+
+
// Stop should not panic and should clean up resources
+
middleware.Stop()
+
+
// Calling Stop again should also be safe (idempotent-ish)
+
// Note: The underlying DPoPVerifier.Stop() closes a channel, so this might panic
+
// if not handled properly. We test that at least one Stop works.
+
}
+
+
// TestOptionalAuth_DPoPBoundToken_NoDPoPHeader tests that OptionalAuth treats
+
// tokens with cnf.jkt but no DPoP header as unauthenticated (potential token theft)
+
func TestOptionalAuth_DPoPBoundToken_NoDPoPHeader(t *testing.T) {
+
// Generate a key pair for DPoP binding
+
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
+
+
// Create a DPoP-bound token (has cnf.jkt)
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:user123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// Use skipVerify=true to simulate a verified token
+
// (In production, skipVerify would be false and VerifyJWT would be called)
+
// However, for this test we need skipVerify=false to trigger DPoP checking
+
// But the fetcher will fail, so let's use skipVerify=true and verify the logic
+
// Actually, the DPoP check only happens when skipVerify=false
+
+
t.Run("with_skipVerify_false", func(t *testing.T) {
+
// This will fail at JWT verification level, but that's expected
+
// The important thing is the code path for DPoP checking
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
+
defer middleware.Stop()
+
+
handlerCalled := false
+
var capturedDID string
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// Deliberately NOT setting DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// Handler should be called (optional auth doesn't block)
+
if !handlerCalled {
+
t.Error("handler should be called")
+
}
+
+
// But since JWT verification fails, user should not be authenticated
+
if capturedDID != "" {
+
t.Errorf("expected empty DID when verification fails, got %s", capturedDID)
+
}
+
})
+
+
t.Run("with_skipVerify_true_dpop_not_checked", func(t *testing.T) {
+
// When skipVerify=true, DPoP is not checked (Phase 1 mode)
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true)
+
defer middleware.Stop()
+
+
handlerCalled := false
+
var capturedDID string
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// No DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
if !handlerCalled {
+
t.Error("handler should be called")
+
}
+
+
// With skipVerify=true, DPoP check is bypassed - token is trusted
+
if capturedDID != "did:plc:user123" {
+
t.Errorf("expected DID when skipVerify=true, got %s", capturedDID)
+
}
+
})
+
}
+
+
// TestDPoPReplayProtection tests that the same DPoP proof cannot be used twice
+
func TestDPoPReplayProtection(t *testing.T) {
+
// This tests the NonceCache functionality
+
cache := auth.NewNonceCache(5 * time.Minute)
+
defer cache.Stop()
+
+
jti := "unique-proof-id-123"
+
+
// First use should succeed
+
if !cache.CheckAndStore(jti) {
+
t.Error("First use of jti should succeed")
+
}
+
+
// Second use should fail (replay detected)
+
if cache.CheckAndStore(jti) {
+
t.Error("SECURITY: Replay attack not detected - same jti accepted twice")
+
}
+
+
// Different jti should succeed
+
if !cache.CheckAndStore("different-jti-456") {
+
t.Error("Different jti should succeed")
+
}
+
}
+
+
// Helper: createDPoPProof creates a DPoP proof JWT for testing
+
func createDPoPProof(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri string) string {
+
// Create JWK from public key
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
+
// Create DPoP claims with UUID for jti to ensure uniqueness across tests
+
claims := auth.DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
ID: uuid.New().String(),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
// Create token with custom header
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = jwk
+
+
// Sign with private key
+
signedToken, err := token.SignedString(privateKey)
+
if err != nil {
+
t.Fatalf("failed to sign DPoP proof: %v", err)
+
}
+
+
return signedToken
+
}
+
+
// Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map
+
func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} {
+
// Get curve name
+
var crv string
+
switch pubKey.Curve {
+
case elliptic.P256():
+
crv = "P-256"
+
case elliptic.P384():
+
crv = "P-384"
+
case elliptic.P521():
+
crv = "P-521"
+
default:
+
panic("unsupported curve")
+
}
+
+
// Encode coordinates
+
xBytes := pubKey.X.Bytes()
+
yBytes := pubKey.Y.Bytes()
+
+
// Ensure proper byte length (pad if needed)
+
keySize := (pubKey.Curve.Params().BitSize + 7) / 8
+
xPadded := make([]byte, keySize)
+
yPadded := make([]byte, keySize)
+
copy(xPadded[keySize-len(xBytes):], xBytes)
+
copy(yPadded[keySize-len(yBytes):], yBytes)
+
+
return map[string]interface{}{
+
"kty": "EC",
+
"crv": crv,
+
"x": base64.RawURLEncoding.EncodeToString(xPadded),
+
"y": base64.RawURLEncoding.EncodeToString(yPadded),
+
}
+
}
+4 -1
internal/atproto/auth/jwt.go
···
// Claims represents the standard JWT claims we care about
type Claims struct {
jwt.RegisteredClaims
-
Scope string `json:"scope,omitempty"`
+
// Confirmation claim for DPoP token binding (RFC 9449)
+
// Contains "jkt" (JWK thumbprint) when token is bound to a DPoP key
+
Confirmation map[string]interface{} `json:"cnf,omitempty"`
+
Scope string `json:"scope,omitempty"`
}
// stripBearerPrefix removes the "Bearer " prefix from a token string
+134 -2
internal/atproto/auth/README.md
···
5. Find matching key by `kid` from JWT header
6. Cache the JWKS for 1 hour
+
## DPoP Token Binding
+
+
DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.
+
+
### What is DPoP?
+
+
DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:
+
+
1. Access token contains `cnf.jkt` claim (JWK thumbprint of client's public key)
+
2. Client creates a DPoP proof JWT signed with their private key
+
3. Server verifies the proof signature and checks it matches the token's `cnf.jkt`
+
+
### CRITICAL: DPoP Security Model
+
+
> โš ๏ธ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**
+
+
The correct verification order is:
+
1. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)
+
2. **If the verified token has `cnf.jkt`, REQUIRE valid DPoP proof**
+
3. **NEVER use DPoP as a fallback when signature verification fails**
+
+
**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.
+
+
### How DPoP Works
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ Client โ”‚ โ”‚ Server โ”‚
+
โ”‚ โ”‚ โ”‚ (Coves) โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚ โ”‚
+
โ”‚ 1. Authorization: Bearer <token> โ”‚
+
โ”‚ DPoP: <proof-jwt> โ”‚
+
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 2. VERIFY token signature
+
โ”‚ โ”‚ (REQUIRED - no fallback!)
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 3. If token has cnf.jkt:
+
โ”‚ โ”‚ - Verify DPoP proof
+
โ”‚ โ”‚ - Check thumbprint match
+
โ”‚ โ”‚
+
โ”‚ 200 OK โ”‚
+
โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
+
```
+
+
### When DPoP is Required
+
+
DPoP verification is **REQUIRED** when:
+
- Access token signature has been verified AND
+
- Access token contains `cnf.jkt` claim (DPoP-bound)
+
+
If the token has `cnf.jkt` but no DPoP header is present, the request is **REJECTED**.
+
+
### Replay Protection
+
+
DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:
+
+
```go
+
// Create a verifier with replay protection (default)
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop() // Stop cleanup goroutine on shutdown
+
+
// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)
+
```
+
+
### DPoP Implementation
+
+
The `dpop.go` module provides:
+
+
```go
+
// Create a verifier with replay protection
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop()
+
+
// Verify the DPoP proof
+
proof, err := verifier.VerifyDPoPProof(dpopHeader, "POST", "https://coves.social/xrpc/...")
+
if err != nil {
+
// Invalid proof (includes replay detection)
+
}
+
+
// Verify it binds to the VERIFIED access token
+
expectedThumbprint, err := auth.ExtractCnfJkt(claims)
+
if err != nil {
+
// Token not DPoP-bound
+
}
+
+
if err := verifier.VerifyTokenBinding(proof, expectedThumbprint); err != nil {
+
// Proof doesn't match token
+
}
+
```
+
+
### DPoP Proof Format
+
+
The DPoP header contains a JWT with:
+
+
**Header**:
+
- `typ`: `"dpop+jwt"` (required)
+
- `alg`: `"ES256"` (or other supported algorithm)
+
- `jwk`: Client's public key (JWK format)
+
+
**Claims**:
+
- `jti`: Unique proof identifier (tracked for replay protection)
+
- `htm`: HTTP method (e.g., `"POST"`)
+
- `htu`: HTTP URI (without query/fragment)
+
- `iat`: Timestamp (must be recent, within 5 minutes)
+
+
**Example**:
+
```json
+
{
+
"typ": "dpop+jwt",
+
"alg": "ES256",
+
"jwk": {
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "...",
+
"y": "..."
+
}
+
}
+
{
+
"jti": "unique-id-123",
+
"htm": "POST",
+
"htu": "https://coves.social/xrpc/social.coves.community.create",
+
"iat": 1700000000
+
}
+
```
+
## Security Considerations
### โœ… Implemented
···
- Required claims validation (sub, iss)
- Key caching with TTL
- Secure error messages (no internal details leaked)
+
- **DPoP proof verification** (proof-of-possession for token binding)
+
- **DPoP thumbprint validation** (prevents token theft attacks)
+
- **DPoP freshness checks** (5-minute proof validity window)
+
- **DPoP replay protection** (jti tracking with in-memory cache)
+
- **Secure DPoP model** (DPoP required AFTER signature verification, never as fallback)
### โš ๏ธ Not Yet Implemented
-
- DPoP validation (for replay attack prevention)
+
- Server-issued DPoP nonces (additional replay protection)
- Scope validation (checking `scope` claim)
- Audience validation (checking `aud` claim)
- Rate limiting per DID
···
## Future Enhancements
-
- [ ] DPoP proof validation
+
- [ ] DPoP nonce validation (server-managed nonce for additional replay protection)
- [ ] Scope-based authorization
- [ ] Audience claim validation
- [ ] Token revocation support
+4 -1
.gitignore
···
# Build artifacts
/validate-lexicon
-
/bin/
+
/bin/
+
+
# Go build cache
+
.cache/
+5 -6
go.mod
···
module Coves
-
go 1.24.0
+
go 1.25
require (
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
···
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/lib/pq v1.10.9
github.com/pressly/goose/v3 v3.22.1
-
github.com/stretchr/testify v1.9.0
+
github.com/stretchr/testify v1.10.0
+
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/net v0.46.0
golang.org/x/time v0.3.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
-
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // 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
-
github.com/stretchr/objx v0.5.2 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
-
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect