A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+14911 -6528
.beads
.claude
commands
.idea
aggregators
cmd
genjwks
validate-lexicon
docs
internal
api
atproto
auth
did
identity
jetstream
lexicon
oauth
utils
xrpc
core
db
jetstream
scripts
static
tests
-12
.env.test.example
···
-
# Test Environment Configuration
-
# This file contains environment variables for running tests
-
# Copy this file to .env.test and update with your actual values
-
-
# Test Database Configuration
-
TEST_DATABASE_URL=postgres://your_test_user:your_test_password@localhost:5434/coves_test?sslmode=disable
-
-
# Test Server Configuration (if needed)
-
TEST_PORT=8081
-
-
# Test CAR Storage Directory
-
TEST_CAR_STORAGE_DIR=/tmp/coves_test_carstore
+17 -10
docs/LOCAL_DEVELOPMENT.md
···
### Testing Commands
```bash
-
make test # Run all tests with test database
-
make test-db-reset # Reset test database
+
make test # Run all tests (starts test DB, runs migrations, executes tests)
+
make test-db-reset # Reset test database (clean slate)
+
make test-db-stop # Stop test database
```
+
**See [TESTING_SUMMARY.md](../TESTING_SUMMARY.md) for complete testing documentation.**
+
### Workflow Commands
```bash
···
## Environment Variables
-
All configuration is in `.env.dev`:
+
All configuration is in [.env.dev](../.env.dev) - a single file for both development and testing:
-
### Database Configuration
+
### Development Database (Port 5433)
```bash
POSTGRES_HOST=localhost
POSTGRES_PORT=5433
···
POSTGRES_PASSWORD=dev_password
```
+
### Test Database (Port 5434)
+
```bash
+
POSTGRES_TEST_DB=coves_test
+
POSTGRES_TEST_USER=test_user
+
POSTGRES_TEST_PASSWORD=test_password
+
POSTGRES_TEST_PORT=5434
+
```
+
### PDS Configuration
```bash
PDS_HOSTNAME=localhost
···
PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev
```
-
### Relay Configuration
-
```bash
-
BGS_PORT=2471
-
BGS_ADMIN_KEY=dev-admin-key
-
```
-
### AppView Configuration
```bash
APPVIEW_PORT=8081
···
LOG_LEVEL=debug
```
+
**No separate `.env.test` file needed!** All configuration (dev + test) is in `.env.dev`.
+
## Next Steps
1. **Build the Firehose Subscriber** - Create the AppView component that subscribes to the relay
-10
internal/core/users/repository.go
···
-
package users
-
-
type UserRepository interface {
-
Create(user *User) (*User, error)
-
GetByID(id int) (*User, error)
-
GetByEmail(email string) (*User, error)
-
GetByUsername(username string) (*User, error)
-
Update(user *User) (*User, error)
-
Delete(id int) error
-
}
-272
internal/core/users/service_test.go
···
-
package users_test
-
-
import (
-
"fmt"
-
"testing"
-
"time"
-
-
"Coves/internal/core/users"
-
)
-
-
type mockUserRepository struct {
-
users map[int]*users.User
-
nextID int
-
shouldFail bool
-
}
-
-
func newMockUserRepository() *mockUserRepository {
-
return &mockUserRepository{
-
users: make(map[int]*users.User),
-
nextID: 1,
-
}
-
}
-
-
func (m *mockUserRepository) Create(user *users.User) (*users.User, error) {
-
if m.shouldFail {
-
return nil, fmt.Errorf("mock: database error")
-
}
-
-
user.ID = m.nextID
-
m.nextID++
-
user.CreatedAt = time.Now()
-
user.UpdatedAt = time.Now()
-
-
m.users[user.ID] = user
-
return user, nil
-
}
-
-
func (m *mockUserRepository) GetByID(id int) (*users.User, error) {
-
if m.shouldFail {
-
return nil, fmt.Errorf("mock: database error")
-
}
-
-
user, exists := m.users[id]
-
if !exists {
-
return nil, fmt.Errorf("repository: user not found")
-
}
-
return user, nil
-
}
-
-
func (m *mockUserRepository) GetByEmail(email string) (*users.User, error) {
-
if m.shouldFail {
-
return nil, fmt.Errorf("mock: database error")
-
}
-
-
for _, user := range m.users {
-
if user.Email == email {
-
return user, nil
-
}
-
}
-
return nil, fmt.Errorf("repository: user not found")
-
}
-
-
func (m *mockUserRepository) GetByUsername(username string) (*users.User, error) {
-
if m.shouldFail {
-
return nil, fmt.Errorf("mock: database error")
-
}
-
-
for _, user := range m.users {
-
if user.Username == username {
-
return user, nil
-
}
-
}
-
return nil, fmt.Errorf("repository: user not found")
-
}
-
-
func (m *mockUserRepository) Update(user *users.User) (*users.User, error) {
-
if m.shouldFail {
-
return nil, fmt.Errorf("mock: database error")
-
}
-
-
if _, exists := m.users[user.ID]; !exists {
-
return nil, fmt.Errorf("repository: user not found")
-
}
-
-
user.UpdatedAt = time.Now()
-
m.users[user.ID] = user
-
return user, nil
-
}
-
-
func (m *mockUserRepository) Delete(id int) error {
-
if m.shouldFail {
-
return fmt.Errorf("mock: database error")
-
}
-
-
if _, exists := m.users[id]; !exists {
-
return fmt.Errorf("repository: user not found")
-
}
-
-
delete(m.users, id)
-
return nil
-
}
-
-
func TestCreateUser(t *testing.T) {
-
repo := newMockUserRepository()
-
service := users.NewUserService(repo)
-
-
tests := []struct {
-
name string
-
req users.CreateUserRequest
-
wantErr bool
-
errMsg string
-
}{
-
{
-
name: "valid user",
-
req: users.CreateUserRequest{
-
Email: "test@example.com",
-
Username: "testuser",
-
},
-
wantErr: false,
-
},
-
{
-
name: "empty email",
-
req: users.CreateUserRequest{
-
Email: "",
-
Username: "testuser",
-
},
-
wantErr: true,
-
errMsg: "email is required",
-
},
-
{
-
name: "empty username",
-
req: users.CreateUserRequest{
-
Email: "test@example.com",
-
Username: "",
-
},
-
wantErr: true,
-
errMsg: "username is required",
-
},
-
{
-
name: "invalid email format",
-
req: users.CreateUserRequest{
-
Email: "invalidemail",
-
Username: "testuser",
-
},
-
wantErr: true,
-
errMsg: "invalid email format",
-
},
-
{
-
name: "short username",
-
req: users.CreateUserRequest{
-
Email: "test@example.com",
-
Username: "ab",
-
},
-
wantErr: true,
-
errMsg: "username must be at least 3 characters",
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
user, err := service.CreateUser(tt.req)
-
-
if tt.wantErr {
-
if err == nil {
-
t.Errorf("expected error but got none")
-
} else if tt.errMsg != "" && err.Error() != "service: "+tt.errMsg {
-
t.Errorf("expected error message '%s' but got '%s'", tt.errMsg, err.Error())
-
}
-
} else {
-
if err != nil {
-
t.Errorf("unexpected error: %v", err)
-
}
-
if user == nil {
-
t.Errorf("expected user but got nil")
-
}
-
}
-
})
-
}
-
}
-
-
func TestCreateUserDuplicates(t *testing.T) {
-
repo := newMockUserRepository()
-
service := users.NewUserService(repo)
-
-
req := users.CreateUserRequest{
-
Email: "test@example.com",
-
Username: "testuser",
-
}
-
-
_, err := service.CreateUser(req)
-
if err != nil {
-
t.Fatalf("unexpected error creating first user: %v", err)
-
}
-
-
_, err = service.CreateUser(req)
-
if err == nil {
-
t.Errorf("expected error for duplicate email but got none")
-
} else if err.Error() != "service: email already exists" {
-
t.Errorf("unexpected error message: %v", err)
-
}
-
-
req2 := users.CreateUserRequest{
-
Email: "different@example.com",
-
Username: "testuser",
-
}
-
-
_, err = service.CreateUser(req2)
-
if err == nil {
-
t.Errorf("expected error for duplicate username but got none")
-
} else if err.Error() != "service: username already exists" {
-
t.Errorf("unexpected error message: %v", err)
-
}
-
}
-
-
func TestGetUserByID(t *testing.T) {
-
repo := newMockUserRepository()
-
service := users.NewUserService(repo)
-
-
createdUser, err := service.CreateUser(users.CreateUserRequest{
-
Email: "test@example.com",
-
Username: "testuser",
-
})
-
if err != nil {
-
t.Fatalf("failed to create user: %v", err)
-
}
-
-
tests := []struct {
-
name string
-
id int
-
wantErr bool
-
errMsg string
-
}{
-
{
-
name: "valid ID",
-
id: createdUser.ID,
-
wantErr: false,
-
},
-
{
-
name: "invalid ID",
-
id: 0,
-
wantErr: true,
-
errMsg: "invalid user ID",
-
},
-
{
-
name: "non-existent ID",
-
id: 999,
-
wantErr: true,
-
errMsg: "user not found",
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
user, err := service.GetUserByID(tt.id)
-
-
if tt.wantErr {
-
if err == nil {
-
t.Errorf("expected error but got none")
-
} else if tt.errMsg != "" && err.Error() != "service: "+tt.errMsg {
-
t.Errorf("expected error message '%s' but got '%s'", tt.errMsg, err.Error())
-
}
-
} else {
-
if err != nil {
-
t.Errorf("unexpected error: %v", err)
-
}
-
if user == nil {
-
t.Errorf("expected user but got nil")
-
}
-
}
-
})
-
}
-
}
-32
internal/db/migrations/005_add_user_maps_indices.sql
···
-
-- +goose Up
-
-- +goose StatementBegin
-
-
-- Note: The user_maps table is created by GORM's AutoMigrate in the carstore package
-
-- Only add indices if the table exists
-
DO $$
-
BEGIN
-
-- Check if user_maps table exists
-
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'user_maps') THEN
-
-- Check if column exists before creating index
-
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'user_maps' AND column_name = 'did') THEN
-
-- Explicit column name specified in GORM tag
-
CREATE INDEX IF NOT EXISTS idx_user_maps_did ON user_maps(did);
-
END IF;
-
-
-- Add index on created_at if column exists
-
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'user_maps' AND column_name = 'created_at') THEN
-
CREATE INDEX IF NOT EXISTS idx_user_maps_created_at ON user_maps(created_at);
-
END IF;
-
END IF;
-
END $$;
-
-
-- +goose StatementEnd
-
-
-- +goose Down
-
-- +goose StatementBegin
-
-
-- Remove indices if they exist
-
DROP INDEX IF EXISTS idx_user_maps_did;
-
DROP INDEX IF EXISTS idx_user_maps_created_at;
-
-
-- +goose StatementEnd
-1
.claude/commands/create-pr.md
···
## Behavior
- Creates a new branch based on current changes
-
- Formats modified files using Biome
- Analyzes changes and automatically splits into logical commits when appropriate
- Each commit focuses on a single logical change or feature
- Creates descriptive commit messages for each logical unit
+85
internal/atproto/lexicon/social/coves/actor/signup.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.actor.signup",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Register a new account on the Coves instance. Creates account on PDS and indexes in AppView.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["handle", "email", "password"],
+
"properties": {
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Requested handle for the account (e.g., alice.coves.dev)"
+
},
+
"email": {
+
"type": "string",
+
"description": "Email address for account recovery and notifications"
+
},
+
"password": {
+
"type": "string",
+
"description": "Account password (must meet strength requirements)"
+
},
+
"inviteCode": {
+
"type": "string",
+
"description": "Invite code (required if instance has invite-only registration)"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "handle", "accessJwt", "refreshJwt"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "The DID of the newly created account"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "The handle of the newly created account"
+
},
+
"accessJwt": {
+
"type": "string",
+
"description": "Access token for authenticated requests"
+
},
+
"refreshJwt": {
+
"type": "string",
+
"description": "Refresh token for obtaining new access tokens"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InvalidHandle",
+
"description": "Handle does not meet format requirements"
+
},
+
{
+
"name": "HandleNotAvailable",
+
"description": "The requested handle is already taken"
+
},
+
{
+
"name": "InvalidInviteCode",
+
"description": "The provided invite code is invalid or expired"
+
},
+
{
+
"name": "InvalidEmail",
+
"description": "The email address is invalid"
+
},
+
{
+
"name": "WeakPassword",
+
"description": "Password does not meet strength requirements"
+
}
+
]
+
}
+
}
+
}
+254
internal/jetstream/user_consumer.go
···
+
package jetstream
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log"
+
"time"
+
+
"Coves/internal/core/users"
+
"github.com/gorilla/websocket"
+
)
+
+
// JetstreamEvent represents an event from the Jetstream firehose
+
// Jetstream documentation: https://docs.bsky.app/docs/advanced-guides/jetstream
+
type JetstreamEvent struct {
+
Did string `json:"did"`
+
TimeUS int64 `json:"time_us"`
+
Kind string `json:"kind"` // "account", "commit", "identity"
+
Account *AccountEvent `json:"account,omitempty"`
+
Identity *IdentityEvent `json:"identity,omitempty"`
+
}
+
+
type AccountEvent struct {
+
Active bool `json:"active"`
+
Did string `json:"did"`
+
Seq int64 `json:"seq"`
+
Time string `json:"time"`
+
}
+
+
type IdentityEvent struct {
+
Did string `json:"did"`
+
Handle string `json:"handle"`
+
Seq int64 `json:"seq"`
+
Time string `json:"time"`
+
}
+
+
// UserEventConsumer consumes user-related events from Jetstream
+
type UserEventConsumer struct {
+
userService users.UserService
+
wsURL string
+
pdsFilter string // Optional: only index users from specific PDS
+
}
+
+
// NewUserEventConsumer creates a new Jetstream consumer for user events
+
func NewUserEventConsumer(userService users.UserService, wsURL string, pdsFilter string) *UserEventConsumer {
+
return &UserEventConsumer{
+
userService: userService,
+
wsURL: wsURL,
+
pdsFilter: pdsFilter,
+
}
+
}
+
+
// Start begins consuming events from Jetstream
+
// Runs indefinitely, reconnecting on errors
+
func (c *UserEventConsumer) Start(ctx context.Context) error {
+
log.Printf("Starting Jetstream user consumer: %s", c.wsURL)
+
+
for {
+
select {
+
case <-ctx.Done():
+
log.Println("Jetstream consumer shutting down")
+
return ctx.Err()
+
default:
+
if err := c.connect(ctx); err != nil {
+
log.Printf("Jetstream connection error: %v. Retrying in 5s...", err)
+
time.Sleep(5 * time.Second)
+
continue
+
}
+
}
+
}
+
}
+
+
// connect establishes WebSocket connection and processes events
+
func (c *UserEventConsumer) 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 conn.Close()
+
+
log.Println("Connected to Jetstream")
+
+
// Set read deadline to detect connection issues
+
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+
+
// Set pong handler to keep connection alive
+
conn.SetPongHandler(func(string) error {
+
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+
return nil
+
})
+
+
// Start ping ticker
+
ticker := time.NewTicker(30 * time.Second)
+
defer ticker.Stop()
+
+
done := make(chan struct{})
+
+
// Goroutine to send pings
+
// TODO: Fix race condition - multiple goroutines can call close(done) concurrently
+
// Use sync.Once to ensure close(done) is called exactly once
+
// See PR review issue #4
+
go func() {
+
for {
+
select {
+
case <-ticker.C:
+
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+
log.Printf("Ping error: %v", err)
+
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 {
+
close(done)
+
return fmt.Errorf("read error: %w", err)
+
}
+
+
// Reset read deadline on successful read
+
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+
+
if err := c.handleEvent(ctx, message); err != nil {
+
log.Printf("Error handling event: %v", err)
+
// Continue processing other events
+
}
+
}
+
}
+
}
+
+
// handleEvent processes a single Jetstream event
+
func (c *UserEventConsumer) 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)
+
}
+
+
// We're interested in identity events (handle updates) and account events (new users)
+
switch event.Kind {
+
case "identity":
+
return c.handleIdentityEvent(ctx, &event)
+
case "account":
+
return c.handleAccountEvent(ctx, &event)
+
default:
+
// Ignore other event types (commits, etc.)
+
return nil
+
}
+
}
+
+
// HandleIdentityEventPublic is a public wrapper for testing
+
func (c *UserEventConsumer) HandleIdentityEventPublic(ctx context.Context, event *JetstreamEvent) error {
+
return c.handleIdentityEvent(ctx, event)
+
}
+
+
// handleIdentityEvent processes identity events (handle changes)
+
func (c *UserEventConsumer) handleIdentityEvent(ctx context.Context, event *JetstreamEvent) error {
+
if event.Identity == nil {
+
return fmt.Errorf("identity event missing identity data")
+
}
+
+
did := event.Identity.Did
+
handle := event.Identity.Handle
+
+
if did == "" || handle == "" {
+
return fmt.Errorf("identity event missing did or handle")
+
}
+
+
log.Printf("Identity event: %s โ†’ %s", did, handle)
+
+
// For now, we'll create/update user on identity events
+
// In a full implementation, you'd want to:
+
// 1. Check if user exists
+
// 2. Update handle if changed
+
// 3. Resolve PDS URL from DID document
+
+
// Simplified: just try to create user (will be idempotent)
+
// We need PDS URL - for now use a placeholder
+
// TODO: Implement DIDโ†’PDS resolution via PLC directory (https://plc.directory/{did})
+
// For production federation support, resolve PDS endpoint from DID document
+
// For local dev, this works fine since we filter to our own PDS
+
// See PR review issue #2
+
pdsURL := "https://bsky.social" // Default Bluesky PDS
+
+
_, err := c.userService.CreateUser(ctx, users.CreateUserRequest{
+
DID: did,
+
Handle: handle,
+
PDSURL: pdsURL,
+
})
+
+
if err != nil {
+
// Check if it's a duplicate error (expected for idempotency)
+
if isDuplicateError(err) {
+
log.Printf("User already indexed: %s (%s)", handle, did)
+
return nil
+
}
+
return fmt.Errorf("failed to create user: %w", err)
+
}
+
+
log.Printf("Indexed new user: %s (%s)", handle, did)
+
return nil
+
}
+
+
// handleAccountEvent processes account events (account creation/updates)
+
func (c *UserEventConsumer) handleAccountEvent(ctx context.Context, event *JetstreamEvent) error {
+
if event.Account == nil {
+
return fmt.Errorf("account event missing account data")
+
}
+
+
did := event.Account.Did
+
if did == "" {
+
return fmt.Errorf("account event missing did")
+
}
+
+
// Account events don't include handle, so we can't index yet
+
// We'll wait for the corresponding identity event
+
log.Printf("Account event for %s (waiting for identity event)", did)
+
return nil
+
}
+
+
// isDuplicateError checks if error is due to duplicate DID/handle
+
func isDuplicateError(err error) bool {
+
if err == nil {
+
return false
+
}
+
errStr := err.Error()
+
return contains(errStr, "already exists") || contains(errStr, "already taken") || contains(errStr, "duplicate")
+
}
+
+
func contains(s, substr string) bool {
+
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && anySubstring(s, substr))
+
}
+
+
func anySubstring(s, substr string) bool {
+
for i := 0; i <= len(s)-len(substr); i++ {
+
if s[i:i+len(substr)] == substr {
+
return true
+
}
+
}
+
return false
+
}
-8
.idea/.gitignore
···
-
# Default ignored files
-
/shelf/
-
/workspace.xml
-
# Editor-based HTTP Client requests
-
/httpRequests/
-
# Datasource local storage ignored files
-
/dataSources/
-
/dataSources.local.xml
-9
.idea/Coves.iml
···
-
<?xml version="1.0" encoding="UTF-8"?>
-
<module type="WEB_MODULE" version="4">
-
<component name="Go" enabled="true" />
-
<component name="NewModuleRootManager">
-
<content url="file://$MODULE_DIR$" />
-
<orderEntry type="inheritedJdk" />
-
<orderEntry type="sourceFolder" forTests="false" />
-
</component>
-
</module>
-17
.idea/dataSources.xml
···
-
<?xml version="1.0" encoding="UTF-8"?>
-
<project version="4">
-
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
-
<data-source source="LOCAL" name="postgres@localhost" uuid="66e25b60-2901-4e78-bdb8-343f2c71fb79">
-
<driver-ref>postgresql</driver-ref>
-
<synchronize>true</synchronize>
-
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
-
<jdbc-url>jdbc:postgresql://localhost:5433/postgres</jdbc-url>
-
<jdbc-additional-properties>
-
<property name="com.intellij.clouds.kubernetes.db.host.port" />
-
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
-
<property name="com.intellij.clouds.kubernetes.db.container.port" />
-
</jdbc-additional-properties>
-
<working-dir>$ProjectFileDir$</working-dir>
-
</data-source>
-
</component>
-
</project>
-8
.idea/modules.xml
···
-
<?xml version="1.0" encoding="UTF-8"?>
-
<project version="4">
-
<component name="ProjectModuleManager">
-
<modules>
-
<module fileurl="file://$PROJECT_DIR$/.idea/Coves.iml" filepath="$PROJECT_DIR$/.idea/Coves.iml" />
-
</modules>
-
</component>
-
</project>
-6
.idea/vcs.xml
···
-
<?xml version="1.0" encoding="UTF-8"?>
-
<project version="4">
-
<component name="VcsDirectoryMappings">
-
<mapping directory="$PROJECT_DIR$" vcs="Git" />
-
</component>
-
</project>
+88
internal/atproto/identity/caching_resolver.go
···
+
package identity
+
+
import (
+
"context"
+
"log"
+
)
+
+
// cachingResolver wraps a base resolver with caching
+
type cachingResolver struct {
+
base Resolver
+
cache IdentityCache
+
}
+
+
// newCachingResolver creates a new caching resolver
+
func newCachingResolver(base Resolver, cache IdentityCache) Resolver {
+
return &cachingResolver{
+
base: base,
+
cache: cache,
+
}
+
}
+
+
// Resolve resolves a handle or DID to complete identity information
+
// First checks cache, then falls back to base resolver
+
func (r *cachingResolver) Resolve(ctx context.Context, identifier string) (*Identity, error) {
+
// Try cache first
+
cached, err := r.cache.Get(ctx, identifier)
+
if err == nil {
+
// Cache hit - mark it as from cache
+
cached.Method = MethodCache
+
return cached, nil
+
}
+
+
// Cache miss - resolve using base resolver
+
identity, err := r.base.Resolve(ctx, identifier)
+
if err != nil {
+
return nil, err
+
}
+
+
// Cache the resolved identity (ignore cache errors, just log them)
+
if cacheErr := r.cache.Set(ctx, identity); cacheErr != nil {
+
log.Printf("Warning: failed to cache identity for %s: %v", identifier, cacheErr)
+
}
+
+
return identity, nil
+
}
+
+
// ResolveHandle specifically resolves a handle to DID and PDS URL
+
func (r *cachingResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) {
+
identity, err := r.Resolve(ctx, handle)
+
if err != nil {
+
return "", "", err
+
}
+
+
return identity.DID, identity.PDSURL, nil
+
}
+
+
// ResolveDID retrieves a DID document and extracts the PDS endpoint
+
func (r *cachingResolver) ResolveDID(ctx context.Context, did string) (*DIDDocument, error) {
+
// Try to get from cache first
+
cached, err := r.cache.Get(ctx, did)
+
if err == nil {
+
// We have cached identity, construct a simple DID document
+
return &DIDDocument{
+
DID: cached.DID,
+
Service: []Service{
+
{
+
ID: "#atproto_pds",
+
Type: "AtprotoPersonalDataServer",
+
ServiceEndpoint: cached.PDSURL,
+
},
+
},
+
}, nil
+
}
+
+
// Cache miss - use base resolver
+
return r.base.ResolveDID(ctx, did)
+
}
+
+
// Purge removes an identifier from the cache and propagates to base
+
func (r *cachingResolver) Purge(ctx context.Context, identifier string) error {
+
// Purge from cache
+
if err := r.cache.Purge(ctx, identifier); err != nil {
+
return err
+
}
+
+
// Propagate to base resolver (though it typically won't cache)
+
return r.base.Purge(ctx, identifier)
+
}
+45
internal/atproto/identity/errors.go
···
+
package identity
+
+
import "fmt"
+
+
// ErrNotFound is returned when an identity cannot be resolved
+
type ErrNotFound struct {
+
Identifier string
+
Reason string
+
}
+
+
func (e *ErrNotFound) Error() string {
+
if e.Reason != "" {
+
return fmt.Sprintf("identity not found: %s (%s)", e.Identifier, e.Reason)
+
}
+
return fmt.Sprintf("identity not found: %s", e.Identifier)
+
}
+
+
// ErrInvalidIdentifier is returned for malformed handles or DIDs
+
type ErrInvalidIdentifier struct {
+
Identifier string
+
Reason string
+
}
+
+
func (e *ErrInvalidIdentifier) Error() string {
+
return fmt.Sprintf("invalid identifier %s: %s", e.Identifier, e.Reason)
+
}
+
+
// ErrCacheMiss is returned when an identifier is not in the cache
+
type ErrCacheMiss struct {
+
Identifier string
+
}
+
+
func (e *ErrCacheMiss) Error() string {
+
return fmt.Sprintf("cache miss: %s", e.Identifier)
+
}
+
+
// ErrResolutionFailed is returned when resolution fails for reasons other than not found
+
type ErrResolutionFailed struct {
+
Identifier string
+
Reason string
+
}
+
+
func (e *ErrResolutionFailed) Error() string {
+
return fmt.Sprintf("resolution failed for %s: %s", e.Identifier, e.Reason)
+
}
+40
internal/atproto/identity/resolver.go
···
+
package identity
+
+
import "context"
+
+
// Resolver provides methods for resolving atProto identities
+
type Resolver interface {
+
// Resolve resolves a handle or DID to complete identity information
+
// The identifier can be either:
+
// - A handle (e.g., "alice.bsky.social")
+
// - A DID (e.g., "did:plc:abc123")
+
Resolve(ctx context.Context, identifier string) (*Identity, error)
+
+
// ResolveHandle specifically resolves a handle to DID and PDS URL
+
// This is a convenience method for handle-only resolution
+
ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error)
+
+
// ResolveDID retrieves a DID document and extracts the PDS endpoint
+
ResolveDID(ctx context.Context, did string) (*DIDDocument, error)
+
+
// Purge removes an identifier from the cache
+
// The identifier can be either a handle or DID
+
Purge(ctx context.Context, identifier string) error
+
}
+
+
// IdentityCache provides caching for resolved identities
+
type IdentityCache interface {
+
// Get retrieves a cached identity by handle or DID
+
Get(ctx context.Context, identifier string) (*Identity, error)
+
+
// Set caches an identity with the given TTL
+
// This should cache bidirectionally (both handle and DID as keys)
+
Set(ctx context.Context, identity *Identity) error
+
+
// Delete removes a cached identity by identifier
+
Delete(ctx context.Context, identifier string) error
+
+
// Purge removes all cache entries associated with an identifier
+
// (both handle and DID if applicable)
+
Purge(ctx context.Context, identifier string) error
+
}
+35
internal/atproto/identity/types.go
···
+
package identity
+
+
import "time"
+
+
// ResolutionMethod indicates how an identity was resolved
+
type ResolutionMethod string
+
+
const (
+
MethodCache ResolutionMethod = "cache"
+
MethodDNS ResolutionMethod = "dns"
+
MethodHTTPS ResolutionMethod = "https"
+
)
+
+
// Identity represents a fully resolved atProto identity
+
type Identity struct {
+
DID string // Decentralized Identifier (e.g., "did:plc:abc123")
+
Handle string // Human-readable handle (e.g., "alice.bsky.social")
+
PDSURL string // Personal Data Server URL
+
ResolvedAt time.Time // When this identity was resolved
+
Method ResolutionMethod // How it was resolved (cache, DNS, HTTPS)
+
}
+
+
// DIDDocument represents an AT Protocol DID document
+
// For now, we only extract the PDS service endpoint
+
type DIDDocument struct {
+
DID string
+
Service []Service
+
}
+
+
// Service represents a service entry in a DID document
+
type Service struct {
+
ID string
+
Type string
+
ServiceEndpoint string
+
}
+58
internal/db/migrations/002_create_identity_cache_table.sql
···
+
-- +goose Up
+
-- +goose StatementBegin
+
CREATE TABLE identity_cache (
+
-- Can lookup by either handle or DID
+
identifier TEXT PRIMARY KEY,
+
+
-- Cached resolution data
+
did TEXT NOT NULL,
+
handle TEXT,
+
pds_url TEXT,
+
+
-- Resolution metadata
+
resolved_at TIMESTAMP WITH TIME ZONE NOT NULL,
+
resolution_method TEXT NOT NULL, -- 'dns', 'https', 'cache'
+
+
-- Cache management
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+
);
+
+
-- Index for reverse lookup (DID โ†’ handle)
+
CREATE INDEX idx_identity_cache_did ON identity_cache(did);
+
+
-- Index for expiry cleanup
+
CREATE INDEX idx_identity_cache_expires ON identity_cache(expires_at);
+
+
-- Function to normalize handles to lowercase
+
CREATE OR REPLACE FUNCTION normalize_handle() RETURNS TRIGGER AS $$
+
BEGIN
+
IF NEW.handle IS NOT NULL THEN
+
NEW.handle = LOWER(TRIM(NEW.handle));
+
END IF;
+
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
+
-- Normalize identifier if it looks like a handle (contains a dot)
+
IF NEW.identifier LIKE '%.%' THEN
+
NEW.identifier = LOWER(TRIM(NEW.identifier));
+
END IF;
+
END IF;
+
NEW.updated_at = CURRENT_TIMESTAMP;
+
RETURN NEW;
+
END;
+
$$ LANGUAGE plpgsql;
+
+
-- Trigger to normalize handles automatically
+
CREATE TRIGGER normalize_handle_trigger
+
BEFORE INSERT OR UPDATE ON identity_cache
+
FOR EACH ROW
+
EXECUTE FUNCTION normalize_handle();
+
+
-- +goose StatementEnd
+
+
-- +goose Down
+
-- +goose StatementBegin
+
DROP TRIGGER IF EXISTS normalize_handle_trigger ON identity_cache;
+
DROP FUNCTION IF EXISTS normalize_handle();
+
DROP TABLE IF EXISTS identity_cache;
+
-- +goose StatementEnd
+1 -1
PRD.md
···
### Phase 1: Core Forum Platform (Web)
#### Must Have:
-
- **Indigo PDS Integration** - Use existing atProto infrastructure (no CAR file reimplementation!)
+
- **Indigo PDS Integration**1 - Use existing atProto infrastructure (no CAR file reimplementation!)
- User registration with phone verification (verified badge)
- Community creation, subscription, and discovery
- Post creation (text initially, then image/video/article)
+69
internal/db/migrations/003_create_oauth_tables.sql
···
+
-- +goose Up
+
-- Create OAuth tables for managing OAuth flow state and user sessions
+
+
-- Temporary state during OAuth authorization flow (30 min TTL)
+
-- This stores the intermediate state between redirect to auth server and callback
+
CREATE TABLE oauth_requests (
+
id SERIAL PRIMARY KEY,
+
state TEXT UNIQUE NOT NULL, -- OAuth state parameter (CSRF protection)
+
did TEXT NOT NULL, -- User's DID (resolved from handle)
+
handle TEXT NOT NULL, -- User's handle (e.g., alice.bsky.social)
+
pds_url TEXT NOT NULL, -- User's PDS URL
+
pkce_verifier TEXT NOT NULL, -- PKCE code verifier
+
dpop_private_jwk JSONB NOT NULL, -- DPoP private key (ES256) for this session
+
dpop_authserver_nonce TEXT, -- DPoP nonce from authorization server
+
auth_server_iss TEXT NOT NULL, -- Authorization server issuer
+
return_url TEXT, -- Optional return URL after login
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+
);
+
+
-- Long-lived user sessions (7 day TTL, auto-refreshed)
+
-- This stores authenticated user sessions with OAuth tokens
+
CREATE TABLE oauth_sessions (
+
id SERIAL PRIMARY KEY,
+
did TEXT UNIQUE NOT NULL, -- User's DID (primary identifier)
+
handle TEXT NOT NULL, -- User's handle (can change)
+
pds_url TEXT NOT NULL, -- User's PDS URL
+
access_token TEXT NOT NULL, -- OAuth access token (DPoP-bound)
+
refresh_token TEXT NOT NULL, -- OAuth refresh token
+
dpop_private_jwk JSONB NOT NULL, -- DPoP private key for this session
+
dpop_authserver_nonce TEXT, -- DPoP nonce for auth server token endpoint
+
dpop_pds_nonce TEXT, -- DPoP nonce for PDS requests
+
auth_server_iss TEXT NOT NULL, -- Authorization server issuer
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, -- Token expiration time
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+
);
+
+
-- Indexes for efficient lookups
+
CREATE INDEX idx_oauth_requests_state ON oauth_requests(state);
+
CREATE INDEX idx_oauth_requests_created_at ON oauth_requests(created_at);
+
CREATE INDEX idx_oauth_sessions_did ON oauth_sessions(did);
+
CREATE INDEX idx_oauth_sessions_expires_at ON oauth_sessions(expires_at);
+
+
-- Function to update updated_at timestamp
+
-- +goose StatementBegin
+
CREATE OR REPLACE FUNCTION update_oauth_session_updated_at()
+
RETURNS TRIGGER AS $$
+
BEGIN
+
NEW.updated_at = CURRENT_TIMESTAMP;
+
RETURN NEW;
+
END;
+
$$ LANGUAGE plpgsql;
+
-- +goose StatementEnd
+
+
-- Trigger to automatically update updated_at
+
CREATE TRIGGER oauth_sessions_updated_at
+
BEFORE UPDATE ON oauth_sessions
+
FOR EACH ROW
+
EXECUTE FUNCTION update_oauth_session_updated_at();
+
+
-- +goose Down
+
DROP TRIGGER IF EXISTS oauth_sessions_updated_at ON oauth_sessions;
+
DROP FUNCTION IF EXISTS update_oauth_session_updated_at();
+
DROP INDEX IF EXISTS idx_oauth_sessions_expires_at;
+
DROP INDEX IF EXISTS idx_oauth_sessions_did;
+
DROP INDEX IF EXISTS idx_oauth_requests_created_at;
+
DROP INDEX IF EXISTS idx_oauth_requests_state;
+
DROP TABLE IF EXISTS oauth_sessions;
+
DROP TABLE IF EXISTS oauth_requests;
+39
internal/db/migrations/006_encrypt_community_credentials.sql
···
+
-- +goose Up
+
-- Enable pgcrypto extension for encryption at rest
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+
-- Create encryption key table (single-row config table)
+
-- SECURITY: In production, use environment variable or external key management
+
CREATE TABLE encryption_keys (
+
id INTEGER PRIMARY KEY CHECK (id = 1),
+
key_data BYTEA NOT NULL,
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
rotated_at TIMESTAMP WITH TIME ZONE
+
);
+
+
-- Insert default encryption key
+
INSERT INTO encryption_keys (id, key_data)
+
VALUES (1, gen_random_bytes(32))
+
ON CONFLICT (id) DO NOTHING;
+
+
-- Add encrypted columns
+
ALTER TABLE communities
+
ADD COLUMN pds_access_token_encrypted BYTEA,
+
ADD COLUMN pds_refresh_token_encrypted BYTEA;
+
+
-- Add index for communities with credentials
+
CREATE INDEX idx_communities_encrypted_tokens ON communities(did) WHERE pds_access_token_encrypted IS NOT NULL;
+
+
-- Security comments
+
COMMENT ON TABLE encryption_keys IS 'Encryption keys for sensitive data - RESTRICT ACCESS';
+
COMMENT ON COLUMN communities.pds_access_token_encrypted IS 'Encrypted JWT - decrypt with pgp_sym_decrypt';
+
COMMENT ON COLUMN communities.pds_refresh_token_encrypted IS 'Encrypted refresh token - decrypt with pgp_sym_decrypt';
+
+
-- +goose Down
+
DROP INDEX IF EXISTS idx_communities_encrypted_tokens;
+
+
ALTER TABLE communities
+
DROP COLUMN IF EXISTS pds_access_token_encrypted,
+
DROP COLUMN IF EXISTS pds_refresh_token_encrypted;
+
+
DROP TABLE IF EXISTS encryption_keys;
+4 -4
internal/api/middleware/ratelimit.go
···
// RateLimiter implements a simple in-memory rate limiter
// For production, consider using Redis or a distributed rate limiter
type RateLimiter struct {
-
mu sync.Mutex
clients map[string]*clientLimit
-
requests int // Max requests per window
-
window time.Duration // Time window
+
requests int
+
window time.Duration
+
mu sync.Mutex
}
type clientLimit struct {
-
count int
resetTime time.Time
+
count int
}
// NewRateLimiter creates a new rate limiter
+2 -2
internal/core/errors/errors.go
···
}
type NotFoundError struct {
-
Resource string
ID interface{}
+
Resource string
}
func (e NotFoundError) Error() string {
···
Resource: resource,
ID: id,
}
-
}
+
}
+8 -8
internal/core/users/user.go
···
// This is NOT the user's repository - that lives in the PDS
// This table only tracks metadata for efficient AppView queries
type User struct {
-
DID string `json:"did" db:"did"` // atProto DID (e.g., did:plc:xyz123)
-
Handle string `json:"handle" db:"handle"` // Human-readable handle (e.g., alice.coves.dev)
-
PDSURL string `json:"pdsUrl" db:"pds_url"` // User's PDS host URL (supports federation)
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
+
DID string `json:"did" db:"did"`
+
Handle string `json:"handle" db:"handle"`
+
PDSURL string `json:"pdsUrl" db:"pds_url"`
}
// CreateUserRequest represents the input for creating a new user
···
// RegisterAccountResponse represents the response from PDS account creation
type RegisterAccountResponse struct {
-
DID string `json:"did"`
-
Handle string `json:"handle"`
-
AccessJwt string `json:"accessJwt"`
-
RefreshJwt string `json:"refreshJwt"`
-
PDSURL string `json:"pdsUrl"`
+
DID string `json:"did"`
+
Handle string `json:"handle"`
+
AccessJwt string `json:"accessJwt"`
+
RefreshJwt string `json:"refreshJwt"`
+
PDSURL string `json:"pdsUrl"`
}
+35
internal/db/migrations/007_add_password_encryption.sql
···
+
-- +goose Up
+
-- +goose StatementBegin
+
-- V2.0: Add encrypted password column for PDS account recovery
+
-- CRITICAL FIX: Password must be encrypted (not hashed) for session recovery
+
-- When access/refresh tokens expire (90-day window), we need the plaintext password
+
-- to call com.atproto.server.createSession - bcrypt hashing prevents this
+
+
-- Add encrypted password column
+
ALTER TABLE communities ADD COLUMN pds_password_encrypted BYTEA;
+
+
-- Drop legacy plaintext token columns (we now use *_encrypted versions from migration 006)
+
ALTER TABLE communities DROP COLUMN IF EXISTS pds_access_token;
+
ALTER TABLE communities DROP COLUMN IF EXISTS pds_refresh_token;
+
+
-- Drop legacy password_hash column from migration 005 (never used in production)
+
ALTER TABLE communities DROP COLUMN IF EXISTS pds_password_hash;
+
+
-- Add comment
+
COMMENT ON COLUMN communities.pds_password_encrypted IS 'Encrypted community PDS password (pgp_sym_encrypt) - required for session recovery when tokens expire';
+
+
-- +goose StatementEnd
+
+
-- +goose Down
+
-- +goose StatementBegin
+
-- Restore legacy columns (for rollback compatibility)
+
ALTER TABLE communities ADD COLUMN pds_access_token TEXT;
+
ALTER TABLE communities ADD COLUMN pds_refresh_token TEXT;
+
ALTER TABLE communities ADD COLUMN pds_password_hash TEXT;
+
+
-- Drop encrypted password
+
ALTER TABLE communities DROP COLUMN IF EXISTS pds_password_encrypted;
+
+
-- Restore old comment
+
COMMENT ON COLUMN communities.pds_password_hash IS 'bcrypt hash of community PDS password (DEPRECATED - cannot recover plaintext)';
+
-- +goose StatementEnd
-80
internal/atproto/did/generator.go
···
-
package did
-
-
import (
-
"crypto/rand"
-
"encoding/base32"
-
"fmt"
-
"strings"
-
)
-
-
// Generator creates DIDs for Coves entities
-
type Generator struct {
-
plcDirectoryURL string
-
isDevEnv bool
-
}
-
-
// NewGenerator creates a new DID generator
-
// isDevEnv: true for local development (no PLC registration), false for production (register with PLC)
-
// plcDirectoryURL: URL for PLC directory (e.g., "https://plc.directory")
-
func NewGenerator(isDevEnv bool, plcDirectoryURL string) *Generator {
-
return &Generator{
-
isDevEnv: isDevEnv,
-
plcDirectoryURL: plcDirectoryURL,
-
}
-
}
-
-
// GenerateCommunityDID creates a new random DID for a community
-
// Format: did:plc:{base32-random}
-
//
-
// Dev mode (isDevEnv=true): Generates did:plc:xxx without registering to PLC
-
// Prod mode (isDevEnv=false): Generates did:plc:xxx AND registers with PLC directory
-
//
-
// See: https://github.com/bluesky-social/did-method-plc
-
func (g *Generator) GenerateCommunityDID() (string, error) {
-
// Generate 16 random bytes for the DID identifier
-
randomBytes := make([]byte, 16)
-
if _, err := rand.Read(randomBytes); err != nil {
-
return "", fmt.Errorf("failed to generate random DID: %w", err)
-
}
-
-
// Encode as base32 (lowercase, no padding) - matches PLC format
-
encoded := base32.StdEncoding.EncodeToString(randomBytes)
-
encoded = strings.ToLower(strings.TrimRight(encoded, "="))
-
-
did := fmt.Sprintf("did:plc:%s", encoded)
-
-
// TODO: In production (isDevEnv=false), register this DID with PLC directory
-
// This would involve:
-
// 1. Generate signing keypair for the DID
-
// 2. Create DID document with service endpoints
-
// 3. POST to plcDirectoryURL to register
-
// 4. Store keypair securely for future DID updates
-
//
-
// For now, we just generate the identifier (works fine for local dev)
-
// Production PLC registration is not yet implemented - DIDs are generated
-
// locally but not registered with the PLC directory. This is acceptable
-
// for development and private instances, but production deployments should
-
// implement full PLC registration to ensure DIDs are globally resolvable.
-
_ = g.isDevEnv // Acknowledge that isDevEnv will be used when PLC registration is implemented
-
-
return did, nil
-
}
-
-
// ValidateDID checks if a DID string is properly formatted
-
// Supports did:plc, did:web (for instances)
-
func ValidateDID(did string) bool {
-
if !strings.HasPrefix(did, "did:") {
-
return false
-
}
-
-
parts := strings.Split(did, ":")
-
if len(parts) < 3 {
-
return false
-
}
-
-
method := parts[1]
-
identifier := parts[2]
-
-
// Basic validation: method and identifier must not be empty
-
return method != "" && identifier != ""
-
}
-127
internal/atproto/did/generator_test.go
···
-
package did
-
-
import (
-
"strings"
-
"testing"
-
)
-
-
func TestGenerateCommunityDID(t *testing.T) {
-
tests := []struct {
-
name string
-
plcDirectoryURL string
-
want string
-
isDevEnv bool
-
}{
-
{
-
name: "generates did:plc in dev mode",
-
isDevEnv: true,
-
plcDirectoryURL: "https://plc.directory",
-
want: "did:plc:",
-
},
-
{
-
name: "generates did:plc in prod mode",
-
isDevEnv: false,
-
plcDirectoryURL: "https://plc.directory",
-
want: "did:plc:",
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
g := NewGenerator(tt.isDevEnv, tt.plcDirectoryURL)
-
did, err := g.GenerateCommunityDID()
-
if err != nil {
-
t.Fatalf("GenerateCommunityDID() error = %v", err)
-
}
-
-
if !strings.HasPrefix(did, tt.want) {
-
t.Errorf("GenerateCommunityDID() = %v, want prefix %v", did, tt.want)
-
}
-
-
// Verify it's a valid DID
-
if !ValidateDID(did) {
-
t.Errorf("Generated DID failed validation: %v", did)
-
}
-
})
-
}
-
}
-
-
func TestGenerateCommunityDID_Uniqueness(t *testing.T) {
-
g := NewGenerator(true, "https://plc.directory")
-
-
// Generate 100 DIDs and ensure they're all unique
-
dids := make(map[string]bool)
-
for i := 0; i < 100; i++ {
-
did, err := g.GenerateCommunityDID()
-
if err != nil {
-
t.Fatalf("GenerateCommunityDID() error = %v", err)
-
}
-
-
if dids[did] {
-
t.Errorf("Duplicate DID generated: %v", did)
-
}
-
dids[did] = true
-
}
-
}
-
-
func TestValidateDID(t *testing.T) {
-
tests := []struct {
-
name string
-
did string
-
want bool
-
}{
-
{
-
name: "valid did:plc",
-
did: "did:plc:z72i7hdynmk6r22z27h6tvur",
-
want: true,
-
},
-
{
-
name: "valid did:plc with base32",
-
did: "did:plc:abc123xyz",
-
want: true,
-
},
-
{
-
name: "valid did:web",
-
did: "did:web:coves.social",
-
want: true,
-
},
-
{
-
name: "valid did:web with path",
-
did: "did:web:coves.social:community:gaming",
-
want: true,
-
},
-
{
-
name: "invalid: missing prefix",
-
did: "plc:abc123",
-
want: false,
-
},
-
{
-
name: "invalid: missing method",
-
did: "did::abc123",
-
want: false,
-
},
-
{
-
name: "invalid: missing identifier",
-
did: "did:plc:",
-
want: false,
-
},
-
{
-
name: "invalid: only did",
-
did: "did:",
-
want: false,
-
},
-
{
-
name: "invalid: empty string",
-
did: "",
-
want: false,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
if got := ValidateDID(tt.did); got != tt.want {
-
t.Errorf("ValidateDID(%v) = %v, want %v", tt.did, got, tt.want)
-
}
-
})
-
}
-
}
-210
internal/api/handlers/oauth/callback.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"log"
-
"net/http"
-
"os"
-
"strings"
-
"time"
-
-
oauthCore "Coves/internal/core/oauth"
-
)
-
-
const (
-
sessionName = "coves_session"
-
sessionDID = "did"
-
)
-
-
// CallbackHandler handles OAuth callback
-
type CallbackHandler struct {
-
sessionStore oauthCore.SessionStore
-
}
-
-
// NewCallbackHandler creates a new callback handler
-
func NewCallbackHandler(sessionStore oauthCore.SessionStore) *CallbackHandler {
-
return &CallbackHandler{
-
sessionStore: sessionStore,
-
}
-
}
-
-
// HandleCallback processes the OAuth callback
-
// GET /oauth/callback?code=...&state=...&iss=...
-
func (h *CallbackHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
-
// Extract query parameters
-
code := r.URL.Query().Get("code")
-
state := r.URL.Query().Get("state")
-
iss := r.URL.Query().Get("iss")
-
errorParam := r.URL.Query().Get("error")
-
errorDesc := r.URL.Query().Get("error_description")
-
-
// Check for authorization errors
-
if errorParam != "" {
-
log.Printf("OAuth error: %s - %s", errorParam, errorDesc)
-
http.Error(w, "Authorization failed", http.StatusBadRequest)
-
return
-
}
-
-
// Validate required parameters
-
if code == "" || state == "" || iss == "" {
-
http.Error(w, "Missing required OAuth parameters", http.StatusBadRequest)
-
return
-
}
-
-
// Retrieve and delete OAuth request atomically to prevent replay attacks
-
oauthReq, err := h.sessionStore.GetAndDeleteRequest(state)
-
if err != nil {
-
log.Printf("Failed to retrieve OAuth request for state %s: %v", state, err)
-
http.Error(w, "Invalid or expired authorization request", http.StatusBadRequest)
-
return
-
}
-
-
// Verify issuer matches
-
if iss != oauthReq.AuthServerIss {
-
log.Printf("Issuer mismatch: expected %s, got %s", oauthReq.AuthServerIss, iss)
-
http.Error(w, "Authorization server mismatch", http.StatusBadRequest)
-
return
-
}
-
-
// Get OAuth client configuration (supports base64 encoding)
-
privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK")
-
if err != nil {
-
log.Printf("Failed to load OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
if privateJWK == "" {
-
http.Error(w, "OAuth not configured", http.StatusInternalServerError)
-
return
-
}
-
-
privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK))
-
if err != nil {
-
log.Printf("Failed to parse OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
-
appviewURL := getAppViewURL()
-
clientID := getClientID(appviewURL)
-
redirectURI := appviewURL + "/oauth/callback"
-
-
// Create OAuth client
-
client := oauth.NewClient(clientID, privateKey, redirectURI)
-
-
// Parse DPoP key from OAuth request
-
dpopKey, err := oauth.ParseJWKFromJSON([]byte(oauthReq.DPoPPrivateJWK))
-
if err != nil {
-
log.Printf("Failed to parse DPoP key: %v", err)
-
http.Error(w, "Failed to restore session key", http.StatusInternalServerError)
-
return
-
}
-
-
// Exchange authorization code for tokens
-
tokenResp, err := client.InitialTokenRequest(
-
r.Context(),
-
code,
-
oauthReq.AuthServerIss,
-
oauthReq.PKCEVerifier,
-
oauthReq.DPoPAuthServerNonce,
-
dpopKey,
-
)
-
if err != nil {
-
log.Printf("Failed to exchange code for tokens: %v", err)
-
http.Error(w, "Failed to obtain access tokens", http.StatusInternalServerError)
-
return
-
}
-
-
// Verify token type is DPoP
-
if tokenResp.TokenType != "DPoP" {
-
log.Printf("Expected DPoP token type, got: %s", tokenResp.TokenType)
-
http.Error(w, "Invalid token type", http.StatusInternalServerError)
-
return
-
}
-
-
// Verify subject (DID) matches
-
if tokenResp.Sub != oauthReq.DID {
-
log.Printf("DID mismatch: expected %s, got %s", oauthReq.DID, tokenResp.Sub)
-
http.Error(w, "Identity verification failed", http.StatusBadRequest)
-
return
-
}
-
-
// Calculate token expiration
-
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
-
-
// Serialize DPoP key for storage
-
dpopKeyJSON, err := oauth.JWKToJSON(dpopKey)
-
if err != nil {
-
log.Printf("Failed to serialize DPoP key: %v", err)
-
http.Error(w, "Failed to store session", http.StatusInternalServerError)
-
return
-
}
-
-
// Save OAuth session to database
-
session := &oauthCore.OAuthSession{
-
DID: oauthReq.DID,
-
Handle: oauthReq.Handle,
-
PDSURL: oauthReq.PDSURL,
-
AccessToken: tokenResp.AccessToken,
-
RefreshToken: tokenResp.RefreshToken,
-
DPoPPrivateJWK: string(dpopKeyJSON),
-
DPoPAuthServerNonce: tokenResp.DpopAuthserverNonce,
-
DPoPPDSNonce: "", // Will be populated on first PDS request
-
AuthServerIss: oauthReq.AuthServerIss,
-
ExpiresAt: expiresAt,
-
}
-
-
if saveErr := h.sessionStore.SaveSession(session); saveErr != nil {
-
log.Printf("Failed to save OAuth session: %v", saveErr)
-
http.Error(w, "Failed to save session", http.StatusInternalServerError)
-
return
-
}
-
-
// Note: OAuth request already deleted atomically in GetAndDeleteRequest above
-
-
// Create HTTP session cookie
-
cookieStore := GetCookieStore()
-
httpSession, err := cookieStore.Get(r, sessionName)
-
if err != nil {
-
log.Printf("Failed to get cookie session: %v", err)
-
// Try to create a new session anyway
-
httpSession, err = cookieStore.New(r, sessionName)
-
if err != nil {
-
log.Printf("Failed to create new session: %v", err)
-
http.Error(w, "Failed to create session", http.StatusInternalServerError)
-
return
-
}
-
}
-
-
httpSession.Values[sessionDID] = oauthReq.DID
-
httpSession.Options.MaxAge = SessionMaxAge
-
httpSession.Options.HttpOnly = true
-
httpSession.Options.Secure = !isDevelopment() // HTTPS only in production
-
httpSession.Options.SameSite = http.SameSiteLaxMode
-
-
if err := httpSession.Save(r, w); err != nil {
-
log.Printf("Failed to save HTTP session: %v", err)
-
http.Error(w, "Failed to create session", http.StatusInternalServerError)
-
return
-
}
-
-
// Determine redirect URL
-
returnURL := oauthReq.ReturnURL
-
if returnURL == "" {
-
returnURL = "/"
-
}
-
-
// Redirect user back to application
-
http.Redirect(w, r, returnURL, http.StatusFound)
-
}
-
-
// isDevelopment checks if we're running in development mode
-
func isDevelopment() bool {
-
// Explicitly check for localhost/127.0.0.1 on any port
-
appviewURL := os.Getenv("APPVIEW_PUBLIC_URL")
-
return appviewURL == "" ||
-
strings.HasPrefix(appviewURL, "http://localhost:") ||
-
strings.HasPrefix(appviewURL, "http://localhost/") ||
-
strings.HasPrefix(appviewURL, "http://127.0.0.1:") ||
-
strings.HasPrefix(appviewURL, "http://127.0.0.1/")
-
}
-131
internal/api/handlers/oauth/env_test.go
···
-
package oauth
-
-
import (
-
"encoding/base64"
-
"os"
-
"testing"
-
)
-
-
func TestGetEnvBase64OrPlain(t *testing.T) {
-
tests := []struct {
-
name string
-
envKey string
-
envValue string
-
want string
-
wantError bool
-
}{
-
{
-
name: "plain JSON value",
-
envKey: "TEST_PLAIN_JSON",
-
envValue: `{"alg":"ES256","kty":"EC"}`,
-
want: `{"alg":"ES256","kty":"EC"}`,
-
wantError: false,
-
},
-
{
-
name: "base64 encoded value",
-
envKey: "TEST_BASE64_JSON",
-
envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte(`{"alg":"ES256","kty":"EC"}`)),
-
want: `{"alg":"ES256","kty":"EC"}`,
-
wantError: false,
-
},
-
{
-
name: "empty value",
-
envKey: "TEST_EMPTY",
-
envValue: "",
-
want: "",
-
wantError: false,
-
},
-
{
-
name: "invalid base64",
-
envKey: "TEST_INVALID_BASE64",
-
envValue: "base64:not-valid-base64!!!",
-
want: "",
-
wantError: true,
-
},
-
{
-
name: "plain string with special chars",
-
envKey: "TEST_SPECIAL_CHARS",
-
envValue: "secret-with-dashes_and_underscores",
-
want: "secret-with-dashes_and_underscores",
-
wantError: false,
-
},
-
{
-
name: "base64 encoded hex string",
-
envKey: "TEST_BASE64_HEX",
-
envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte("f1132c01b1a625a865c6c455a75ee793")),
-
want: "f1132c01b1a625a865c6c455a75ee793",
-
wantError: false,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
// Set environment variable
-
if tt.envValue != "" {
-
if err := os.Setenv(tt.envKey, tt.envValue); err != nil {
-
t.Fatalf("Failed to set env var: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv(tt.envKey); err != nil {
-
t.Errorf("Failed to unset env var: %v", err)
-
}
-
}()
-
}
-
-
got, err := GetEnvBase64OrPlain(tt.envKey)
-
-
if (err != nil) != tt.wantError {
-
t.Errorf("GetEnvBase64OrPlain() error = %v, wantError %v", err, tt.wantError)
-
return
-
}
-
-
if got != tt.want {
-
t.Errorf("GetEnvBase64OrPlain() = %v, want %v", got, tt.want)
-
}
-
})
-
}
-
}
-
-
func TestGetEnvBase64OrPlain_RealWorldJWK(t *testing.T) {
-
// Test with a real JWK (the one from .env.dev)
-
realJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
-
-
tests := []struct {
-
name string
-
envValue string
-
want string
-
}{
-
{
-
name: "plain JWK",
-
envValue: realJWK,
-
want: realJWK,
-
},
-
{
-
name: "base64 encoded JWK",
-
envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte(realJWK)),
-
want: realJWK,
-
},
-
}
-
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
if err := os.Setenv("TEST_REAL_JWK", tt.envValue); err != nil {
-
t.Fatalf("Failed to set env var: %v", err)
-
}
-
defer func() {
-
if err := os.Unsetenv("TEST_REAL_JWK"); err != nil {
-
t.Errorf("Failed to unset env var: %v", err)
-
}
-
}()
-
-
got, err := GetEnvBase64OrPlain("TEST_REAL_JWK")
-
if err != nil {
-
t.Fatalf("unexpected error: %v", err)
-
}
-
-
if got != tt.want {
-
t.Errorf("GetEnvBase64OrPlain() = %v, want %v", got, tt.want)
-
}
-
})
-
}
-
}
-53
internal/api/handlers/oauth/jwks.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"encoding/json"
-
"log"
-
"net/http"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// HandleJWKS serves the JSON Web Key Set (JWKS) containing the public key
-
// GET /oauth/jwks.json
-
func HandleJWKS(w http.ResponseWriter, r *http.Request) {
-
// Get private key from environment (supports base64 encoding)
-
privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK")
-
if err != nil {
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
if privateJWK == "" {
-
http.Error(w, "OAuth not configured", http.StatusInternalServerError)
-
return
-
}
-
-
// Parse private key
-
privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK))
-
if err != nil {
-
http.Error(w, "Failed to parse private key", http.StatusInternalServerError)
-
return
-
}
-
-
// Get public key
-
publicKey, err := privateKey.PublicKey()
-
if err != nil {
-
http.Error(w, "Failed to get public key", http.StatusInternalServerError)
-
return
-
}
-
-
// Create JWKS
-
jwks := jwk.NewSet()
-
if err := jwks.AddKey(publicKey); err != nil {
-
http.Error(w, "Failed to create JWKS", http.StatusInternalServerError)
-
return
-
}
-
-
// Serve JWKS
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(jwks); err != nil {
-
log.Printf("Failed to encode JWKS response: %v", err)
-
}
-
}
-177
internal/api/handlers/oauth/login.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/oauth"
-
"encoding/json"
-
"log"
-
"net/http"
-
"net/url"
-
"strings"
-
-
oauthCore "Coves/internal/core/oauth"
-
)
-
-
// LoginHandler handles OAuth login flow initiation
-
type LoginHandler struct {
-
identityResolver identity.Resolver
-
sessionStore oauthCore.SessionStore
-
}
-
-
// NewLoginHandler creates a new login handler
-
func NewLoginHandler(identityResolver identity.Resolver, sessionStore oauthCore.SessionStore) *LoginHandler {
-
return &LoginHandler{
-
identityResolver: identityResolver,
-
sessionStore: sessionStore,
-
}
-
}
-
-
// HandleLogin initiates the OAuth login flow
-
// POST /oauth/login
-
// Body: { "handle": "alice.bsky.social" }
-
func (h *LoginHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
-
if r.Method != http.MethodPost {
-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
-
return
-
}
-
-
// Parse request body
-
var req struct {
-
Handle string `json:"handle"`
-
ReturnURL string `json:"returnUrl,omitempty"`
-
}
-
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
// Normalize handle
-
handle := strings.TrimSpace(strings.ToLower(req.Handle))
-
handle = strings.TrimPrefix(handle, "@")
-
-
// Validate handle format
-
if handle == "" || !strings.Contains(handle, ".") {
-
http.Error(w, "Invalid handle format", http.StatusBadRequest)
-
return
-
}
-
-
// Resolve handle to DID and PDS
-
resolved, err := h.identityResolver.Resolve(r.Context(), handle)
-
if err != nil {
-
log.Printf("Failed to resolve handle %s: %v", handle, err)
-
http.Error(w, "Unable to find that account", http.StatusBadRequest)
-
return
-
}
-
-
// Get OAuth client configuration (supports base64 encoding)
-
privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK")
-
if err != nil {
-
log.Printf("Failed to load OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
if privateJWK == "" {
-
http.Error(w, "OAuth not configured", http.StatusInternalServerError)
-
return
-
}
-
-
privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK))
-
if err != nil {
-
log.Printf("Failed to parse OAuth private key: %v", err)
-
http.Error(w, "OAuth configuration error", http.StatusInternalServerError)
-
return
-
}
-
-
appviewURL := getAppViewURL()
-
clientID := getClientID(appviewURL)
-
redirectURI := appviewURL + "/oauth/callback"
-
-
// Create OAuth client
-
client := oauth.NewClient(clientID, privateKey, redirectURI)
-
-
// Discover auth server from PDS
-
pdsURL := resolved.PDSURL
-
authServerIss, err := client.ResolvePDSAuthServer(r.Context(), pdsURL)
-
if err != nil {
-
log.Printf("Failed to resolve auth server for PDS %s: %v", pdsURL, err)
-
http.Error(w, "Failed to discover authorization server", http.StatusInternalServerError)
-
return
-
}
-
-
// Fetch auth server metadata
-
authMeta, err := client.FetchAuthServerMetadata(r.Context(), authServerIss)
-
if err != nil {
-
log.Printf("Failed to fetch auth server metadata: %v", err)
-
http.Error(w, "Failed to fetch authorization server metadata", http.StatusInternalServerError)
-
return
-
}
-
-
// Generate DPoP key for this session
-
dpopKey, err := oauth.GenerateDPoPKey()
-
if err != nil {
-
log.Printf("Failed to generate DPoP key: %v", err)
-
http.Error(w, "Failed to generate session key", http.StatusInternalServerError)
-
return
-
}
-
-
// Send PAR request
-
parResp, err := client.SendPARRequest(r.Context(), authMeta, handle, "atproto transition:generic", dpopKey)
-
if err != nil {
-
log.Printf("Failed to send PAR request: %v", err)
-
http.Error(w, "Failed to initiate authorization", http.StatusInternalServerError)
-
return
-
}
-
-
// Serialize DPoP key to JSON
-
dpopKeyJSON, err := oauth.JWKToJSON(dpopKey)
-
if err != nil {
-
log.Printf("Failed to serialize DPoP key: %v", err)
-
http.Error(w, "Failed to store session key", http.StatusInternalServerError)
-
return
-
}
-
-
// Save OAuth request state to database
-
oauthReq := &oauthCore.OAuthRequest{
-
State: parResp.State,
-
DID: resolved.DID,
-
Handle: handle,
-
PDSURL: pdsURL,
-
PKCEVerifier: parResp.PKCEVerifier,
-
DPoPPrivateJWK: string(dpopKeyJSON),
-
DPoPAuthServerNonce: parResp.DpopAuthserverNonce,
-
AuthServerIss: authServerIss,
-
ReturnURL: req.ReturnURL,
-
}
-
-
if saveErr := h.sessionStore.SaveRequest(oauthReq); saveErr != nil {
-
log.Printf("Failed to save OAuth request: %v", saveErr)
-
http.Error(w, "Failed to save authorization state", http.StatusInternalServerError)
-
return
-
}
-
-
// Build authorization URL
-
authURL, err := url.Parse(authMeta.AuthorizationEndpoint)
-
if err != nil {
-
log.Printf("Invalid authorization endpoint: %v", err)
-
http.Error(w, "Invalid authorization endpoint", http.StatusInternalServerError)
-
return
-
}
-
-
query := authURL.Query()
-
query.Set("client_id", clientID)
-
query.Set("request_uri", parResp.RequestURI)
-
authURL.RawQuery = query.Encode()
-
-
// Return authorization URL to client
-
resp := map[string]string{
-
"authorizationUrl": authURL.String(),
-
"state": parResp.State,
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(resp); err != nil {
-
log.Printf("Failed to encode response: %v", err)
-
}
-
}
-86
internal/api/handlers/oauth/metadata.go
···
-
package oauth
-
-
import (
-
"encoding/json"
-
"net/http"
-
"os"
-
"strings"
-
)
-
-
// ClientMetadata represents OAuth 2.0 client metadata (RFC 7591)
-
// Served at /oauth/client-metadata.json
-
type ClientMetadata struct {
-
ClientID string `json:"client_id"`
-
ClientName string `json:"client_name"`
-
ClientURI string `json:"client_uri"`
-
Scope string `json:"scope"`
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
-
ApplicationType string `json:"application_type"`
-
JwksURI string `json:"jwks_uri,omitempty"`
-
RedirectURIs []string `json:"redirect_uris"`
-
GrantTypes []string `json:"grant_types"`
-
ResponseTypes []string `json:"response_types"`
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
-
}
-
-
// HandleClientMetadata serves the OAuth client metadata
-
// GET /oauth/client-metadata.json
-
func HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
-
appviewURL := getAppViewURL()
-
-
// Determine client ID based on environment
-
clientID := getClientID(appviewURL)
-
jwksURI := ""
-
-
// Only include JWKS URI in production (not for loopback clients)
-
if !strings.HasPrefix(appviewURL, "http://localhost") && !strings.HasPrefix(appviewURL, "http://127.0.0.1") {
-
jwksURI = appviewURL + "/oauth/jwks.json"
-
}
-
-
metadata := ClientMetadata{
-
ClientID: clientID,
-
ClientName: "Coves",
-
ClientURI: appviewURL,
-
RedirectURIs: []string{appviewURL + "/oauth/callback"},
-
GrantTypes: []string{"authorization_code", "refresh_token"},
-
ResponseTypes: []string{"code"},
-
Scope: "atproto transition:generic",
-
TokenEndpointAuthMethod: "private_key_jwt",
-
TokenEndpointAuthSigningAlg: "ES256",
-
DpopBoundAccessTokens: true,
-
ApplicationType: "web",
-
JwksURI: jwksURI,
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
if err := json.NewEncoder(w).Encode(metadata); err != nil {
-
// Log encoding errors but don't return error response (headers already sent)
-
// This follows Go's standard practice for HTTP handlers
-
_ = err
-
}
-
}
-
-
// getAppViewURL returns the public URL of the AppView
-
func getAppViewURL() string {
-
url := os.Getenv("APPVIEW_PUBLIC_URL")
-
if url == "" {
-
// Default to localhost for development
-
url = "http://localhost:8081"
-
}
-
return strings.TrimSuffix(url, "/")
-
}
-
-
// getClientID returns the OAuth client ID based on environment
-
// For localhost development, use loopback client identifier
-
// For production, use HTTPS URL to client metadata
-
func getClientID(appviewURL string) string {
-
// Development: use loopback client (http://localhost?...)
-
if strings.HasPrefix(appviewURL, "http://localhost") || strings.HasPrefix(appviewURL, "http://127.0.0.1") {
-
return "http://localhost?redirect_uri=" + appviewURL + "/oauth/callback&scope=atproto%20transition:generic"
-
}
-
-
// Production: use HTTPS URL to client metadata
-
return appviewURL + "/oauth/client-metadata.json"
-
}
-167
internal/atproto/oauth/dpop.go
···
-
package oauth
-
-
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"crypto/rand"
-
"crypto/sha256"
-
"encoding/base64"
-
"encoding/json"
-
"fmt"
-
"time"
-
-
"github.com/lestrrat-go/jwx/v2/jwa"
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
"github.com/lestrrat-go/jwx/v2/jws"
-
"github.com/lestrrat-go/jwx/v2/jwt"
-
)
-
-
// DPoP (Demonstrating Proof of Possession) - RFC 9449
-
// Binds access tokens to specific clients using cryptographic proofs
-
-
// GenerateDPoPKey generates a new ES256 (NIST P-256) keypair for DPoP
-
// Each OAuth session should have its own unique DPoP key
-
func GenerateDPoPKey() (jwk.Key, error) {
-
// Generate ES256 private key
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-
if err != nil {
-
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
-
}
-
-
// Convert to JWK
-
jwkKey, err := jwk.FromRaw(privateKey)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create JWK from private key: %w", err)
-
}
-
-
// Set JWK parameters
-
if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
-
return nil, fmt.Errorf("failed to set algorithm: %w", err)
-
}
-
if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
-
return nil, fmt.Errorf("failed to set key usage: %w", err)
-
}
-
-
return jwkKey, nil
-
}
-
-
// CreateDPoPProof creates a DPoP proof JWT for HTTP requests
-
// Parameters:
-
// - privateKey: The DPoP private key (ES256) as JWK
-
// - method: HTTP method (e.g., "POST", "GET")
-
// - uri: Full HTTP URI (e.g., "https://pds.example.com/xrpc/com.atproto.server.getSession")
-
// - nonce: Optional server-provided nonce (empty on first request, use nonce from 401 response on retry)
-
// - accessToken: Optional access token hash (required when using access token)
-
func CreateDPoPProof(privateKey jwk.Key, method, uri, nonce, accessToken string) (string, error) {
-
// Get public key for JWK thumbprint
-
pubKey, err := privateKey.PublicKey()
-
if err != nil {
-
return "", fmt.Errorf("failed to get public key: %w", err)
-
}
-
-
// Create JWT builder
-
builder := jwt.NewBuilder().
-
Claim("htm", method). // HTTP method
-
Claim("htu", uri). // HTTP URI
-
Claim("iat", time.Now().Unix()). // Issued at
-
Claim("jti", generateJTI()) // Unique JWT ID
-
-
// Add nonce if provided (required after first DPoP request)
-
if nonce != "" {
-
builder = builder.Claim("nonce", nonce)
-
}
-
-
// Add access token hash if provided (required when using access token)
-
if accessToken != "" {
-
ath := hashAccessToken(accessToken)
-
builder = builder.Claim("ath", ath)
-
}
-
-
// Build the token
-
token, err := builder.Build()
-
if err != nil {
-
return "", fmt.Errorf("failed to build JWT: %w", err)
-
}
-
-
// Serialize the token payload to JSON
-
payloadBytes, err := json.Marshal(token)
-
if err != nil {
-
return "", fmt.Errorf("failed to marshal token: %w", err)
-
}
-
-
// Create headers with DPoP-specific fields
-
// RFC 9449 requires the "jwk" header to contain the public key as a JSON object
-
headers := jws.NewHeaders()
-
if setErr := headers.Set(jws.AlgorithmKey, jwa.ES256); setErr != nil {
-
return "", fmt.Errorf("failed to set algorithm: %w", setErr)
-
}
-
if setErr := headers.Set(jws.TypeKey, "dpop+jwt"); setErr != nil {
-
return "", fmt.Errorf("failed to set type: %w", setErr)
-
}
-
// Set the public JWK directly - jwx library will handle serialization
-
if setErr := headers.Set(jws.JWKKey, pubKey); setErr != nil {
-
return "", fmt.Errorf("failed to set JWK: %w", setErr)
-
}
-
-
// Sign using jws.Sign to preserve custom headers
-
// (jwt.Sign() overrides headers, so we use jws.Sign() directly)
-
signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers)))
-
if err != nil {
-
return "", fmt.Errorf("failed to sign JWT: %w", err)
-
}
-
-
return string(signed), nil
-
}
-
-
// generateJTI generates a unique JWT ID for DPoP proofs
-
func generateJTI() string {
-
// Generate 16 random bytes
-
b := make([]byte, 16)
-
if _, err := rand.Read(b); err != nil {
-
// Fallback to timestamp-based ID
-
return fmt.Sprintf("%d", time.Now().UnixNano())
-
}
-
return base64.RawURLEncoding.EncodeToString(b)
-
}
-
-
// hashAccessToken creates the 'ath' (access token hash) claim
-
// ath = base64url(SHA-256(access_token))
-
func hashAccessToken(accessToken string) string {
-
hash := sha256.Sum256([]byte(accessToken))
-
return base64.RawURLEncoding.EncodeToString(hash[:])
-
}
-
-
// ParseJWKFromJSON parses a JWK from JSON bytes
-
func ParseJWKFromJSON(data []byte) (jwk.Key, error) {
-
key, err := jwk.ParseKey(data)
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse JWK: %w", err)
-
}
-
return key, nil
-
}
-
-
// JWKToJSON converts a JWK to JSON bytes
-
func JWKToJSON(key jwk.Key) ([]byte, error) {
-
data, err := json.Marshal(key)
-
if err != nil {
-
return nil, fmt.Errorf("failed to marshal JWK: %w", err)
-
}
-
return data, nil
-
}
-
-
// GetPublicJWKS creates a JWKS (JSON Web Key Set) response for the public key
-
// This is served at /oauth/jwks.json
-
func GetPublicJWKS(privateKey jwk.Key) (jwk.Set, error) {
-
pubKey, err := privateKey.PublicKey()
-
if err != nil {
-
return nil, fmt.Errorf("failed to get public key: %w", err)
-
}
-
-
// Create JWK Set
-
set := jwk.NewSet()
-
if err := set.AddKey(pubKey); err != nil {
-
return nil, fmt.Errorf("failed to add key to set: %w", err)
-
}
-
-
return set, nil
-
}
-172
internal/atproto/oauth/dpop_test.go
···
-
package oauth
-
-
import (
-
"encoding/base64"
-
"encoding/json"
-
"strings"
-
"testing"
-
)
-
-
// TestCreateDPoPProof tests DPoP proof generation and structure
-
func TestCreateDPoPProof(t *testing.T) {
-
// Generate a test DPoP key
-
dpopKey, err := GenerateDPoPKey()
-
if err != nil {
-
t.Fatalf("Failed to generate DPoP key: %v", err)
-
}
-
-
// Create a DPoP proof
-
proof, err := CreateDPoPProof(dpopKey, "POST", "https://example.com/token", "", "")
-
if err != nil {
-
t.Fatalf("Failed to create DPoP proof: %v", err)
-
}
-
-
// DPoP proof should be a JWT in form: header.payload.signature
-
parts := strings.Split(proof, ".")
-
if len(parts) != 3 {
-
t.Fatalf("Expected 3 parts in JWT, got %d", len(parts))
-
}
-
-
// Decode and inspect the header
-
headerJSON, decodeErr := base64.RawURLEncoding.DecodeString(parts[0])
-
if decodeErr != nil {
-
t.Fatalf("Failed to decode header: %v", decodeErr)
-
}
-
-
var header map[string]interface{}
-
if unmarshalErr := json.Unmarshal(headerJSON, &header); unmarshalErr != nil {
-
t.Fatalf("Failed to unmarshal header: %v", unmarshalErr)
-
}
-
-
t.Logf("DPoP Header: %s", string(headerJSON))
-
-
// Verify required header fields
-
if header["alg"] != "ES256" {
-
t.Errorf("Expected alg=ES256, got %v", header["alg"])
-
}
-
if header["typ"] != "dpop+jwt" {
-
t.Errorf("Expected typ=dpop+jwt, got %v", header["typ"])
-
}
-
-
// Verify JWK is present and is a JSON object
-
jwkValue, hasJWK := header["jwk"]
-
if !hasJWK {
-
t.Fatal("Header missing 'jwk' field")
-
}
-
-
// JWK should be a map/object, not a string
-
jwkMap, ok := jwkValue.(map[string]interface{})
-
if !ok {
-
t.Fatalf("JWK is not a JSON object, got type: %T, value: %v", jwkValue, jwkValue)
-
}
-
-
// Verify JWK has required fields for EC key
-
if jwkMap["kty"] != "EC" {
-
t.Errorf("Expected kty=EC, got %v", jwkMap["kty"])
-
}
-
if jwkMap["crv"] != "P-256" {
-
t.Errorf("Expected crv=P-256, got %v", jwkMap["crv"])
-
}
-
if _, hasX := jwkMap["x"]; !hasX {
-
t.Error("JWK missing 'x' coordinate")
-
}
-
if _, hasY := jwkMap["y"]; !hasY {
-
t.Error("JWK missing 'y' coordinate")
-
}
-
-
// Verify private key is NOT in the public JWK
-
if _, hasD := jwkMap["d"]; hasD {
-
t.Error("SECURITY: JWK contains private key component 'd'!")
-
}
-
-
// Decode and inspect the payload
-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
-
if err != nil {
-
t.Fatalf("Failed to decode payload: %v", err)
-
}
-
-
var payload map[string]interface{}
-
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
-
t.Fatalf("Failed to unmarshal payload: %v", err)
-
}
-
-
t.Logf("DPoP Payload: %s", string(payloadJSON))
-
-
// Verify required payload claims
-
if payload["htm"] != "POST" {
-
t.Errorf("Expected htm=POST, got %v", payload["htm"])
-
}
-
if payload["htu"] != "https://example.com/token" {
-
t.Errorf("Expected htu=https://example.com/token, got %v", payload["htu"])
-
}
-
if _, hasIAT := payload["iat"]; !hasIAT {
-
t.Error("Payload missing 'iat' (issued at)")
-
}
-
if _, hasJTI := payload["jti"]; !hasJTI {
-
t.Error("Payload missing 'jti' (JWT ID)")
-
}
-
}
-
-
// TestDPoPProofWithNonce tests DPoP proof with nonce
-
func TestDPoPProofWithNonce(t *testing.T) {
-
dpopKey, err := GenerateDPoPKey()
-
if err != nil {
-
t.Fatalf("Failed to generate DPoP key: %v", err)
-
}
-
-
testNonce := "test-nonce-12345"
-
proof, err := CreateDPoPProof(dpopKey, "POST", "https://example.com/token", testNonce, "")
-
if err != nil {
-
t.Fatalf("Failed to create DPoP proof: %v", err)
-
}
-
-
// Decode payload
-
parts := strings.Split(proof, ".")
-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
-
if err != nil {
-
t.Fatalf("Failed to decode payload: %v", err)
-
}
-
var payload map[string]interface{}
-
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
-
t.Fatalf("Failed to unmarshal payload: %v", err)
-
}
-
-
if payload["nonce"] != testNonce {
-
t.Errorf("Expected nonce=%s, got %v", testNonce, payload["nonce"])
-
}
-
}
-
-
// TestDPoPProofWithAccessToken tests DPoP proof with access token hash
-
func TestDPoPProofWithAccessToken(t *testing.T) {
-
dpopKey, err := GenerateDPoPKey()
-
if err != nil {
-
t.Fatalf("Failed to generate DPoP key: %v", err)
-
}
-
-
testToken := "test-access-token"
-
proof, err := CreateDPoPProof(dpopKey, "GET", "https://example.com/resource", "", testToken)
-
if err != nil {
-
t.Fatalf("Failed to create DPoP proof: %v", err)
-
}
-
-
// Decode payload
-
parts := strings.Split(proof, ".")
-
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
-
if err != nil {
-
t.Fatalf("Failed to decode payload: %v", err)
-
}
-
var payload map[string]interface{}
-
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
-
t.Fatalf("Failed to unmarshal payload: %v", err)
-
}
-
-
ath, hasATH := payload["ath"]
-
if !hasATH {
-
t.Fatal("Payload missing 'ath' (access token hash)")
-
}
-
if ath == "" {
-
t.Error("Access token hash is empty")
-
}
-
-
t.Logf("Access token hash: %v", ath)
-
}
-201
internal/atproto/xrpc/dpop_transport.go
···
-
package xrpc
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"fmt"
-
"log"
-
"net/http"
-
"sync"
-
-
oauthCore "Coves/internal/core/oauth"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// DPoPTransport is an http.RoundTripper that automatically adds DPoP proofs to requests
-
// It intercepts HTTP requests and:
-
// 1. Adds Authorization: DPoP <access_token>
-
// 2. Creates and adds DPoP proof JWT
-
// 3. Handles nonce rotation (retries on 401 with new nonce)
-
// 4. Updates nonces in session store
-
type DPoPTransport struct {
-
base http.RoundTripper // Underlying transport (usually http.DefaultTransport)
-
session *oauthCore.OAuthSession // User's OAuth session
-
sessionStore oauthCore.SessionStore // For updating nonces
-
dpopKey jwk.Key // Parsed DPoP private key
-
mu sync.Mutex // Protects nonce updates
-
}
-
-
// NewDPoPTransport creates a new DPoP-enabled HTTP transport
-
func NewDPoPTransport(base http.RoundTripper, session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*DPoPTransport, error) {
-
if base == nil {
-
base = http.DefaultTransport
-
}
-
-
// Parse DPoP private key from session
-
dpopKey, err := oauth.ParseJWKFromJSON([]byte(session.DPoPPrivateJWK))
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse DPoP key: %w", err)
-
}
-
-
return &DPoPTransport{
-
base: base,
-
session: session,
-
sessionStore: sessionStore,
-
dpopKey: dpopKey,
-
}, nil
-
}
-
-
// RoundTrip implements http.RoundTripper
-
// This is called for every HTTP request made by the client
-
func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-
// Clone the request (don't modify original)
-
req = req.Clone(req.Context())
-
-
// Add Authorization header with DPoP-bound access token
-
req.Header.Set("Authorization", "DPoP "+t.session.AccessToken)
-
-
// Determine which nonce to use based on the target URL
-
nonce := t.getDPoPNonce(req.URL.String())
-
-
// Create DPoP proof for this specific request
-
dpopProof, err := oauth.CreateDPoPProof(
-
t.dpopKey,
-
req.Method,
-
req.URL.String(),
-
nonce,
-
t.session.AccessToken,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof: %w", err)
-
}
-
-
// Add DPoP proof header
-
req.Header.Set("DPoP", dpopProof)
-
-
// Execute the request
-
resp, err := t.base.RoundTrip(req)
-
if err != nil {
-
return nil, err
-
}
-
-
// Handle DPoP nonce rotation
-
if resp.StatusCode == http.StatusUnauthorized {
-
// Check if server provided a new nonce
-
newNonce := resp.Header.Get("DPoP-Nonce")
-
if newNonce != "" {
-
// Update nonce and retry request once
-
t.updateDPoPNonce(req.URL.String(), newNonce)
-
-
// Close the 401 response body
-
if err := resp.Body.Close(); err != nil {
-
log.Printf("Failed to close response body: %v", err)
-
}
-
-
// Retry with new nonce
-
return t.retryWithNewNonce(req, newNonce)
-
}
-
}
-
-
// Check for nonce update even on successful responses
-
if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" {
-
t.updateDPoPNonce(req.URL.String(), newNonce)
-
}
-
-
return resp, nil
-
}
-
-
// getDPoPNonce determines which DPoP nonce to use for a given URL
-
func (t *DPoPTransport) getDPoPNonce(url string) string {
-
t.mu.Lock()
-
defer t.mu.Unlock()
-
-
// If URL is to the PDS, use PDS nonce
-
if contains(url, t.session.PDSURL) {
-
return t.session.DPoPPDSNonce
-
}
-
-
// If URL is to auth server, use auth server nonce
-
if contains(url, t.session.AuthServerIss) {
-
return t.session.DPoPAuthServerNonce
-
}
-
-
// Default: no nonce (first request to this server)
-
return ""
-
}
-
-
// updateDPoPNonce updates the appropriate nonce based on URL
-
func (t *DPoPTransport) updateDPoPNonce(url, newNonce string) {
-
t.mu.Lock()
-
-
// Read DID inside lock to avoid race condition
-
did := t.session.DID
-
-
// Update PDS nonce
-
if contains(url, t.session.PDSURL) {
-
t.session.DPoPPDSNonce = newNonce
-
t.mu.Unlock()
-
// Persist to database (async, best-effort)
-
go func() {
-
if err := t.sessionStore.UpdatePDSNonce(did, newNonce); err != nil {
-
log.Printf("Failed to update PDS nonce: %v", err)
-
}
-
}()
-
return
-
}
-
-
// Update auth server nonce
-
if contains(url, t.session.AuthServerIss) {
-
t.session.DPoPAuthServerNonce = newNonce
-
t.mu.Unlock()
-
// Persist to database (async, best-effort)
-
go func() {
-
if err := t.sessionStore.UpdateAuthServerNonce(did, newNonce); err != nil {
-
log.Printf("Failed to update auth server nonce: %v", err)
-
}
-
}()
-
return
-
}
-
-
t.mu.Unlock()
-
}
-
-
// retryWithNewNonce retries a request with an updated DPoP nonce
-
func (t *DPoPTransport) retryWithNewNonce(req *http.Request, newNonce string) (*http.Response, error) {
-
// Create new DPoP proof with updated nonce
-
dpopProof, err := oauth.CreateDPoPProof(
-
t.dpopKey,
-
req.Method,
-
req.URL.String(),
-
newNonce,
-
t.session.AccessToken,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP proof on retry: %w", err)
-
}
-
-
// Update DPoP header
-
req.Header.Set("DPoP", dpopProof)
-
-
// Retry the request (only once - no infinite loops)
-
return t.base.RoundTrip(req)
-
}
-
-
// contains checks if haystack contains needle (case-sensitive)
-
func contains(haystack, needle string) bool {
-
return len(haystack) >= len(needle) && haystack[:len(needle)] == needle ||
-
len(haystack) > len(needle) && haystack[len(haystack)-len(needle):] == needle
-
}
-
-
// AuthenticatedClient creates an HTTP client with DPoP transport
-
// This is what handlers use to make authenticated requests to the user's PDS
-
func NewAuthenticatedClient(session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*http.Client, error) {
-
transport, err := NewDPoPTransport(nil, session, sessionStore)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create DPoP transport: %w", err)
-
}
-
-
return &http.Client{
-
Transport: transport,
-
}, nil
-
}
-91
internal/core/oauth/auth_service.go
···
-
package oauth
-
-
import (
-
"Coves/internal/atproto/oauth"
-
"context"
-
"fmt"
-
"time"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// AuthService handles authentication-related business logic
-
// Extracted from middleware to maintain clean architecture
-
type AuthService struct {
-
sessionStore SessionStore
-
oauthClient *oauth.Client
-
}
-
-
// NewAuthService creates a new authentication service
-
func NewAuthService(sessionStore SessionStore, oauthClient *oauth.Client) *AuthService {
-
return &AuthService{
-
sessionStore: sessionStore,
-
oauthClient: oauthClient,
-
}
-
}
-
-
// ValidateSession retrieves and validates a user's OAuth session
-
// Returns the session if valid, error if not found or expired
-
func (s *AuthService) ValidateSession(ctx context.Context, did string) (*OAuthSession, error) {
-
session, err := s.sessionStore.GetSession(did)
-
if err != nil {
-
return nil, fmt.Errorf("session not found: %w", err)
-
}
-
return session, nil
-
}
-
-
// RefreshTokenIfNeeded checks if token needs refresh and refreshes if necessary
-
// Returns updated session if refreshed, original session otherwise
-
func (s *AuthService) RefreshTokenIfNeeded(ctx context.Context, session *OAuthSession, threshold time.Duration) (*OAuthSession, error) {
-
// Check if token needs refresh
-
if time.Until(session.ExpiresAt) >= threshold {
-
// Token is still valid, no refresh needed
-
return session, nil
-
}
-
-
// Parse DPoP key
-
dpopKey, err := oauth.ParseJWKFromJSON([]byte(session.DPoPPrivateJWK))
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse DPoP key: %w", err)
-
}
-
-
// Refresh token
-
tokenResp, err := s.oauthClient.RefreshTokenRequest(
-
ctx,
-
session.RefreshToken,
-
session.AuthServerIss,
-
session.DPoPAuthServerNonce,
-
dpopKey,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("failed to refresh token: %w", err)
-
}
-
-
// Update session with new tokens
-
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
-
if err := s.sessionStore.RefreshSession(session.DID, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt); err != nil {
-
return nil, fmt.Errorf("failed to update session: %w", err)
-
}
-
-
// Update nonce if provided (best effort - non-critical)
-
if tokenResp.DpopAuthserverNonce != "" {
-
session.DPoPAuthServerNonce = tokenResp.DpopAuthserverNonce
-
if updateErr := s.sessionStore.UpdateAuthServerNonce(session.DID, tokenResp.DpopAuthserverNonce); updateErr != nil {
-
// Log but don't fail - nonce will be updated on next request
-
// (We ignore the error here intentionally as nonce updates are non-critical)
-
_ = updateErr
-
}
-
}
-
-
// Return updated session
-
session.AccessToken = tokenResp.AccessToken
-
session.RefreshToken = tokenResp.RefreshToken
-
session.ExpiresAt = expiresAt
-
-
return session, nil
-
}
-
-
// CreateDPoPKey generates a new DPoP key for a session
-
func (s *AuthService) CreateDPoPKey() (jwk.Key, error) {
-
return oauth.GenerateDPoPKey()
-
}
-353
internal/core/oauth/repository.go
···
-
package oauth
-
-
import (
-
"context"
-
"database/sql"
-
"fmt"
-
"time"
-
)
-
-
// PostgresSessionStore implements SessionStore using PostgreSQL
-
type PostgresSessionStore struct {
-
db *sql.DB
-
}
-
-
// NewPostgresSessionStore creates a new PostgreSQL-backed session store
-
func NewPostgresSessionStore(db *sql.DB) SessionStore {
-
return &PostgresSessionStore{db: db}
-
}
-
-
// SaveRequest stores a temporary OAuth request state
-
func (s *PostgresSessionStore) SaveRequest(req *OAuthRequest) error {
-
query := `
-
INSERT INTO oauth_requests (
-
state, did, handle, pds_url, pkce_verifier,
-
dpop_private_jwk, dpop_authserver_nonce, auth_server_iss, return_url
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
-
`
-
-
_, err := s.db.Exec(
-
query,
-
req.State,
-
req.DID,
-
req.Handle,
-
req.PDSURL,
-
req.PKCEVerifier,
-
req.DPoPPrivateJWK,
-
req.DPoPAuthServerNonce,
-
req.AuthServerIss,
-
req.ReturnURL,
-
)
-
if err != nil {
-
return fmt.Errorf("failed to save OAuth request: %w", err)
-
}
-
-
return nil
-
}
-
-
// GetRequestByState retrieves an OAuth request by state parameter
-
func (s *PostgresSessionStore) GetRequestByState(state string) (*OAuthRequest, error) {
-
query := `
-
SELECT
-
state, did, handle, pds_url, pkce_verifier,
-
dpop_private_jwk, dpop_authserver_nonce, auth_server_iss,
-
COALESCE(return_url, ''), created_at
-
FROM oauth_requests
-
WHERE state = $1
-
`
-
-
var req OAuthRequest
-
err := s.db.QueryRow(query, state).Scan(
-
&req.State,
-
&req.DID,
-
&req.Handle,
-
&req.PDSURL,
-
&req.PKCEVerifier,
-
&req.DPoPPrivateJWK,
-
&req.DPoPAuthServerNonce,
-
&req.AuthServerIss,
-
&req.ReturnURL,
-
&req.CreatedAt,
-
)
-
-
if err == sql.ErrNoRows {
-
return nil, fmt.Errorf("OAuth request not found for state: %s", state)
-
}
-
if err != nil {
-
return nil, fmt.Errorf("failed to get OAuth request: %w", err)
-
}
-
-
return &req, nil
-
}
-
-
// GetAndDeleteRequest atomically retrieves and deletes an OAuth request to prevent replay attacks
-
// This ensures the state parameter can only be used once
-
func (s *PostgresSessionStore) GetAndDeleteRequest(state string) (*OAuthRequest, error) {
-
query := `
-
DELETE FROM oauth_requests
-
WHERE state = $1
-
RETURNING
-
state, did, handle, pds_url, pkce_verifier,
-
dpop_private_jwk, dpop_authserver_nonce, auth_server_iss,
-
COALESCE(return_url, ''), created_at
-
`
-
-
var req OAuthRequest
-
err := s.db.QueryRow(query, state).Scan(
-
&req.State,
-
&req.DID,
-
&req.Handle,
-
&req.PDSURL,
-
&req.PKCEVerifier,
-
&req.DPoPPrivateJWK,
-
&req.DPoPAuthServerNonce,
-
&req.AuthServerIss,
-
&req.ReturnURL,
-
&req.CreatedAt,
-
)
-
-
if err == sql.ErrNoRows {
-
return nil, fmt.Errorf("OAuth request not found or already used: %s", state)
-
}
-
if err != nil {
-
return nil, fmt.Errorf("failed to get and delete OAuth request: %w", err)
-
}
-
-
return &req, nil
-
}
-
-
// DeleteRequest removes an OAuth request (cleanup after callback)
-
func (s *PostgresSessionStore) DeleteRequest(state string) error {
-
query := `DELETE FROM oauth_requests WHERE state = $1`
-
-
_, err := s.db.Exec(query, state)
-
if err != nil {
-
return fmt.Errorf("failed to delete OAuth request: %w", err)
-
}
-
-
return nil
-
}
-
-
// SaveSession stores a new OAuth session (upsert on DID)
-
func (s *PostgresSessionStore) SaveSession(session *OAuthSession) error {
-
query := `
-
INSERT INTO oauth_sessions (
-
did, handle, pds_url, access_token, refresh_token,
-
dpop_private_jwk, dpop_authserver_nonce, dpop_pds_nonce,
-
auth_server_iss, expires_at
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
-
ON CONFLICT (did) DO UPDATE SET
-
handle = EXCLUDED.handle,
-
pds_url = EXCLUDED.pds_url,
-
access_token = EXCLUDED.access_token,
-
refresh_token = EXCLUDED.refresh_token,
-
dpop_private_jwk = EXCLUDED.dpop_private_jwk,
-
dpop_authserver_nonce = EXCLUDED.dpop_authserver_nonce,
-
dpop_pds_nonce = EXCLUDED.dpop_pds_nonce,
-
auth_server_iss = EXCLUDED.auth_server_iss,
-
expires_at = EXCLUDED.expires_at,
-
updated_at = CURRENT_TIMESTAMP
-
`
-
-
_, err := s.db.Exec(
-
query,
-
session.DID,
-
session.Handle,
-
session.PDSURL,
-
session.AccessToken,
-
session.RefreshToken,
-
session.DPoPPrivateJWK,
-
session.DPoPAuthServerNonce,
-
session.DPoPPDSNonce,
-
session.AuthServerIss,
-
session.ExpiresAt,
-
)
-
if err != nil {
-
return fmt.Errorf("failed to save OAuth session: %w", err)
-
}
-
-
return nil
-
}
-
-
// GetSession retrieves an OAuth session by DID
-
func (s *PostgresSessionStore) GetSession(did string) (*OAuthSession, error) {
-
query := `
-
SELECT
-
did, handle, pds_url, access_token, refresh_token,
-
dpop_private_jwk,
-
COALESCE(dpop_authserver_nonce, ''),
-
COALESCE(dpop_pds_nonce, ''),
-
auth_server_iss, expires_at, created_at, updated_at
-
FROM oauth_sessions
-
WHERE did = $1
-
`
-
-
var session OAuthSession
-
err := s.db.QueryRow(query, did).Scan(
-
&session.DID,
-
&session.Handle,
-
&session.PDSURL,
-
&session.AccessToken,
-
&session.RefreshToken,
-
&session.DPoPPrivateJWK,
-
&session.DPoPAuthServerNonce,
-
&session.DPoPPDSNonce,
-
&session.AuthServerIss,
-
&session.ExpiresAt,
-
&session.CreatedAt,
-
&session.UpdatedAt,
-
)
-
-
if err == sql.ErrNoRows {
-
return nil, fmt.Errorf("session not found for DID: %s", did)
-
}
-
if err != nil {
-
return nil, fmt.Errorf("failed to get OAuth session: %w", err)
-
}
-
-
return &session, nil
-
}
-
-
// UpdateSession updates an existing OAuth session
-
func (s *PostgresSessionStore) UpdateSession(session *OAuthSession) error {
-
query := `
-
UPDATE oauth_sessions SET
-
handle = $2,
-
pds_url = $3,
-
access_token = $4,
-
refresh_token = $5,
-
dpop_private_jwk = $6,
-
dpop_authserver_nonce = $7,
-
dpop_pds_nonce = $8,
-
auth_server_iss = $9,
-
expires_at = $10,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
result, err := s.db.Exec(
-
query,
-
session.DID,
-
session.Handle,
-
session.PDSURL,
-
session.AccessToken,
-
session.RefreshToken,
-
session.DPoPPrivateJWK,
-
session.DPoPAuthServerNonce,
-
session.DPoPPDSNonce,
-
session.AuthServerIss,
-
session.ExpiresAt,
-
)
-
if err != nil {
-
return fmt.Errorf("failed to update OAuth session: %w", err)
-
}
-
-
rows, err := result.RowsAffected()
-
if err != nil {
-
return fmt.Errorf("failed to check rows affected: %w", err)
-
}
-
if rows == 0 {
-
return fmt.Errorf("session not found for DID: %s", session.DID)
-
}
-
-
return nil
-
}
-
-
// DeleteSession removes an OAuth session (logout)
-
func (s *PostgresSessionStore) DeleteSession(did string) error {
-
query := `DELETE FROM oauth_sessions WHERE did = $1`
-
-
_, err := s.db.Exec(query, did)
-
if err != nil {
-
return fmt.Errorf("failed to delete OAuth session: %w", err)
-
}
-
-
return nil
-
}
-
-
// RefreshSession updates access and refresh tokens after a token refresh
-
func (s *PostgresSessionStore) RefreshSession(did, newAccessToken, newRefreshToken string, expiresAt time.Time) error {
-
query := `
-
UPDATE oauth_sessions SET
-
access_token = $2,
-
refresh_token = $3,
-
expires_at = $4,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
result, err := s.db.Exec(query, did, newAccessToken, newRefreshToken, expiresAt)
-
if err != nil {
-
return fmt.Errorf("failed to refresh OAuth session: %w", err)
-
}
-
-
rows, err := result.RowsAffected()
-
if err != nil {
-
return fmt.Errorf("failed to check rows affected: %w", err)
-
}
-
if rows == 0 {
-
return fmt.Errorf("session not found for DID: %s", did)
-
}
-
-
return nil
-
}
-
-
// UpdateAuthServerNonce updates the DPoP nonce for the auth server token endpoint
-
func (s *PostgresSessionStore) UpdateAuthServerNonce(did, nonce string) error {
-
query := `
-
UPDATE oauth_sessions SET
-
dpop_authserver_nonce = $2,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
_, err := s.db.Exec(query, did, nonce)
-
if err != nil {
-
return fmt.Errorf("failed to update auth server nonce: %w", err)
-
}
-
-
return nil
-
}
-
-
// UpdatePDSNonce updates the DPoP nonce for PDS requests
-
func (s *PostgresSessionStore) UpdatePDSNonce(did, nonce string) error {
-
query := `
-
UPDATE oauth_sessions SET
-
dpop_pds_nonce = $2,
-
updated_at = CURRENT_TIMESTAMP
-
WHERE did = $1
-
`
-
-
_, err := s.db.Exec(query, did, nonce)
-
if err != nil {
-
return fmt.Errorf("failed to update PDS nonce: %w", err)
-
}
-
-
return nil
-
}
-
-
// CleanupExpiredRequests removes OAuth requests older than 30 minutes
-
// Should be called periodically (e.g., via cron job or background goroutine)
-
func (s *PostgresSessionStore) CleanupExpiredRequests(ctx context.Context) error {
-
query := `DELETE FROM oauth_requests WHERE created_at < NOW() - INTERVAL '30 minutes'`
-
-
_, err := s.db.ExecContext(ctx, query)
-
if err != nil {
-
return fmt.Errorf("failed to cleanup expired requests: %w", err)
-
}
-
-
return nil
-
}
-
-
// CleanupExpiredSessions removes OAuth sessions that have been expired for > 7 days
-
// Gives users time to refresh their tokens before permanent deletion
-
func (s *PostgresSessionStore) CleanupExpiredSessions(ctx context.Context) error {
-
query := `DELETE FROM oauth_sessions WHERE expires_at < NOW() - INTERVAL '7 days'`
-
-
_, err := s.db.ExecContext(ctx, query)
-
if err != nil {
-
return fmt.Errorf("failed to cleanup expired sessions: %w", err)
-
}
-
-
return nil
-
}
-59
internal/core/oauth/session.go
···
-
package oauth
-
-
import (
-
"time"
-
)
-
-
// OAuthRequest represents a temporary OAuth authorization flow state
-
// Stored during the redirect to auth server, deleted after callback
-
type OAuthRequest struct {
-
CreatedAt time.Time `db:"created_at"`
-
State string `db:"state"`
-
DID string `db:"did"`
-
Handle string `db:"handle"`
-
PDSURL string `db:"pds_url"`
-
PKCEVerifier string `db:"pkce_verifier"`
-
DPoPPrivateJWK string `db:"dpop_private_jwk"`
-
DPoPAuthServerNonce string `db:"dpop_authserver_nonce"`
-
AuthServerIss string `db:"auth_server_iss"`
-
ReturnURL string `db:"return_url"`
-
}
-
-
// OAuthSession represents a long-lived authenticated user session
-
// Stored after successful OAuth login, used for all authenticated requests
-
type OAuthSession struct {
-
ExpiresAt time.Time `db:"expires_at"`
-
CreatedAt time.Time `db:"created_at"`
-
UpdatedAt time.Time `db:"updated_at"`
-
DID string `db:"did"`
-
Handle string `db:"handle"`
-
PDSURL string `db:"pds_url"`
-
AccessToken string `db:"access_token"`
-
RefreshToken string `db:"refresh_token"`
-
DPoPPrivateJWK string `db:"dpop_private_jwk"`
-
DPoPAuthServerNonce string `db:"dpop_authserver_nonce"`
-
DPoPPDSNonce string `db:"dpop_pds_nonce"`
-
AuthServerIss string `db:"auth_server_iss"`
-
}
-
-
// SessionStore defines the interface for OAuth session storage
-
type SessionStore interface {
-
// OAuth flow state management
-
SaveRequest(req *OAuthRequest) error
-
GetRequestByState(state string) (*OAuthRequest, error)
-
GetAndDeleteRequest(state string) (*OAuthRequest, error) // Atomic get-and-delete for CSRF protection
-
DeleteRequest(state string) error
-
-
// User session management
-
SaveSession(session *OAuthSession) error
-
GetSession(did string) (*OAuthSession, error)
-
UpdateSession(session *OAuthSession) error
-
DeleteSession(did string) error
-
-
// Token refresh
-
RefreshSession(did, newAccessToken, newRefreshToken string, expiresAt time.Time) error
-
-
// Nonce updates (for DPoP)
-
UpdateAuthServerNonce(did, nonce string) error
-
UpdatePDSNonce(did, nonce string) error
-
}
+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"
+
}
+
}
+
}
+
}
+
}
+
}
+5 -1
internal/core/communities/errors.go
···
// ErrBlockNotFound is returned when block doesn't exist
ErrBlockNotFound = errors.New("block not found")
+
// ErrBlockAlreadyExists is returned when user has already blocked the community
+
ErrBlockAlreadyExists = errors.New("community already blocked")
+
// ErrMembershipNotFound is returned when membership doesn't exist
ErrMembershipNotFound = errors.New("membership not found")
···
func IsConflict(err error) bool {
return errors.Is(err, ErrCommunityAlreadyExists) ||
errors.Is(err, ErrHandleTaken) ||
-
errors.Is(err, ErrSubscriptionAlreadyExists)
+
errors.Is(err, ErrSubscriptionAlreadyExists) ||
+
errors.Is(err, ErrBlockAlreadyExists)
}
// IsValidationError checks if error is a validation error
+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
+
}
+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
+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)
+
}
+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"
+
}
+
}
}
}
}
+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;
+20 -3
Makefile
···
@go build -o server ./cmd/server
@echo "$(GREEN)โœ“ Build complete: ./server$(RESET)"
-
run: ## Run the Coves server (requires database running)
-
@echo "$(GREEN)Starting Coves server...$(RESET)"
-
@go run ./cmd/server
+
run: ## Run the Coves server with dev environment (requires database running)
+
@./scripts/dev-run.sh
##@ Cleanup
···
@make dev-down
@make dev-up
+
##@ Mobile Testing
+
+
mobile-setup: ## Setup Android port forwarding for USB-connected devices (recommended)
+
@echo "$(CYAN)Setting up Android mobile testing environment...$(RESET)"
+
@./scripts/setup-mobile-ports.sh
+
+
mobile-reset: ## Remove all Android port forwarding
+
@echo "$(YELLOW)Removing Android port forwarding...$(RESET)"
+
@adb reverse --remove-all || echo "$(YELLOW)No device connected$(RESET)"
+
@echo "$(GREEN)โœ“ Port forwarding removed$(RESET)"
+
+
ngrok-up: ## Start ngrok tunnels (for iOS or WiFi testing - requires paid plan for 3 tunnels)
+
@echo "$(GREEN)Starting ngrok tunnels for mobile testing...$(RESET)"
+
@./scripts/start-ngrok.sh
+
+
ngrok-down: ## Stop all ngrok tunnels
+
@./scripts/stop-ngrok.sh
+
##@ Utilities
validate-lexicon: ## Validate all Lexicon schemas
+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"
-
}
+
}
+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"
-
}
-
}
}
}
}
+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"
-
}
-
}
}
}
}
+8 -1
cmd/validate-lexicon/main.go
···
// Post record types (removed - no longer exists in new structure)
// Actor definitions
-
"social.coves.actor.profile#geoLocation",
+
"social.coves.actor.defs#profileView",
+
"social.coves.actor.defs#profileViewDetailed",
+
"social.coves.actor.defs#profileStats",
+
"social.coves.actor.defs#viewerState",
// Community definitions
+
"social.coves.community.defs#communityView",
+
"social.coves.community.defs#communityViewDetailed",
+
"social.coves.community.defs#communityStats",
+
"social.coves.community.defs#viewerState",
"social.coves.community.rules#rule",
}
-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"
}
}
+1
internal/atproto/lexicon/social/coves/community/profile.json
···
"name": {
"type": "string",
"maxLength": 64,
+
"maxGraphemes": 64,
"description": "Short community name (local part of handle)"
},
"displayName": {
+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)
}
+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"`
+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
+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"
+
}
+
]
+
}
+
}
+
}
+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"
+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
+
}
+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
+2 -3
internal/api/handlers/community/create.go
···
package community
import (
-
"encoding/json"
-
"net/http"
-
"Coves/internal/api/middleware"
"Coves/internal/core/communities"
+
"encoding/json"
+
"net/http"
)
// CreateHandler handles community creation
+1 -2
internal/api/handlers/community/errors.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/communities"
)
// XRPCError represents an XRPC error response
+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
+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/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"
)
+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
+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_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_repo_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityRepository_Create(t *testing.T) {
+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
+3 -4
tests/integration/community_v2_validation_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityConsumer_V2RKeyValidation tests that only V2 communities (rkey="self") are accepted
+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"
)
+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/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
+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"
)
+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"
+
}
+
]
+
}
+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"
}
}
}
+4 -1
.gitignore
···
# Build artifacts
/validate-lexicon
-
/bin/
+
/bin/
+
+
# Go build cache
+
.cache/
+7 -4
docs/FEED_SYSTEM_IMPLEMENTATION.md
···
# Get personalized timeline (hot posts from subscriptions)
curl -X GET \
'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=hot&limit=15' \
-
-H 'Authorization: Bearer eyJhbGc...'
+
-H 'Authorization: DPoP eyJhbGc...' \
+
-H 'DPoP: eyJhbGc...'
# Get top posts from last week
curl -X GET \
'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \
-
-H 'Authorization: Bearer eyJhbGc...'
+
-H 'Authorization: DPoP eyJhbGc...' \
+
-H 'DPoP: eyJhbGc...'
# Get newest posts with pagination
curl -X GET \
'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \
-
-H 'Authorization: Bearer eyJhbGc...'
+
-H 'Authorization: DPoP eyJhbGc...' \
+
-H 'DPoP: eyJhbGc...'
```
**Response:**
···
- โœ… Context timeout support
### Authentication (Timeline)
-
- โœ… JWT Bearer token required
+
- โœ… DPoP-bound access token required
- โœ… DID extracted from auth context
- โœ… Validates token signature (when AUTH_SKIP_VERIFY=false)
- โœ… Returns 401 on auth failure
+477
internal/atproto/oauth/handlers_security_test.go
···
+
package oauth
+
+
import (
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// TestIsAllowedMobileRedirectURI tests the mobile redirect URI allowlist with EXACT URI matching
+
// Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security
+
func TestIsAllowedMobileRedirectURI(t *testing.T) {
+
tests := []struct {
+
name string
+
uri string
+
expected bool
+
}{
+
{
+
name: "allowed - Universal Link",
+
uri: "https://coves.social/app/oauth/callback",
+
expected: true,
+
},
+
{
+
name: "rejected - custom scheme coves-app (vulnerable to interception)",
+
uri: "coves-app://oauth/callback",
+
expected: false,
+
},
+
{
+
name: "rejected - custom scheme coves (vulnerable to interception)",
+
uri: "coves://oauth/callback",
+
expected: false,
+
},
+
{
+
name: "rejected - evil scheme",
+
uri: "evil://callback",
+
expected: false,
+
},
+
{
+
name: "rejected - http (not secure)",
+
uri: "http://example.com/callback",
+
expected: false,
+
},
+
{
+
name: "rejected - https different domain",
+
uri: "https://example.com/callback",
+
expected: false,
+
},
+
{
+
name: "rejected - https coves.social wrong path",
+
uri: "https://coves.social/wrong/path",
+
expected: false,
+
},
+
{
+
name: "rejected - invalid URI",
+
uri: "not a uri",
+
expected: false,
+
},
+
{
+
name: "rejected - empty string",
+
uri: "",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := isAllowedMobileRedirectURI(tt.uri)
+
assert.Equal(t, tt.expected, result,
+
"isAllowedMobileRedirectURI(%q) = %v, want %v", tt.uri, result, tt.expected)
+
})
+
}
+
}
+
+
// TestExtractScheme tests the scheme extraction function
+
func TestExtractScheme(t *testing.T) {
+
tests := []struct {
+
name string
+
uri string
+
expected string
+
}{
+
{
+
name: "https scheme",
+
uri: "https://coves.social/app/oauth/callback",
+
expected: "https",
+
},
+
{
+
name: "custom scheme",
+
uri: "coves-app://callback",
+
expected: "coves-app",
+
},
+
{
+
name: "invalid URI",
+
uri: "not a uri",
+
expected: "invalid",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := extractScheme(tt.uri)
+
assert.Equal(t, tt.expected, result)
+
})
+
}
+
}
+
+
// TestGenerateCSRFToken tests CSRF token generation
+
func TestGenerateCSRFToken(t *testing.T) {
+
// Generate two tokens and verify they are different (randomness check)
+
token1, err1 := generateCSRFToken()
+
require.NoError(t, err1)
+
require.NotEmpty(t, token1)
+
+
token2, err2 := generateCSRFToken()
+
require.NoError(t, err2)
+
require.NotEmpty(t, token2)
+
+
assert.NotEqual(t, token1, token2, "CSRF tokens should be unique")
+
+
// Verify token is base64 encoded (should decode without error)
+
assert.Greater(t, len(token1), 40, "CSRF token should be reasonably long (32 bytes base64 encoded)")
+
}
+
+
// TestHandleMobileLogin_RedirectURIValidation tests that HandleMobileLogin validates redirect URIs
+
func TestHandleMobileLogin_RedirectURIValidation(t *testing.T) {
+
// Note: This is a unit test for the validation logic only.
+
// Full integration tests with OAuth flow are in tests/integration/oauth_e2e_test.go
+
+
tests := []struct {
+
name string
+
redirectURI string
+
expectedLog string
+
expectedStatus int
+
}{
+
{
+
name: "allowed - Universal Link",
+
redirectURI: "https://coves.social/app/oauth/callback",
+
expectedStatus: http.StatusBadRequest, // Will fail at StartAuthFlow (no OAuth client setup)
+
},
+
{
+
name: "rejected - custom scheme coves-app (insecure)",
+
redirectURI: "coves-app://oauth/callback",
+
expectedStatus: http.StatusBadRequest,
+
expectedLog: "rejected unauthorized mobile redirect URI",
+
},
+
{
+
name: "rejected evil scheme",
+
redirectURI: "evil://callback",
+
expectedStatus: http.StatusBadRequest,
+
expectedLog: "rejected unauthorized mobile redirect URI",
+
},
+
{
+
name: "rejected http",
+
redirectURI: "http://evil.com/callback",
+
expectedStatus: http.StatusBadRequest,
+
expectedLog: "scheme not allowed",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
// Test the validation function directly
+
result := isAllowedMobileRedirectURI(tt.redirectURI)
+
if tt.expectedLog != "" {
+
assert.False(t, result, "Should reject %s", tt.redirectURI)
+
}
+
})
+
}
+
}
+
+
// TestHandleCallback_CSRFValidation tests that HandleCallback validates CSRF tokens for mobile flow
+
func TestHandleCallback_CSRFValidation(t *testing.T) {
+
// This is a conceptual test structure. Full implementation would require:
+
// 1. Mock OAuthClient
+
// 2. Mock OAuth store
+
// 3. Simulated OAuth callback with cookies
+
+
t.Run("mobile callback requires CSRF token", func(t *testing.T) {
+
// Setup: Create request with mobile_redirect_uri cookie but NO oauth_csrf cookie
+
req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test", nil)
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_uri",
+
Value: "https%3A%2F%2Fcoves.social%2Fapp%2Foauth%2Fcallback",
+
})
+
// Missing: oauth_csrf cookie
+
+
// This would be rejected with 403 Forbidden in the actual handler
+
// (Full test in integration tests with real OAuth flow)
+
+
assert.NotNil(t, req) // Placeholder assertion
+
})
+
+
t.Run("mobile callback with valid CSRF token", func(t *testing.T) {
+
// Setup: Create request with both cookies
+
req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test", nil)
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_uri",
+
Value: "https%3A%2F%2Fcoves.social%2Fapp%2Foauth%2Fcallback",
+
})
+
req.AddCookie(&http.Cookie{
+
Name: "oauth_csrf",
+
Value: "valid-csrf-token",
+
})
+
+
// This would be accepted (assuming valid OAuth code/state)
+
// (Full test in integration tests with real OAuth flow)
+
+
assert.NotNil(t, req) // Placeholder assertion
+
})
+
}
+
+
// TestHandleMobileCallback_RevalidatesRedirectURI tests that handleMobileCallback re-validates the redirect URI
+
func TestHandleMobileCallback_RevalidatesRedirectURI(t *testing.T) {
+
// This is a critical security test: even if an attacker somehow bypasses the initial check,
+
// the callback handler should re-validate the redirect URI before redirecting.
+
+
tests := []struct {
+
name string
+
redirectURI string
+
shouldPass bool
+
}{
+
{
+
name: "allowed - Universal Link",
+
redirectURI: "https://coves.social/app/oauth/callback",
+
shouldPass: true,
+
},
+
{
+
name: "blocked - custom scheme (insecure)",
+
redirectURI: "coves-app://oauth/callback",
+
shouldPass: false,
+
},
+
{
+
name: "blocked - evil scheme",
+
redirectURI: "evil://callback",
+
shouldPass: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := isAllowedMobileRedirectURI(tt.redirectURI)
+
assert.Equal(t, tt.shouldPass, result)
+
})
+
}
+
}
+
+
// TestGenerateMobileRedirectBinding tests the binding token generation
+
// The binding now includes the CSRF token for proper double-submit validation
+
func TestGenerateMobileRedirectBinding(t *testing.T) {
+
csrfToken := "test-csrf-token-12345"
+
tests := []struct {
+
name string
+
redirectURI string
+
}{
+
{
+
name: "Universal Link",
+
redirectURI: "https://coves.social/app/oauth/callback",
+
},
+
{
+
name: "different path",
+
redirectURI: "https://coves.social/different/path",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
binding1 := generateMobileRedirectBinding(csrfToken, tt.redirectURI)
+
binding2 := generateMobileRedirectBinding(csrfToken, tt.redirectURI)
+
+
// Same CSRF token + URI should produce same binding (deterministic)
+
assert.Equal(t, binding1, binding2, "binding should be deterministic for same inputs")
+
+
// Binding should not be empty
+
assert.NotEmpty(t, binding1, "binding should not be empty")
+
+
// Binding should be base64 encoded (should decode without error)
+
assert.Greater(t, len(binding1), 20, "binding should be reasonably long")
+
})
+
}
+
+
// Different URIs should produce different bindings
+
binding1 := generateMobileRedirectBinding(csrfToken, "https://coves.social/app/oauth/callback")
+
binding2 := generateMobileRedirectBinding(csrfToken, "https://coves.social/different/path")
+
assert.NotEqual(t, binding1, binding2, "different URIs should produce different bindings")
+
+
// Different CSRF tokens should produce different bindings
+
binding3 := generateMobileRedirectBinding("different-csrf-token", "https://coves.social/app/oauth/callback")
+
assert.NotEqual(t, binding1, binding3, "different CSRF tokens should produce different bindings")
+
}
+
+
// TestValidateMobileRedirectBinding tests the binding validation
+
// Now validates both CSRF token and redirect URI together (double-submit pattern)
+
func TestValidateMobileRedirectBinding(t *testing.T) {
+
csrfToken := "test-csrf-token-for-validation"
+
redirectURI := "https://coves.social/app/oauth/callback"
+
validBinding := generateMobileRedirectBinding(csrfToken, redirectURI)
+
+
tests := []struct {
+
name string
+
csrfToken string
+
redirectURI string
+
binding string
+
shouldPass bool
+
}{
+
{
+
name: "valid - correct CSRF token and redirect URI",
+
csrfToken: csrfToken,
+
redirectURI: redirectURI,
+
binding: validBinding,
+
shouldPass: true,
+
},
+
{
+
name: "invalid - wrong redirect URI",
+
csrfToken: csrfToken,
+
redirectURI: "https://coves.social/different/path",
+
binding: validBinding,
+
shouldPass: false,
+
},
+
{
+
name: "invalid - wrong CSRF token",
+
csrfToken: "wrong-csrf-token",
+
redirectURI: redirectURI,
+
binding: validBinding,
+
shouldPass: false,
+
},
+
{
+
name: "invalid - random binding",
+
csrfToken: csrfToken,
+
redirectURI: redirectURI,
+
binding: "random-invalid-binding",
+
shouldPass: false,
+
},
+
{
+
name: "invalid - empty binding",
+
csrfToken: csrfToken,
+
redirectURI: redirectURI,
+
binding: "",
+
shouldPass: false,
+
},
+
{
+
name: "invalid - empty CSRF token",
+
csrfToken: "",
+
redirectURI: redirectURI,
+
binding: validBinding,
+
shouldPass: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := validateMobileRedirectBinding(tt.csrfToken, tt.redirectURI, tt.binding)
+
assert.Equal(t, tt.shouldPass, result)
+
})
+
}
+
}
+
+
// TestSessionFixationAttackPrevention tests that the binding prevents session fixation
+
func TestSessionFixationAttackPrevention(t *testing.T) {
+
// Simulate attack scenario:
+
// 1. Attacker plants a cookie for evil://steal with binding for evil://steal
+
// 2. User does a web login (no mobile_redirect_binding cookie)
+
// 3. Callback should NOT redirect to evil://steal
+
+
attackerCSRF := "attacker-csrf-token"
+
attackerRedirectURI := "evil://steal"
+
attackerBinding := generateMobileRedirectBinding(attackerCSRF, attackerRedirectURI)
+
+
// Later, user's legitimate mobile login
+
userCSRF := "user-csrf-token"
+
userRedirectURI := "https://coves.social/app/oauth/callback"
+
userBinding := generateMobileRedirectBinding(userCSRF, userRedirectURI)
+
+
// The attacker's binding should NOT validate for the user's redirect URI
+
assert.False(t, validateMobileRedirectBinding(userCSRF, userRedirectURI, attackerBinding),
+
"attacker's binding should not validate for user's CSRF token and redirect URI")
+
+
// The user's binding should validate for the user's CSRF token and redirect URI
+
assert.True(t, validateMobileRedirectBinding(userCSRF, userRedirectURI, userBinding),
+
"user's binding should validate for user's CSRF token and redirect URI")
+
+
// Cross-validation should fail
+
assert.False(t, validateMobileRedirectBinding(attackerCSRF, attackerRedirectURI, userBinding),
+
"user's binding should not validate for attacker's CSRF token and redirect URI")
+
}
+
+
// TestCSRFTokenValidation tests that CSRF token VALUE is validated, not just presence
+
func TestCSRFTokenValidation(t *testing.T) {
+
// This test verifies the fix for the P1 security issue:
+
// "The callback never validates the token... the csrfToken argument is ignored entirely"
+
//
+
// The fix ensures that the CSRF token VALUE is cryptographically bound to the
+
// binding token, so changing the CSRF token will invalidate the binding.
+
+
t.Run("CSRF token value must match", func(t *testing.T) {
+
originalCSRF := "original-csrf-token-from-login"
+
redirectURI := "https://coves.social/app/oauth/callback"
+
binding := generateMobileRedirectBinding(originalCSRF, redirectURI)
+
+
// Original CSRF token should validate
+
assert.True(t, validateMobileRedirectBinding(originalCSRF, redirectURI, binding),
+
"original CSRF token should validate")
+
+
// Different CSRF token should NOT validate (this is the key security fix)
+
differentCSRF := "attacker-forged-csrf-token"
+
assert.False(t, validateMobileRedirectBinding(differentCSRF, redirectURI, binding),
+
"different CSRF token should NOT validate - this is the security fix")
+
})
+
+
t.Run("attacker cannot forge binding without CSRF token", func(t *testing.T) {
+
// Attacker knows the redirect URI but not the CSRF token
+
redirectURI := "https://coves.social/app/oauth/callback"
+
victimCSRF := "victim-secret-csrf-token"
+
victimBinding := generateMobileRedirectBinding(victimCSRF, redirectURI)
+
+
// Attacker tries various CSRF tokens to forge the binding
+
attackerGuesses := []string{
+
"",
+
"guess1",
+
"attacker-csrf",
+
redirectURI, // trying the redirect URI as CSRF
+
}
+
+
for _, guess := range attackerGuesses {
+
assert.False(t, validateMobileRedirectBinding(guess, redirectURI, victimBinding),
+
"attacker's CSRF guess %q should not validate", guess)
+
}
+
})
+
}
+
+
// TestConstantTimeCompare tests the timing-safe comparison function
+
func TestConstantTimeCompare(t *testing.T) {
+
tests := []struct {
+
name string
+
a string
+
b string
+
expected bool
+
}{
+
{
+
name: "equal strings",
+
a: "abc123",
+
b: "abc123",
+
expected: true,
+
},
+
{
+
name: "different strings same length",
+
a: "abc123",
+
b: "xyz789",
+
expected: false,
+
},
+
{
+
name: "different lengths",
+
a: "short",
+
b: "longer",
+
expected: false,
+
},
+
{
+
name: "empty strings",
+
a: "",
+
b: "",
+
expected: true,
+
},
+
{
+
name: "one empty",
+
a: "abc",
+
b: "",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := constantTimeCompare(tt.a, tt.b)
+
assert.Equal(t, tt.expected, result)
+
})
+
}
+
}
+152
internal/atproto/oauth/seal.go
···
+
package oauth
+
+
import (
+
"crypto/aes"
+
"crypto/cipher"
+
"crypto/rand"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"time"
+
)
+
+
// SealedSession represents the data sealed in a mobile session token
+
type SealedSession struct {
+
DID string `json:"did"` // User's DID
+
SessionID string `json:"sid"` // Session identifier
+
ExpiresAt int64 `json:"exp"` // Unix timestamp when token expires
+
}
+
+
// SealSession creates an encrypted token containing session information.
+
// The token is encrypted using AES-256-GCM and encoded as base64url.
+
//
+
// Token format: base64url(nonce || ciphertext || tag)
+
// - nonce: 12 bytes (GCM standard nonce size)
+
// - ciphertext: encrypted JSON payload
+
// - tag: 16 bytes (GCM authentication tag)
+
//
+
// The sealed token can be safely given to mobile clients and used as
+
// a reference to the server-side session without exposing sensitive data.
+
func (c *OAuthClient) SealSession(did, sessionID string, ttl time.Duration) (string, error) {
+
if len(c.SealSecret) == 0 {
+
return "", fmt.Errorf("seal secret not configured")
+
}
+
+
if did == "" {
+
return "", fmt.Errorf("DID is required")
+
}
+
+
if sessionID == "" {
+
return "", fmt.Errorf("session ID is required")
+
}
+
+
// Create the session data
+
expiresAt := time.Now().Add(ttl).Unix()
+
session := SealedSession{
+
DID: did,
+
SessionID: sessionID,
+
ExpiresAt: expiresAt,
+
}
+
+
// Marshal to JSON
+
plaintext, err := json.Marshal(session)
+
if err != nil {
+
return "", fmt.Errorf("failed to marshal session: %w", err)
+
}
+
+
// Create AES cipher
+
block, err := aes.NewCipher(c.SealSecret)
+
if err != nil {
+
return "", fmt.Errorf("failed to create cipher: %w", err)
+
}
+
+
// Create GCM mode
+
gcm, err := cipher.NewGCM(block)
+
if err != nil {
+
return "", fmt.Errorf("failed to create GCM: %w", err)
+
}
+
+
// Generate random nonce
+
nonce := make([]byte, gcm.NonceSize())
+
if _, err := rand.Read(nonce); err != nil {
+
return "", fmt.Errorf("failed to generate nonce: %w", err)
+
}
+
+
// Encrypt and authenticate
+
// GCM.Seal appends the ciphertext and tag to the nonce
+
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
+
+
// Encode as base64url (no padding)
+
token := base64.RawURLEncoding.EncodeToString(ciphertext)
+
+
return token, nil
+
}
+
+
// UnsealSession decrypts and validates a sealed session token.
+
// Returns the session information if the token is valid and not expired.
+
func (c *OAuthClient) UnsealSession(token string) (*SealedSession, error) {
+
if len(c.SealSecret) == 0 {
+
return nil, fmt.Errorf("seal secret not configured")
+
}
+
+
if token == "" {
+
return nil, fmt.Errorf("token is required")
+
}
+
+
// Decode from base64url
+
ciphertext, err := base64.RawURLEncoding.DecodeString(token)
+
if err != nil {
+
return nil, fmt.Errorf("invalid token encoding: %w", err)
+
}
+
+
// Create AES cipher
+
block, err := aes.NewCipher(c.SealSecret)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create cipher: %w", err)
+
}
+
+
// Create GCM mode
+
gcm, err := cipher.NewGCM(block)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create GCM: %w", err)
+
}
+
+
// Verify minimum size (nonce + tag)
+
nonceSize := gcm.NonceSize()
+
if len(ciphertext) < nonceSize {
+
return nil, fmt.Errorf("invalid token: too short")
+
}
+
+
// Extract nonce and ciphertext
+
nonce := ciphertext[:nonceSize]
+
ciphertextData := ciphertext[nonceSize:]
+
+
// Decrypt and authenticate
+
plaintext, err := gcm.Open(nil, nonce, ciphertextData, nil)
+
if err != nil {
+
return nil, fmt.Errorf("failed to decrypt token: %w", err)
+
}
+
+
// Unmarshal JSON
+
var session SealedSession
+
if err := json.Unmarshal(plaintext, &session); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
+
}
+
+
// Validate required fields
+
if session.DID == "" {
+
return nil, fmt.Errorf("invalid session: missing DID")
+
}
+
+
if session.SessionID == "" {
+
return nil, fmt.Errorf("invalid session: missing session ID")
+
}
+
+
// Check expiration
+
now := time.Now().Unix()
+
if session.ExpiresAt <= now {
+
return nil, fmt.Errorf("token expired at %v", time.Unix(session.ExpiresAt, 0))
+
}
+
+
return &session, nil
+
}
+331
internal/atproto/oauth/seal_test.go
···
+
package oauth
+
+
import (
+
"crypto/rand"
+
"encoding/base64"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// generateSealSecret generates a random 32-byte seal secret for testing
+
func generateSealSecret() []byte {
+
secret := make([]byte, 32)
+
if _, err := rand.Read(secret); err != nil {
+
panic(err)
+
}
+
return secret
+
}
+
+
func TestSealSession_RoundTrip(t *testing.T) {
+
// Create client with seal secret
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Seal the session
+
token, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
require.NotEmpty(t, token)
+
+
// Token should be base64url encoded
+
_, err = base64.RawURLEncoding.DecodeString(token)
+
require.NoError(t, err, "token should be valid base64url")
+
+
// Unseal the session
+
session, err := client.UnsealSession(token)
+
require.NoError(t, err)
+
require.NotNil(t, session)
+
+
// Verify data
+
assert.Equal(t, did, session.DID)
+
assert.Equal(t, sessionID, session.SessionID)
+
+
// Verify expiration is approximately correct (within 1 second)
+
expectedExpiry := time.Now().Add(ttl).Unix()
+
assert.InDelta(t, expectedExpiry, session.ExpiresAt, 1.0)
+
}
+
+
func TestSealSession_ExpirationValidation(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 2 * time.Second // Short TTL (must be >= 1 second due to Unix timestamp granularity)
+
+
// Seal the session
+
token, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
// Should work immediately
+
session, err := client.UnsealSession(token)
+
require.NoError(t, err)
+
assert.Equal(t, did, session.DID)
+
+
// Wait well past expiration
+
time.Sleep(2500 * time.Millisecond)
+
+
// Should fail after expiration
+
session, err = client.UnsealSession(token)
+
assert.Error(t, err)
+
assert.Nil(t, session)
+
assert.Contains(t, err.Error(), "token expired")
+
}
+
+
func TestSealSession_TamperedTokenDetection(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Seal the session
+
token, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
// Tamper with the token by modifying one character
+
tampered := token[:len(token)-5] + "XXXX" + token[len(token)-1:]
+
+
// Should fail to unseal tampered token
+
session, err := client.UnsealSession(tampered)
+
assert.Error(t, err)
+
assert.Nil(t, session)
+
assert.Contains(t, err.Error(), "failed to decrypt token")
+
}
+
+
func TestSealSession_InvalidTokenFormats(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
tests := []struct {
+
name string
+
token string
+
}{
+
{
+
name: "empty token",
+
token: "",
+
},
+
{
+
name: "invalid base64",
+
token: "not-valid-base64!@#$",
+
},
+
{
+
name: "too short",
+
token: base64.RawURLEncoding.EncodeToString([]byte("short")),
+
},
+
{
+
name: "random bytes",
+
token: base64.RawURLEncoding.EncodeToString(make([]byte, 50)),
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
session, err := client.UnsealSession(tt.token)
+
assert.Error(t, err)
+
assert.Nil(t, session)
+
})
+
}
+
}
+
+
func TestSealSession_DifferentSecrets(t *testing.T) {
+
// Create two clients with different secrets
+
client1 := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
client2 := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Seal with client1
+
token, err := client1.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
// Try to unseal with client2 (different secret)
+
session, err := client2.UnsealSession(token)
+
assert.Error(t, err)
+
assert.Nil(t, session)
+
assert.Contains(t, err.Error(), "failed to decrypt token")
+
}
+
+
func TestSealSession_NoSecretConfigured(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: nil,
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Should fail to seal without secret
+
token, err := client.SealSession(did, sessionID, ttl)
+
assert.Error(t, err)
+
assert.Empty(t, token)
+
assert.Contains(t, err.Error(), "seal secret not configured")
+
+
// Should fail to unseal without secret
+
session, err := client.UnsealSession("dummy-token")
+
assert.Error(t, err)
+
assert.Nil(t, session)
+
assert.Contains(t, err.Error(), "seal secret not configured")
+
}
+
+
func TestSealSession_MissingRequiredFields(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
ttl := 1 * time.Hour
+
+
tests := []struct {
+
name string
+
did string
+
sessionID string
+
errorMsg string
+
}{
+
{
+
name: "missing DID",
+
did: "",
+
sessionID: "session-123",
+
errorMsg: "DID is required",
+
},
+
{
+
name: "missing session ID",
+
did: "did:plc:abc123",
+
sessionID: "",
+
errorMsg: "session ID is required",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
token, err := client.SealSession(tt.did, tt.sessionID, ttl)
+
assert.Error(t, err)
+
assert.Empty(t, token)
+
assert.Contains(t, err.Error(), tt.errorMsg)
+
})
+
}
+
}
+
+
func TestSealSession_UniquenessPerCall(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Seal the same session twice
+
token1, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
token2, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
// Tokens should be different (different nonces)
+
assert.NotEqual(t, token1, token2, "tokens should be unique due to different nonces")
+
+
// But both should unseal to the same session data
+
session1, err := client.UnsealSession(token1)
+
require.NoError(t, err)
+
+
session2, err := client.UnsealSession(token2)
+
require.NoError(t, err)
+
+
assert.Equal(t, session1.DID, session2.DID)
+
assert.Equal(t, session1.SessionID, session2.SessionID)
+
}
+
+
func TestSealSession_LongDIDAndSessionID(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
// Test with very long DID and session ID
+
did := "did:plc:" + strings.Repeat("a", 200)
+
sessionID := "session-" + strings.Repeat("x", 200)
+
ttl := 1 * time.Hour
+
+
// Should work with long values
+
token, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
session, err := client.UnsealSession(token)
+
require.NoError(t, err)
+
assert.Equal(t, did, session.DID)
+
assert.Equal(t, sessionID, session.SessionID)
+
}
+
+
func TestSealSession_URLSafeEncoding(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Seal multiple times to get different nonces
+
for i := 0; i < 100; i++ {
+
token, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
// Token should not contain URL-unsafe characters
+
assert.NotContains(t, token, "+", "token should not contain '+'")
+
assert.NotContains(t, token, "/", "token should not contain '/'")
+
assert.NotContains(t, token, "=", "token should not contain '='")
+
+
// Should unseal successfully
+
session, err := client.UnsealSession(token)
+
require.NoError(t, err)
+
assert.Equal(t, did, session.DID)
+
}
+
}
+
+
func TestSealSession_ConcurrentAccess(t *testing.T) {
+
client := &OAuthClient{
+
SealSecret: generateSealSecret(),
+
}
+
+
did := "did:plc:abc123"
+
sessionID := "session-xyz"
+
ttl := 1 * time.Hour
+
+
// Run concurrent seal/unseal operations
+
done := make(chan bool)
+
for i := 0; i < 10; i++ {
+
go func() {
+
for j := 0; j < 100; j++ {
+
token, err := client.SealSession(did, sessionID, ttl)
+
require.NoError(t, err)
+
+
session, err := client.UnsealSession(token)
+
require.NoError(t, err)
+
assert.Equal(t, did, session.DID)
+
}
+
done <- true
+
}()
+
}
+
+
// Wait for all goroutines
+
for i := 0; i < 10; i++ {
+
<-done
+
}
+
}
+614
internal/atproto/oauth/store.go
···
+
package oauth
+
+
import (
+
"context"
+
"database/sql"
+
"errors"
+
"fmt"
+
"log/slog"
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/lib/pq"
+
)
+
+
var (
+
ErrSessionNotFound = errors.New("oauth session not found")
+
ErrAuthRequestNotFound = errors.New("oauth auth request not found")
+
)
+
+
// PostgresOAuthStore implements oauth.ClientAuthStore interface using PostgreSQL
+
type PostgresOAuthStore struct {
+
db *sql.DB
+
sessionTTL time.Duration
+
}
+
+
// NewPostgresOAuthStore creates a new PostgreSQL-backed OAuth store
+
func NewPostgresOAuthStore(db *sql.DB, sessionTTL time.Duration) oauth.ClientAuthStore {
+
if sessionTTL == 0 {
+
sessionTTL = 7 * 24 * time.Hour // Default to 7 days
+
}
+
return &PostgresOAuthStore{
+
db: db,
+
sessionTTL: sessionTTL,
+
}
+
}
+
+
// GetSession retrieves a session by DID and session ID
+
func (s *PostgresOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
+
query := `
+
SELECT
+
did, session_id, host_url, auth_server_iss,
+
auth_server_token_endpoint, auth_server_revocation_endpoint,
+
scopes, access_token, refresh_token,
+
dpop_authserver_nonce, dpop_pds_nonce, dpop_private_key_multibase
+
FROM oauth_sessions
+
WHERE did = $1 AND session_id = $2 AND expires_at > NOW()
+
`
+
+
var session oauth.ClientSessionData
+
var authServerIss, authServerTokenEndpoint, authServerRevocationEndpoint sql.NullString
+
var hostURL, dpopPrivateKeyMultibase sql.NullString
+
var scopes pq.StringArray
+
var dpopAuthServerNonce, dpopHostNonce sql.NullString
+
+
err := s.db.QueryRowContext(ctx, query, did.String(), sessionID).Scan(
+
&session.AccountDID,
+
&session.SessionID,
+
&hostURL,
+
&authServerIss,
+
&authServerTokenEndpoint,
+
&authServerRevocationEndpoint,
+
&scopes,
+
&session.AccessToken,
+
&session.RefreshToken,
+
&dpopAuthServerNonce,
+
&dpopHostNonce,
+
&dpopPrivateKeyMultibase,
+
)
+
+
if err == sql.ErrNoRows {
+
return nil, ErrSessionNotFound
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get session: %w", err)
+
}
+
+
// Convert nullable fields
+
if hostURL.Valid {
+
session.HostURL = hostURL.String
+
}
+
if authServerIss.Valid {
+
session.AuthServerURL = authServerIss.String
+
}
+
if authServerTokenEndpoint.Valid {
+
session.AuthServerTokenEndpoint = authServerTokenEndpoint.String
+
}
+
if authServerRevocationEndpoint.Valid {
+
session.AuthServerRevocationEndpoint = authServerRevocationEndpoint.String
+
}
+
if dpopAuthServerNonce.Valid {
+
session.DPoPAuthServerNonce = dpopAuthServerNonce.String
+
}
+
if dpopHostNonce.Valid {
+
session.DPoPHostNonce = dpopHostNonce.String
+
}
+
if dpopPrivateKeyMultibase.Valid {
+
session.DPoPPrivateKeyMultibase = dpopPrivateKeyMultibase.String
+
}
+
session.Scopes = scopes
+
+
return &session, nil
+
}
+
+
// SaveSession saves or updates a session (upsert operation)
+
func (s *PostgresOAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
+
// Input validation per atProto OAuth security requirements
+
+
// Validate DID format
+
if _, err := syntax.ParseDID(sess.AccountDID.String()); err != nil {
+
return fmt.Errorf("invalid DID format: %w", err)
+
}
+
+
// Validate token lengths (max 10000 chars to prevent memory issues)
+
const maxTokenLength = 10000
+
if len(sess.AccessToken) > maxTokenLength {
+
return fmt.Errorf("access_token exceeds maximum length of %d characters", maxTokenLength)
+
}
+
if len(sess.RefreshToken) > maxTokenLength {
+
return fmt.Errorf("refresh_token exceeds maximum length of %d characters", maxTokenLength)
+
}
+
+
// Validate session ID is not empty
+
if sess.SessionID == "" {
+
return fmt.Errorf("session_id cannot be empty")
+
}
+
+
// Validate URLs if provided
+
if sess.HostURL != "" {
+
if _, err := url.Parse(sess.HostURL); err != nil {
+
return fmt.Errorf("invalid host_url: %w", err)
+
}
+
}
+
if sess.AuthServerURL != "" {
+
if _, err := url.Parse(sess.AuthServerURL); err != nil {
+
return fmt.Errorf("invalid auth_server URL: %w", err)
+
}
+
}
+
if sess.AuthServerTokenEndpoint != "" {
+
if _, err := url.Parse(sess.AuthServerTokenEndpoint); err != nil {
+
return fmt.Errorf("invalid auth_server_token_endpoint: %w", err)
+
}
+
}
+
if sess.AuthServerRevocationEndpoint != "" {
+
if _, err := url.Parse(sess.AuthServerRevocationEndpoint); err != nil {
+
return fmt.Errorf("invalid auth_server_revocation_endpoint: %w", err)
+
}
+
}
+
+
query := `
+
INSERT INTO oauth_sessions (
+
did, session_id, handle, pds_url, host_url,
+
access_token, refresh_token,
+
dpop_private_jwk, dpop_private_key_multibase,
+
dpop_authserver_nonce, dpop_pds_nonce,
+
auth_server_iss, auth_server_token_endpoint, auth_server_revocation_endpoint,
+
scopes, expires_at, created_at, updated_at
+
) VALUES (
+
$1, $2, $3, $4, $5,
+
$6, $7,
+
NULL, $8,
+
$9, $10,
+
$11, $12, $13,
+
$14, $15, NOW(), NOW()
+
)
+
ON CONFLICT (did, session_id) DO UPDATE SET
+
handle = EXCLUDED.handle,
+
pds_url = EXCLUDED.pds_url,
+
host_url = EXCLUDED.host_url,
+
access_token = EXCLUDED.access_token,
+
refresh_token = EXCLUDED.refresh_token,
+
dpop_private_key_multibase = EXCLUDED.dpop_private_key_multibase,
+
dpop_authserver_nonce = EXCLUDED.dpop_authserver_nonce,
+
dpop_pds_nonce = EXCLUDED.dpop_pds_nonce,
+
auth_server_iss = EXCLUDED.auth_server_iss,
+
auth_server_token_endpoint = EXCLUDED.auth_server_token_endpoint,
+
auth_server_revocation_endpoint = EXCLUDED.auth_server_revocation_endpoint,
+
scopes = EXCLUDED.scopes,
+
expires_at = EXCLUDED.expires_at,
+
updated_at = NOW()
+
`
+
+
// Calculate token expiration using configured TTL
+
expiresAt := time.Now().Add(s.sessionTTL)
+
+
// Convert empty strings to NULL for optional fields
+
var authServerRevocationEndpoint sql.NullString
+
if sess.AuthServerRevocationEndpoint != "" {
+
authServerRevocationEndpoint.String = sess.AuthServerRevocationEndpoint
+
authServerRevocationEndpoint.Valid = true
+
}
+
+
// Extract handle from DID (placeholder - in real implementation, resolve from identity)
+
// For now, use DID as handle since we don't have the handle in ClientSessionData
+
handle := sess.AccountDID.String()
+
+
// Use HostURL as PDS URL
+
pdsURL := sess.HostURL
+
if pdsURL == "" {
+
pdsURL = sess.AuthServerURL // Fallback to auth server URL
+
}
+
+
_, err := s.db.ExecContext(
+
ctx, query,
+
sess.AccountDID.String(),
+
sess.SessionID,
+
handle,
+
pdsURL,
+
sess.HostURL,
+
sess.AccessToken,
+
sess.RefreshToken,
+
sess.DPoPPrivateKeyMultibase,
+
sess.DPoPAuthServerNonce,
+
sess.DPoPHostNonce,
+
sess.AuthServerURL,
+
sess.AuthServerTokenEndpoint,
+
authServerRevocationEndpoint,
+
pq.Array(sess.Scopes),
+
expiresAt,
+
)
+
if err != nil {
+
return fmt.Errorf("failed to save session: %w", err)
+
}
+
+
return nil
+
}
+
+
// DeleteSession deletes a session by DID and session ID
+
func (s *PostgresOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
+
query := `DELETE FROM oauth_sessions WHERE did = $1 AND session_id = $2`
+
+
result, err := s.db.ExecContext(ctx, query, did.String(), sessionID)
+
if err != nil {
+
return fmt.Errorf("failed to delete session: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rows == 0 {
+
return ErrSessionNotFound
+
}
+
+
return nil
+
}
+
+
// GetAuthRequestInfo retrieves auth request information by state
+
func (s *PostgresOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
+
query := `
+
SELECT
+
state, did, handle, pds_url, pkce_verifier,
+
dpop_private_key_multibase, dpop_authserver_nonce,
+
auth_server_iss, request_uri,
+
auth_server_token_endpoint, auth_server_revocation_endpoint,
+
scopes, created_at
+
FROM oauth_requests
+
WHERE state = $1
+
`
+
+
var info oauth.AuthRequestData
+
var did, handle, pdsURL sql.NullString
+
var dpopPrivateKeyMultibase, dpopAuthServerNonce sql.NullString
+
var requestURI, authServerTokenEndpoint, authServerRevocationEndpoint sql.NullString
+
var scopes pq.StringArray
+
var createdAt time.Time
+
+
err := s.db.QueryRowContext(ctx, query, state).Scan(
+
&info.State,
+
&did,
+
&handle,
+
&pdsURL,
+
&info.PKCEVerifier,
+
&dpopPrivateKeyMultibase,
+
&dpopAuthServerNonce,
+
&info.AuthServerURL,
+
&requestURI,
+
&authServerTokenEndpoint,
+
&authServerRevocationEndpoint,
+
&scopes,
+
&createdAt,
+
)
+
+
if err == sql.ErrNoRows {
+
return nil, ErrAuthRequestNotFound
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get auth request info: %w", err)
+
}
+
+
// Parse DID if present
+
if did.Valid && did.String != "" {
+
parsedDID, err := syntax.ParseDID(did.String)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse DID: %w", err)
+
}
+
info.AccountDID = &parsedDID
+
}
+
+
// Convert nullable fields
+
if dpopPrivateKeyMultibase.Valid {
+
info.DPoPPrivateKeyMultibase = dpopPrivateKeyMultibase.String
+
}
+
if dpopAuthServerNonce.Valid {
+
info.DPoPAuthServerNonce = dpopAuthServerNonce.String
+
}
+
if requestURI.Valid {
+
info.RequestURI = requestURI.String
+
}
+
if authServerTokenEndpoint.Valid {
+
info.AuthServerTokenEndpoint = authServerTokenEndpoint.String
+
}
+
if authServerRevocationEndpoint.Valid {
+
info.AuthServerRevocationEndpoint = authServerRevocationEndpoint.String
+
}
+
info.Scopes = scopes
+
+
return &info, nil
+
}
+
+
// SaveAuthRequestInfo saves auth request information (create only, not upsert)
+
func (s *PostgresOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
+
query := `
+
INSERT INTO oauth_requests (
+
state, did, handle, pds_url, pkce_verifier,
+
dpop_private_key_multibase, dpop_authserver_nonce,
+
auth_server_iss, request_uri,
+
auth_server_token_endpoint, auth_server_revocation_endpoint,
+
scopes, return_url, created_at
+
) VALUES (
+
$1, $2, $3, $4, $5,
+
$6, $7,
+
$8, $9,
+
$10, $11,
+
$12, NULL, NOW()
+
)
+
`
+
+
// Extract DID string if present
+
var didStr sql.NullString
+
if info.AccountDID != nil {
+
didStr.String = info.AccountDID.String()
+
didStr.Valid = true
+
}
+
+
// Convert empty strings to NULL for optional fields
+
var authServerRevocationEndpoint sql.NullString
+
if info.AuthServerRevocationEndpoint != "" {
+
authServerRevocationEndpoint.String = info.AuthServerRevocationEndpoint
+
authServerRevocationEndpoint.Valid = true
+
}
+
+
// Placeholder values for handle and pds_url (not in AuthRequestData)
+
// In production, these would be resolved during the auth flow
+
handle := ""
+
pdsURL := ""
+
if info.AccountDID != nil {
+
handle = info.AccountDID.String() // Temporary placeholder
+
pdsURL = info.AuthServerURL // Temporary placeholder
+
}
+
+
_, err := s.db.ExecContext(
+
ctx, query,
+
info.State,
+
didStr,
+
handle,
+
pdsURL,
+
info.PKCEVerifier,
+
info.DPoPPrivateKeyMultibase,
+
info.DPoPAuthServerNonce,
+
info.AuthServerURL,
+
info.RequestURI,
+
info.AuthServerTokenEndpoint,
+
authServerRevocationEndpoint,
+
pq.Array(info.Scopes),
+
)
+
if err != nil {
+
// Check for duplicate state
+
if strings.Contains(err.Error(), "duplicate key") && strings.Contains(err.Error(), "oauth_requests_state_key") {
+
return fmt.Errorf("auth request with state already exists: %s", info.State)
+
}
+
return fmt.Errorf("failed to save auth request info: %w", err)
+
}
+
+
return nil
+
}
+
+
// DeleteAuthRequestInfo deletes auth request information by state
+
func (s *PostgresOAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
+
query := `DELETE FROM oauth_requests WHERE state = $1`
+
+
result, err := s.db.ExecContext(ctx, query, state)
+
if err != nil {
+
return fmt.Errorf("failed to delete auth request info: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rows == 0 {
+
return ErrAuthRequestNotFound
+
}
+
+
return nil
+
}
+
+
// CleanupExpiredSessions removes sessions that have expired
+
// Should be called periodically (e.g., via cron job)
+
func (s *PostgresOAuthStore) CleanupExpiredSessions(ctx context.Context) (int64, error) {
+
query := `DELETE FROM oauth_sessions WHERE expires_at < NOW()`
+
+
result, err := s.db.ExecContext(ctx, query)
+
if err != nil {
+
return 0, fmt.Errorf("failed to cleanup expired sessions: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return 0, fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
return rows, nil
+
}
+
+
// CleanupExpiredAuthRequests removes auth requests older than 30 minutes
+
// Should be called periodically (e.g., via cron job)
+
func (s *PostgresOAuthStore) CleanupExpiredAuthRequests(ctx context.Context) (int64, error) {
+
query := `DELETE FROM oauth_requests WHERE created_at < NOW() - INTERVAL '30 minutes'`
+
+
result, err := s.db.ExecContext(ctx, query)
+
if err != nil {
+
return 0, fmt.Errorf("failed to cleanup expired auth requests: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return 0, fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
return rows, nil
+
}
+
+
// MobileOAuthData holds mobile-specific OAuth flow data
+
type MobileOAuthData struct {
+
CSRFToken string
+
RedirectURI string
+
}
+
+
// mobileFlowContextKey is the context key for mobile flow data
+
type mobileFlowContextKey struct{}
+
+
// ContextWithMobileFlowData adds mobile flow data to a context.
+
// This is used by HandleMobileLogin to pass mobile data to the store wrapper,
+
// which will save it when SaveAuthRequestInfo is called by indigo.
+
func ContextWithMobileFlowData(ctx context.Context, data MobileOAuthData) context.Context {
+
return context.WithValue(ctx, mobileFlowContextKey{}, data)
+
}
+
+
// getMobileFlowDataFromContext retrieves mobile flow data from context, if present
+
func getMobileFlowDataFromContext(ctx context.Context) (MobileOAuthData, bool) {
+
data, ok := ctx.Value(mobileFlowContextKey{}).(MobileOAuthData)
+
return data, ok
+
}
+
+
// MobileAwareClientStore is a marker interface that indicates a store is properly
+
// configured for mobile OAuth flows. Only stores that intercept SaveAuthRequestInfo
+
// to save mobile CSRF data should implement this interface.
+
// This prevents silent mobile OAuth breakage when a plain PostgresOAuthStore is used.
+
type MobileAwareClientStore interface {
+
IsMobileAware() bool
+
}
+
+
// MobileAwareStoreWrapper wraps a ClientAuthStore to automatically save mobile
+
// CSRF data when SaveAuthRequestInfo is called during a mobile OAuth flow.
+
// This is necessary because indigo's StartAuthFlow doesn't expose the OAuth state,
+
// so we intercept the SaveAuthRequestInfo call to capture it.
+
type MobileAwareStoreWrapper struct {
+
oauth.ClientAuthStore
+
mobileStore MobileOAuthStore
+
}
+
+
// IsMobileAware implements MobileAwareClientStore, indicating this store
+
// properly saves mobile CSRF data during OAuth flow initiation.
+
func (w *MobileAwareStoreWrapper) IsMobileAware() bool {
+
return true
+
}
+
+
// NewMobileAwareStoreWrapper creates a wrapper that intercepts SaveAuthRequestInfo
+
// to also save mobile CSRF data when present in context.
+
func NewMobileAwareStoreWrapper(store oauth.ClientAuthStore) *MobileAwareStoreWrapper {
+
wrapper := &MobileAwareStoreWrapper{
+
ClientAuthStore: store,
+
}
+
// Check if the underlying store implements MobileOAuthStore
+
if ms, ok := store.(MobileOAuthStore); ok {
+
wrapper.mobileStore = ms
+
}
+
return wrapper
+
}
+
+
// SaveAuthRequestInfo saves the auth request and also saves mobile CSRF data
+
// if mobile flow data is present in the context.
+
func (w *MobileAwareStoreWrapper) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
+
// First, save the auth request to the underlying store
+
if err := w.ClientAuthStore.SaveAuthRequestInfo(ctx, info); err != nil {
+
return err
+
}
+
+
// Check if this is a mobile flow (mobile data in context)
+
if mobileData, ok := getMobileFlowDataFromContext(ctx); ok && w.mobileStore != nil {
+
// Save mobile CSRF data tied to this OAuth state
+
// IMPORTANT: If this fails, we MUST propagate the error. Otherwise:
+
// 1. No server-side CSRF record is stored
+
// 2. Every mobile callback will "fail closed" to web flow
+
// 3. Mobile sign-in silently breaks with no indication
+
// Failing loudly here lets the user retry rather than being confused
+
// about why they're getting a web flow instead of mobile.
+
if err := w.mobileStore.SaveMobileOAuthData(ctx, info.State, mobileData); err != nil {
+
slog.Error("failed to save mobile CSRF data - mobile login will fail",
+
"state", info.State, "error", err)
+
return fmt.Errorf("failed to save mobile OAuth data: %w", err)
+
}
+
}
+
+
return nil
+
}
+
+
// GetMobileOAuthData implements MobileOAuthStore interface by delegating to underlying store
+
func (w *MobileAwareStoreWrapper) GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) {
+
if w.mobileStore != nil {
+
return w.mobileStore.GetMobileOAuthData(ctx, state)
+
}
+
return nil, nil
+
}
+
+
// SaveMobileOAuthData implements MobileOAuthStore interface by delegating to underlying store
+
func (w *MobileAwareStoreWrapper) SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error {
+
if w.mobileStore != nil {
+
return w.mobileStore.SaveMobileOAuthData(ctx, state, data)
+
}
+
return nil
+
}
+
+
// UnwrapPostgresStore returns the underlying PostgresOAuthStore if present.
+
// This is useful for accessing cleanup methods that aren't part of the interface.
+
func (w *MobileAwareStoreWrapper) UnwrapPostgresStore() *PostgresOAuthStore {
+
if ps, ok := w.ClientAuthStore.(*PostgresOAuthStore); ok {
+
return ps
+
}
+
return nil
+
}
+
+
// SaveMobileOAuthData stores mobile CSRF data tied to an OAuth state
+
// This ties the CSRF token to the OAuth flow via the state parameter,
+
// which comes back through the OAuth response for server-side validation.
+
func (s *PostgresOAuthStore) SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error {
+
query := `
+
UPDATE oauth_requests
+
SET mobile_csrf_token = $2, mobile_redirect_uri = $3
+
WHERE state = $1
+
`
+
+
result, err := s.db.ExecContext(ctx, query, state, data.CSRFToken, data.RedirectURI)
+
if err != nil {
+
return fmt.Errorf("failed to save mobile OAuth data: %w", err)
+
}
+
+
rows, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rows == 0 {
+
return ErrAuthRequestNotFound
+
}
+
+
return nil
+
}
+
+
// GetMobileOAuthData retrieves mobile CSRF data by OAuth state
+
// This is called during callback to compare the server-side CSRF token
+
// (retrieved by state from the OAuth response) against the cookie CSRF.
+
func (s *PostgresOAuthStore) GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) {
+
query := `
+
SELECT mobile_csrf_token, mobile_redirect_uri
+
FROM oauth_requests
+
WHERE state = $1
+
`
+
+
var csrfToken, redirectURI sql.NullString
+
err := s.db.QueryRowContext(ctx, query, state).Scan(&csrfToken, &redirectURI)
+
+
if err == sql.ErrNoRows {
+
return nil, ErrAuthRequestNotFound
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get mobile OAuth data: %w", err)
+
}
+
+
// Return nil if no mobile data was stored (this was a web flow)
+
if !csrfToken.Valid {
+
return nil, nil
+
}
+
+
return &MobileOAuthData{
+
CSRFToken: csrfToken.String,
+
RedirectURI: redirectURI.String,
+
}, nil
+
}
+522
internal/atproto/oauth/store_test.go
···
+
package oauth
+
+
import (
+
"context"
+
"database/sql"
+
"os"
+
"testing"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// setupTestDB creates a test database connection and runs migrations
+
func setupTestDB(t *testing.T) *sql.DB {
+
dsn := os.Getenv("TEST_DATABASE_URL")
+
if dsn == "" {
+
dsn = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dsn)
+
require.NoError(t, err, "Failed to connect to test database")
+
+
// Run migrations
+
require.NoError(t, goose.Up(db, "../../db/migrations"), "Failed to run migrations")
+
+
return db
+
}
+
+
// cleanupOAuth removes all test OAuth data from the database
+
func cleanupOAuth(t *testing.T, db *sql.DB) {
+
_, err := db.Exec("DELETE FROM oauth_sessions WHERE did LIKE 'did:plc:test%'")
+
require.NoError(t, err, "Failed to cleanup oauth_sessions")
+
+
_, err = db.Exec("DELETE FROM oauth_requests WHERE state LIKE 'test%'")
+
require.NoError(t, err, "Failed to cleanup oauth_requests")
+
}
+
+
func TestPostgresOAuthStore_SaveAndGetSession(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:test123abc")
+
require.NoError(t, err)
+
+
session := oauth.ClientSessionData{
+
AccountDID: did,
+
SessionID: "session123",
+
HostURL: "https://pds.example.com",
+
AuthServerURL: "https://auth.example.com",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
AuthServerRevocationEndpoint: "https://auth.example.com/oauth/revoke",
+
Scopes: []string{"atproto"},
+
AccessToken: "at_test_token_abc123",
+
RefreshToken: "rt_test_token_xyz789",
+
DPoPAuthServerNonce: "nonce_auth_123",
+
DPoPHostNonce: "nonce_host_456",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
// Save session
+
err = store.SaveSession(ctx, session)
+
assert.NoError(t, err)
+
+
// Retrieve session
+
retrieved, err := store.GetSession(ctx, did, "session123")
+
assert.NoError(t, err)
+
assert.NotNil(t, retrieved)
+
assert.Equal(t, session.AccountDID.String(), retrieved.AccountDID.String())
+
assert.Equal(t, session.SessionID, retrieved.SessionID)
+
assert.Equal(t, session.HostURL, retrieved.HostURL)
+
assert.Equal(t, session.AuthServerURL, retrieved.AuthServerURL)
+
assert.Equal(t, session.AuthServerTokenEndpoint, retrieved.AuthServerTokenEndpoint)
+
assert.Equal(t, session.AccessToken, retrieved.AccessToken)
+
assert.Equal(t, session.RefreshToken, retrieved.RefreshToken)
+
assert.Equal(t, session.DPoPAuthServerNonce, retrieved.DPoPAuthServerNonce)
+
assert.Equal(t, session.DPoPHostNonce, retrieved.DPoPHostNonce)
+
assert.Equal(t, session.DPoPPrivateKeyMultibase, retrieved.DPoPPrivateKeyMultibase)
+
assert.Equal(t, session.Scopes, retrieved.Scopes)
+
}
+
+
func TestPostgresOAuthStore_SaveSession_Upsert(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:testupsert")
+
require.NoError(t, err)
+
+
// Initial session
+
session1 := oauth.ClientSessionData{
+
AccountDID: did,
+
SessionID: "session_upsert",
+
HostURL: "https://pds1.example.com",
+
AuthServerURL: "https://auth1.example.com",
+
AuthServerTokenEndpoint: "https://auth1.example.com/oauth/token",
+
Scopes: []string{"atproto"},
+
AccessToken: "old_access_token",
+
RefreshToken: "old_refresh_token",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
err = store.SaveSession(ctx, session1)
+
require.NoError(t, err)
+
+
// Updated session (same DID and session ID)
+
session2 := oauth.ClientSessionData{
+
AccountDID: did,
+
SessionID: "session_upsert",
+
HostURL: "https://pds2.example.com",
+
AuthServerURL: "https://auth2.example.com",
+
AuthServerTokenEndpoint: "https://auth2.example.com/oauth/token",
+
Scopes: []string{"atproto", "transition:generic"},
+
AccessToken: "new_access_token",
+
RefreshToken: "new_refresh_token",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktX",
+
}
+
+
// Save again - should update
+
err = store.SaveSession(ctx, session2)
+
assert.NoError(t, err)
+
+
// Retrieve should get updated values
+
retrieved, err := store.GetSession(ctx, did, "session_upsert")
+
assert.NoError(t, err)
+
assert.Equal(t, "new_access_token", retrieved.AccessToken)
+
assert.Equal(t, "new_refresh_token", retrieved.RefreshToken)
+
assert.Equal(t, "https://pds2.example.com", retrieved.HostURL)
+
assert.Equal(t, []string{"atproto", "transition:generic"}, retrieved.Scopes)
+
}
+
+
func TestPostgresOAuthStore_GetSession_NotFound(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:nonexistent")
+
require.NoError(t, err)
+
+
_, err = store.GetSession(ctx, did, "nonexistent_session")
+
assert.ErrorIs(t, err, ErrSessionNotFound)
+
}
+
+
func TestPostgresOAuthStore_DeleteSession(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:testdelete")
+
require.NoError(t, err)
+
+
session := oauth.ClientSessionData{
+
AccountDID: did,
+
SessionID: "session_delete",
+
HostURL: "https://pds.example.com",
+
AuthServerURL: "https://auth.example.com",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
Scopes: []string{"atproto"},
+
AccessToken: "test_token",
+
RefreshToken: "test_refresh",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
// Save session
+
err = store.SaveSession(ctx, session)
+
require.NoError(t, err)
+
+
// Delete session
+
err = store.DeleteSession(ctx, did, "session_delete")
+
assert.NoError(t, err)
+
+
// Verify session is gone
+
_, err = store.GetSession(ctx, did, "session_delete")
+
assert.ErrorIs(t, err, ErrSessionNotFound)
+
}
+
+
func TestPostgresOAuthStore_DeleteSession_NotFound(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:nonexistent")
+
require.NoError(t, err)
+
+
err = store.DeleteSession(ctx, did, "nonexistent_session")
+
assert.ErrorIs(t, err, ErrSessionNotFound)
+
}
+
+
func TestPostgresOAuthStore_SaveAndGetAuthRequestInfo(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:testrequest")
+
require.NoError(t, err)
+
+
info := oauth.AuthRequestData{
+
State: "test_state_abc123",
+
AuthServerURL: "https://auth.example.com",
+
AccountDID: &did,
+
Scopes: []string{"atproto"},
+
RequestURI: "urn:ietf:params:oauth:request_uri:abc123",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
AuthServerRevocationEndpoint: "https://auth.example.com/oauth/revoke",
+
PKCEVerifier: "verifier_xyz789",
+
DPoPAuthServerNonce: "nonce_abc",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
// Save auth request info
+
err = store.SaveAuthRequestInfo(ctx, info)
+
assert.NoError(t, err)
+
+
// Retrieve auth request info
+
retrieved, err := store.GetAuthRequestInfo(ctx, "test_state_abc123")
+
assert.NoError(t, err)
+
assert.NotNil(t, retrieved)
+
assert.Equal(t, info.State, retrieved.State)
+
assert.Equal(t, info.AuthServerURL, retrieved.AuthServerURL)
+
assert.NotNil(t, retrieved.AccountDID)
+
assert.Equal(t, info.AccountDID.String(), retrieved.AccountDID.String())
+
assert.Equal(t, info.Scopes, retrieved.Scopes)
+
assert.Equal(t, info.RequestURI, retrieved.RequestURI)
+
assert.Equal(t, info.AuthServerTokenEndpoint, retrieved.AuthServerTokenEndpoint)
+
assert.Equal(t, info.PKCEVerifier, retrieved.PKCEVerifier)
+
assert.Equal(t, info.DPoPAuthServerNonce, retrieved.DPoPAuthServerNonce)
+
assert.Equal(t, info.DPoPPrivateKeyMultibase, retrieved.DPoPPrivateKeyMultibase)
+
}
+
+
func TestPostgresOAuthStore_SaveAuthRequestInfo_NoDID(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
info := oauth.AuthRequestData{
+
State: "test_state_nodid",
+
AuthServerURL: "https://auth.example.com",
+
AccountDID: nil, // No DID provided
+
Scopes: []string{"atproto"},
+
RequestURI: "urn:ietf:params:oauth:request_uri:nodid",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
PKCEVerifier: "verifier_nodid",
+
DPoPAuthServerNonce: "nonce_nodid",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
// Save auth request info without DID
+
err := store.SaveAuthRequestInfo(ctx, info)
+
assert.NoError(t, err)
+
+
// Retrieve and verify DID is nil
+
retrieved, err := store.GetAuthRequestInfo(ctx, "test_state_nodid")
+
assert.NoError(t, err)
+
assert.Nil(t, retrieved.AccountDID)
+
assert.Equal(t, info.State, retrieved.State)
+
}
+
+
func TestPostgresOAuthStore_GetAuthRequestInfo_NotFound(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
_, err := store.GetAuthRequestInfo(ctx, "nonexistent_state")
+
assert.ErrorIs(t, err, ErrAuthRequestNotFound)
+
}
+
+
func TestPostgresOAuthStore_DeleteAuthRequestInfo(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
info := oauth.AuthRequestData{
+
State: "test_state_delete",
+
AuthServerURL: "https://auth.example.com",
+
Scopes: []string{"atproto"},
+
RequestURI: "urn:ietf:params:oauth:request_uri:delete",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
PKCEVerifier: "verifier_delete",
+
DPoPAuthServerNonce: "nonce_delete",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
// Save auth request info
+
err := store.SaveAuthRequestInfo(ctx, info)
+
require.NoError(t, err)
+
+
// Delete auth request info
+
err = store.DeleteAuthRequestInfo(ctx, "test_state_delete")
+
assert.NoError(t, err)
+
+
// Verify it's gone
+
_, err = store.GetAuthRequestInfo(ctx, "test_state_delete")
+
assert.ErrorIs(t, err, ErrAuthRequestNotFound)
+
}
+
+
func TestPostgresOAuthStore_DeleteAuthRequestInfo_NotFound(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
err := store.DeleteAuthRequestInfo(ctx, "nonexistent_state")
+
assert.ErrorIs(t, err, ErrAuthRequestNotFound)
+
}
+
+
func TestPostgresOAuthStore_CleanupExpiredSessions(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
storeInterface := NewPostgresOAuthStore(db, 0) // Use default TTL
+
store, ok := storeInterface.(*PostgresOAuthStore)
+
require.True(t, ok, "store should be *PostgresOAuthStore")
+
ctx := context.Background()
+
+
did1, err := syntax.ParseDID("did:plc:testexpired1")
+
require.NoError(t, err)
+
did2, err := syntax.ParseDID("did:plc:testexpired2")
+
require.NoError(t, err)
+
+
// Create an expired session (manually insert with past expiration)
+
_, err = db.ExecContext(ctx, `
+
INSERT INTO oauth_sessions (
+
did, session_id, handle, pds_url, host_url,
+
access_token, refresh_token,
+
dpop_private_key_multibase, auth_server_iss,
+
auth_server_token_endpoint, scopes,
+
expires_at, created_at
+
) VALUES (
+
$1, $2, $3, $4, $5,
+
$6, $7,
+
$8, $9,
+
$10, $11,
+
NOW() - INTERVAL '1 day', NOW()
+
)
+
`, did1.String(), "expired_session", "test.handle", "https://pds.example.com", "https://pds.example.com",
+
"expired_token", "expired_refresh",
+
"z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH", "https://auth.example.com",
+
"https://auth.example.com/oauth/token", `{"atproto"}`)
+
require.NoError(t, err)
+
+
// Create a valid session
+
validSession := oauth.ClientSessionData{
+
AccountDID: did2,
+
SessionID: "valid_session",
+
HostURL: "https://pds.example.com",
+
AuthServerURL: "https://auth.example.com",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
Scopes: []string{"atproto"},
+
AccessToken: "valid_token",
+
RefreshToken: "valid_refresh",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
err = store.SaveSession(ctx, validSession)
+
require.NoError(t, err)
+
+
// Cleanup expired sessions
+
count, err := store.CleanupExpiredSessions(ctx)
+
assert.NoError(t, err)
+
assert.Equal(t, int64(1), count, "Should delete 1 expired session")
+
+
// Verify expired session is gone
+
_, err = store.GetSession(ctx, did1, "expired_session")
+
assert.ErrorIs(t, err, ErrSessionNotFound)
+
+
// Verify valid session still exists
+
_, err = store.GetSession(ctx, did2, "valid_session")
+
assert.NoError(t, err)
+
}
+
+
func TestPostgresOAuthStore_CleanupExpiredAuthRequests(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
storeInterface := NewPostgresOAuthStore(db, 0)
+
pgStore, ok := storeInterface.(*PostgresOAuthStore)
+
require.True(t, ok, "store should be *PostgresOAuthStore")
+
store := oauth.ClientAuthStore(pgStore)
+
ctx := context.Background()
+
+
// Create an old auth request (manually insert with old timestamp)
+
_, err := db.ExecContext(ctx, `
+
INSERT INTO oauth_requests (
+
state, did, handle, pds_url, pkce_verifier,
+
dpop_private_key_multibase, dpop_authserver_nonce,
+
auth_server_iss, request_uri,
+
auth_server_token_endpoint, scopes,
+
created_at
+
) VALUES (
+
$1, $2, $3, $4, $5,
+
$6, $7,
+
$8, $9,
+
$10, $11,
+
NOW() - INTERVAL '1 hour'
+
)
+
`, "test_old_state", "did:plc:testold", "test.handle", "https://pds.example.com",
+
"old_verifier", "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
"nonce_old", "https://auth.example.com", "urn:ietf:params:oauth:request_uri:old",
+
"https://auth.example.com/oauth/token", `{"atproto"}`)
+
require.NoError(t, err)
+
+
// Create a recent auth request
+
recentInfo := oauth.AuthRequestData{
+
State: "test_recent_state",
+
AuthServerURL: "https://auth.example.com",
+
Scopes: []string{"atproto"},
+
RequestURI: "urn:ietf:params:oauth:request_uri:recent",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
PKCEVerifier: "recent_verifier",
+
DPoPAuthServerNonce: "nonce_recent",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
err = store.SaveAuthRequestInfo(ctx, recentInfo)
+
require.NoError(t, err)
+
+
// Cleanup expired auth requests (older than 30 minutes)
+
count, err := pgStore.CleanupExpiredAuthRequests(ctx)
+
assert.NoError(t, err)
+
assert.Equal(t, int64(1), count, "Should delete 1 expired auth request")
+
+
// Verify old request is gone
+
_, err = store.GetAuthRequestInfo(ctx, "test_old_state")
+
assert.ErrorIs(t, err, ErrAuthRequestNotFound)
+
+
// Verify recent request still exists
+
_, err = store.GetAuthRequestInfo(ctx, "test_recent_state")
+
assert.NoError(t, err)
+
}
+
+
func TestPostgresOAuthStore_MultipleSessions(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
defer cleanupOAuth(t, db)
+
+
store := NewPostgresOAuthStore(db, 0) // Use default TTL
+
ctx := context.Background()
+
+
did, err := syntax.ParseDID("did:plc:testmulti")
+
require.NoError(t, err)
+
+
// Create multiple sessions for the same DID
+
session1 := oauth.ClientSessionData{
+
AccountDID: did,
+
SessionID: "browser1",
+
HostURL: "https://pds.example.com",
+
AuthServerURL: "https://auth.example.com",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
Scopes: []string{"atproto"},
+
AccessToken: "token_browser1",
+
RefreshToken: "refresh_browser1",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
+
}
+
+
session2 := oauth.ClientSessionData{
+
AccountDID: did,
+
SessionID: "mobile_app",
+
HostURL: "https://pds.example.com",
+
AuthServerURL: "https://auth.example.com",
+
AuthServerTokenEndpoint: "https://auth.example.com/oauth/token",
+
Scopes: []string{"atproto"},
+
AccessToken: "token_mobile",
+
RefreshToken: "refresh_mobile",
+
DPoPPrivateKeyMultibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktX",
+
}
+
+
// Save both sessions
+
err = store.SaveSession(ctx, session1)
+
require.NoError(t, err)
+
err = store.SaveSession(ctx, session2)
+
require.NoError(t, err)
+
+
// Retrieve both sessions
+
retrieved1, err := store.GetSession(ctx, did, "browser1")
+
assert.NoError(t, err)
+
assert.Equal(t, "token_browser1", retrieved1.AccessToken)
+
+
retrieved2, err := store.GetSession(ctx, did, "mobile_app")
+
assert.NoError(t, err)
+
assert.Equal(t, "token_mobile", retrieved2.AccessToken)
+
+
// Delete one session
+
err = store.DeleteSession(ctx, did, "browser1")
+
assert.NoError(t, err)
+
+
// Verify only browser1 is deleted
+
_, err = store.GetSession(ctx, did, "browser1")
+
assert.ErrorIs(t, err, ErrSessionNotFound)
+
+
// mobile_app should still exist
+
_, err = store.GetSession(ctx, did, "mobile_app")
+
assert.NoError(t, err)
+
}
+99
internal/atproto/oauth/transport.go
···
+
package oauth
+
+
import (
+
"fmt"
+
"net"
+
"net/http"
+
"time"
+
)
+
+
// ssrfSafeTransport wraps http.Transport to prevent SSRF attacks
+
type ssrfSafeTransport struct {
+
base *http.Transport
+
allowPrivate bool // For dev/testing only
+
}
+
+
// isPrivateIP checks if an IP is in a private/reserved range
+
func isPrivateIP(ip net.IP) bool {
+
if ip == nil {
+
return false
+
}
+
+
// Check for loopback
+
if ip.IsLoopback() {
+
return true
+
}
+
+
// Check for link-local
+
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+
return true
+
}
+
+
// Check for private ranges
+
privateRanges := []string{
+
"10.0.0.0/8",
+
"172.16.0.0/12",
+
"192.168.0.0/16",
+
"169.254.0.0/16",
+
"::1/128",
+
"fc00::/7",
+
"fe80::/10",
+
}
+
+
for _, cidr := range privateRanges {
+
_, network, err := net.ParseCIDR(cidr)
+
if err == nil && network.Contains(ip) {
+
return true
+
}
+
}
+
+
return false
+
}
+
+
func (t *ssrfSafeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+
host := req.URL.Hostname()
+
+
// Resolve hostname to IP
+
ips, err := net.LookupIP(host)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve host: %w", err)
+
}
+
+
// Check all resolved IPs
+
if !t.allowPrivate {
+
for _, ip := range ips {
+
if isPrivateIP(ip) {
+
return nil, fmt.Errorf("SSRF blocked: %s resolves to private IP %s", host, ip)
+
}
+
}
+
}
+
+
return t.base.RoundTrip(req)
+
}
+
+
// NewSSRFSafeHTTPClient creates an HTTP client with SSRF protections
+
func NewSSRFSafeHTTPClient(allowPrivate bool) *http.Client {
+
transport := &ssrfSafeTransport{
+
base: &http.Transport{
+
DialContext: (&net.Dialer{
+
Timeout: 10 * time.Second,
+
KeepAlive: 30 * time.Second,
+
}).DialContext,
+
MaxIdleConns: 100,
+
IdleConnTimeout: 90 * time.Second,
+
TLSHandshakeTimeout: 10 * time.Second,
+
},
+
allowPrivate: allowPrivate,
+
}
+
+
return &http.Client{
+
Timeout: 15 * time.Second,
+
Transport: transport,
+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
+
if len(via) >= 5 {
+
return fmt.Errorf("too many redirects")
+
}
+
return nil
+
},
+
}
+
}
+132
internal/atproto/oauth/transport_test.go
···
+
package oauth
+
+
import (
+
"net"
+
"net/http"
+
"testing"
+
)
+
+
func TestIsPrivateIP(t *testing.T) {
+
tests := []struct {
+
name string
+
ip string
+
expected bool
+
}{
+
// Loopback addresses
+
{"IPv4 loopback", "127.0.0.1", true},
+
{"IPv6 loopback", "::1", true},
+
+
// Private IPv4 ranges
+
{"Private 10.x.x.x", "10.0.0.1", true},
+
{"Private 10.x.x.x edge", "10.255.255.255", true},
+
{"Private 172.16.x.x", "172.16.0.1", true},
+
{"Private 172.31.x.x edge", "172.31.255.255", true},
+
{"Private 192.168.x.x", "192.168.1.1", true},
+
{"Private 192.168.x.x edge", "192.168.255.255", true},
+
+
// Link-local addresses
+
{"Link-local IPv4", "169.254.1.1", true},
+
{"Link-local IPv6", "fe80::1", true},
+
+
// IPv6 private ranges
+
{"IPv6 unique local fc00", "fc00::1", true},
+
{"IPv6 unique local fd00", "fd00::1", true},
+
+
// Public addresses
+
{"Public IP 1.1.1.1", "1.1.1.1", false},
+
{"Public IP 8.8.8.8", "8.8.8.8", false},
+
{"Public IP 172.15.0.1", "172.15.0.1", false}, // Just before 172.16/12
+
{"Public IP 172.32.0.1", "172.32.0.1", false}, // Just after 172.31/12
+
{"Public IP 11.0.0.1", "11.0.0.1", false}, // Just after 10/8
+
{"Public IPv6", "2001:4860:4860::8888", false}, // Google DNS
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
ip := net.ParseIP(tt.ip)
+
if ip == nil {
+
t.Fatalf("Failed to parse IP: %s", tt.ip)
+
}
+
+
result := isPrivateIP(ip)
+
if result != tt.expected {
+
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.ip, result, tt.expected)
+
}
+
})
+
}
+
}
+
+
func TestIsPrivateIP_NilIP(t *testing.T) {
+
result := isPrivateIP(nil)
+
if result != false {
+
t.Errorf("isPrivateIP(nil) = %v, expected false", result)
+
}
+
}
+
+
func TestNewSSRFSafeHTTPClient(t *testing.T) {
+
tests := []struct {
+
name string
+
allowPrivate bool
+
}{
+
{"Production client (no private IPs)", false},
+
{"Development client (allow private IPs)", true},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
client := NewSSRFSafeHTTPClient(tt.allowPrivate)
+
+
if client == nil {
+
t.Fatal("NewSSRFSafeHTTPClient returned nil")
+
}
+
+
if client.Timeout == 0 {
+
t.Error("Expected timeout to be set")
+
}
+
+
if client.Transport == nil {
+
t.Error("Expected transport to be set")
+
}
+
+
transport, ok := client.Transport.(*ssrfSafeTransport)
+
if !ok {
+
t.Error("Expected ssrfSafeTransport")
+
}
+
+
if transport.allowPrivate != tt.allowPrivate {
+
t.Errorf("Expected allowPrivate=%v, got %v", tt.allowPrivate, transport.allowPrivate)
+
}
+
})
+
}
+
}
+
+
func TestSSRFSafeHTTPClient_RedirectLimit(t *testing.T) {
+
client := NewSSRFSafeHTTPClient(false)
+
+
// Simulate checking redirect limit
+
if client.CheckRedirect == nil {
+
t.Fatal("Expected CheckRedirect to be set")
+
}
+
+
// Test redirect limit (5 redirects)
+
var via []*http.Request
+
for i := 0; i < 5; i++ {
+
req := &http.Request{}
+
via = append(via, req)
+
}
+
+
err := client.CheckRedirect(nil, via)
+
if err == nil {
+
t.Error("Expected error for too many redirects")
+
}
+
if err.Error() != "too many redirects" {
+
t.Errorf("Expected 'too many redirects' error, got: %v", err)
+
}
+
+
// Test within limit (4 redirects)
+
via = via[:4]
+
err = client.CheckRedirect(nil, via)
+
if err != nil {
+
t.Errorf("Expected no error for 4 redirects, got: %v", err)
+
}
+
}
+124
internal/db/migrations/019_update_oauth_for_indigo.sql
···
+
-- +goose Up
+
-- Update OAuth tables to match indigo's ClientAuthStore interface requirements
+
-- This migration adds columns needed for OAuth client sessions and auth requests
+
+
-- Update oauth_requests table
+
-- Add columns for request URI, auth server endpoints, scopes, and DPoP key
+
ALTER TABLE oauth_requests
+
ADD COLUMN request_uri TEXT,
+
ADD COLUMN auth_server_token_endpoint TEXT,
+
ADD COLUMN auth_server_revocation_endpoint TEXT,
+
ADD COLUMN scopes TEXT[],
+
ADD COLUMN dpop_private_key_multibase TEXT;
+
+
-- Make original dpop_private_jwk nullable (we now use dpop_private_key_multibase)
+
ALTER TABLE oauth_requests ALTER COLUMN dpop_private_jwk DROP NOT NULL;
+
+
-- Make did nullable (indigo's AuthRequestData.AccountDID is a pointer - optional)
+
ALTER TABLE oauth_requests ALTER COLUMN did DROP NOT NULL;
+
+
-- Make handle and pds_url nullable too (derived from DID resolution, not always available at auth request time)
+
ALTER TABLE oauth_requests ALTER COLUMN handle DROP NOT NULL;
+
ALTER TABLE oauth_requests ALTER COLUMN pds_url DROP NOT NULL;
+
+
-- Update existing oauth_requests data
+
-- Convert dpop_private_jwk (JSONB) to multibase format if needed
+
-- Note: This will leave the multibase column NULL for now since conversion requires crypto logic
+
-- The application will need to handle NULL values or regenerate keys on next auth flow
+
UPDATE oauth_requests
+
SET
+
auth_server_token_endpoint = auth_server_iss || '/oauth/token',
+
scopes = ARRAY['atproto']::TEXT[]
+
WHERE auth_server_token_endpoint IS NULL;
+
+
-- Add indexes for new columns
+
CREATE INDEX idx_oauth_requests_request_uri ON oauth_requests(request_uri) WHERE request_uri IS NOT NULL;
+
+
-- Update oauth_sessions table
+
-- Add session_id column (will become part of composite key)
+
ALTER TABLE oauth_sessions
+
ADD COLUMN session_id TEXT,
+
ADD COLUMN host_url TEXT,
+
ADD COLUMN auth_server_token_endpoint TEXT,
+
ADD COLUMN auth_server_revocation_endpoint TEXT,
+
ADD COLUMN scopes TEXT[],
+
ADD COLUMN dpop_private_key_multibase TEXT;
+
+
-- Make original dpop_private_jwk nullable (we now use dpop_private_key_multibase)
+
ALTER TABLE oauth_sessions ALTER COLUMN dpop_private_jwk DROP NOT NULL;
+
+
-- Populate session_id for existing sessions (use DID as default for single-session per account)
+
-- In production, you may want to generate unique session IDs
+
UPDATE oauth_sessions
+
SET
+
session_id = 'default',
+
host_url = pds_url,
+
auth_server_token_endpoint = auth_server_iss || '/oauth/token',
+
scopes = ARRAY['atproto']::TEXT[]
+
WHERE session_id IS NULL;
+
+
-- Make session_id NOT NULL after populating existing data
+
ALTER TABLE oauth_sessions
+
ALTER COLUMN session_id SET NOT NULL;
+
+
-- Drop old unique constraint on did only
+
ALTER TABLE oauth_sessions
+
DROP CONSTRAINT IF EXISTS oauth_sessions_did_key;
+
+
-- Create new composite unique constraint for (did, session_id)
+
-- This allows multiple sessions per account
+
-- Note: UNIQUE constraint automatically creates an index, so no separate index needed
+
ALTER TABLE oauth_sessions
+
ADD CONSTRAINT oauth_sessions_did_session_id_key UNIQUE (did, session_id);
+
+
-- Add comment explaining the schema change
+
COMMENT ON COLUMN oauth_sessions.session_id IS 'Session identifier to support multiple concurrent sessions per account';
+
COMMENT ON CONSTRAINT oauth_sessions_did_session_id_key ON oauth_sessions IS 'Composite key allowing multiple sessions per DID';
+
+
-- +goose Down
+
-- Rollback: Remove added columns and restore original unique constraint
+
+
-- oauth_sessions rollback
+
-- Drop composite unique constraint (this also drops the associated index)
+
ALTER TABLE oauth_sessions
+
DROP CONSTRAINT IF EXISTS oauth_sessions_did_session_id_key;
+
+
-- Delete all but the most recent session per DID before restoring unique constraint
+
-- This ensures the UNIQUE (did) constraint can be added without conflicts
+
DELETE FROM oauth_sessions a
+
USING oauth_sessions b
+
WHERE a.did = b.did
+
AND a.created_at < b.created_at;
+
+
-- Restore old unique constraint
+
ALTER TABLE oauth_sessions
+
ADD CONSTRAINT oauth_sessions_did_key UNIQUE (did);
+
+
-- Restore NOT NULL constraint on dpop_private_jwk
+
ALTER TABLE oauth_sessions
+
ALTER COLUMN dpop_private_jwk SET NOT NULL;
+
+
ALTER TABLE oauth_sessions
+
DROP COLUMN IF EXISTS dpop_private_key_multibase,
+
DROP COLUMN IF EXISTS scopes,
+
DROP COLUMN IF EXISTS auth_server_revocation_endpoint,
+
DROP COLUMN IF EXISTS auth_server_token_endpoint,
+
DROP COLUMN IF EXISTS host_url,
+
DROP COLUMN IF EXISTS session_id;
+
+
-- oauth_requests rollback
+
DROP INDEX IF EXISTS idx_oauth_requests_request_uri;
+
+
-- Restore NOT NULL constraints
+
ALTER TABLE oauth_requests
+
ALTER COLUMN dpop_private_jwk SET NOT NULL,
+
ALTER COLUMN did SET NOT NULL,
+
ALTER COLUMN handle SET NOT NULL,
+
ALTER COLUMN pds_url SET NOT NULL;
+
+
ALTER TABLE oauth_requests
+
DROP COLUMN IF EXISTS dpop_private_key_multibase,
+
DROP COLUMN IF EXISTS scopes,
+
DROP COLUMN IF EXISTS auth_server_revocation_endpoint,
+
DROP COLUMN IF EXISTS auth_server_token_endpoint,
+
DROP COLUMN IF EXISTS request_uri;
+23
internal/db/migrations/020_add_mobile_oauth_csrf.sql
···
+
-- +goose Up
+
-- Add columns for mobile OAuth CSRF protection with server-side state
+
-- This ties the CSRF token to the OAuth state, allowing validation against
+
-- a value that comes back through the OAuth response (the state parameter)
+
-- rather than only validating cookies against each other.
+
+
ALTER TABLE oauth_requests
+
ADD COLUMN mobile_csrf_token TEXT,
+
ADD COLUMN mobile_redirect_uri TEXT;
+
+
-- Index for quick lookup of mobile data when callback is received
+
CREATE INDEX idx_oauth_requests_mobile_csrf ON oauth_requests(state)
+
WHERE mobile_csrf_token IS NOT NULL;
+
+
COMMENT ON COLUMN oauth_requests.mobile_csrf_token IS 'CSRF token for mobile OAuth flows, validated against cookie on callback';
+
COMMENT ON COLUMN oauth_requests.mobile_redirect_uri IS 'Mobile redirect URI (Universal Link) for this OAuth flow';
+
+
-- +goose Down
+
DROP INDEX IF EXISTS idx_oauth_requests_mobile_csrf;
+
+
ALTER TABLE oauth_requests
+
DROP COLUMN IF EXISTS mobile_redirect_uri,
+
DROP COLUMN IF EXISTS mobile_csrf_token;
+137
internal/api/handlers/wellknown/universal_links.go
···
+
package wellknown
+
+
import (
+
"encoding/json"
+
"log/slog"
+
"net/http"
+
"os"
+
)
+
+
// HandleAppleAppSiteAssociation serves the iOS Universal Links configuration
+
// GET /.well-known/apple-app-site-association
+
//
+
// Universal Links provide cryptographic binding between the app and domain:
+
// - Requires apple-app-site-association file served over HTTPS
+
// - App must have Associated Domains capability configured
+
// - System verifies domain ownership before routing deep links
+
// - Prevents malicious apps from intercepting deep links
+
//
+
// Spec: https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app
+
func HandleAppleAppSiteAssociation(w http.ResponseWriter, r *http.Request) {
+
// Get Apple App ID from environment (format: <Team ID>.<Bundle ID>)
+
// Example: "ABCD1234.social.coves.app"
+
// Find Team ID in Apple Developer Portal -> Membership
+
// Bundle ID is configured in Xcode project
+
appleAppID := os.Getenv("APPLE_APP_ID")
+
if appleAppID == "" {
+
// Development fallback - allows testing without real Team ID
+
// IMPORTANT: This MUST be set in production for Universal Links to work
+
appleAppID = "DEVELOPMENT.social.coves.app"
+
slog.Warn("APPLE_APP_ID not set, using development placeholder",
+
"app_id", appleAppID,
+
"note", "Set APPLE_APP_ID env var for production Universal Links")
+
}
+
+
// Apple requires application/json content type (no charset)
+
w.Header().Set("Content-Type", "application/json")
+
+
// Construct the response per Apple's spec
+
// See: https://developer.apple.com/documentation/bundleresources/applinks
+
response := map[string]interface{}{
+
"applinks": map[string]interface{}{
+
"apps": []string{}, // Must be empty array per Apple spec
+
"details": []map[string]interface{}{
+
{
+
"appID": appleAppID,
+
// Paths that trigger Universal Links when opened in Safari/other apps
+
// These URLs will open the app instead of the browser
+
"paths": []string{
+
"/app/oauth/callback", // Primary Universal Link OAuth callback
+
"/app/oauth/callback/*", // Catch-all for query params
+
},
+
},
+
},
+
},
+
}
+
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
slog.Error("failed to encode apple-app-site-association", "error", err)
+
http.Error(w, "internal server error", http.StatusInternalServerError)
+
return
+
}
+
+
slog.Debug("served apple-app-site-association", "app_id", appleAppID)
+
}
+
+
// HandleAssetLinks serves the Android App Links configuration
+
// GET /.well-known/assetlinks.json
+
//
+
// App Links provide cryptographic binding between the app and domain:
+
// - Requires assetlinks.json file served over HTTPS
+
// - App must have intent-filter with android:autoVerify="true"
+
// - System verifies domain ownership via SHA-256 certificate fingerprint
+
// - Prevents malicious apps from intercepting deep links
+
//
+
// Spec: https://developer.android.com/training/app-links/verify-android-applinks
+
func HandleAssetLinks(w http.ResponseWriter, r *http.Request) {
+
// Get Android package name from environment
+
// Example: "social.coves.app"
+
androidPackage := os.Getenv("ANDROID_PACKAGE_NAME")
+
if androidPackage == "" {
+
androidPackage = "social.coves.app" // Default for development
+
slog.Warn("ANDROID_PACKAGE_NAME not set, using default",
+
"package", androidPackage,
+
"note", "Set ANDROID_PACKAGE_NAME env var for production App Links")
+
}
+
+
// Get SHA-256 fingerprint from environment
+
// This is the SHA-256 fingerprint of the app's signing certificate
+
//
+
// To get the fingerprint:
+
// Production: keytool -list -v -keystore release.jks -alias release
+
// Debug: keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
+
//
+
// Look for "SHA256:" in the output
+
// Format: AA:BB:CC:DD:...:FF (64 hex characters separated by colons)
+
androidFingerprint := os.Getenv("ANDROID_SHA256_FINGERPRINT")
+
if androidFingerprint == "" {
+
// Development fallback - this won't work for real App Links verification
+
// IMPORTANT: This MUST be set in production for App Links to work
+
androidFingerprint = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00"
+
slog.Warn("ANDROID_SHA256_FINGERPRINT not set, using development placeholder",
+
"fingerprint", androidFingerprint,
+
"note", "Set ANDROID_SHA256_FINGERPRINT env var for production App Links")
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
+
// Construct the response per Google's Digital Asset Links spec
+
// See: https://developers.google.com/digital-asset-links/v1/getting-started
+
response := []map[string]interface{}{
+
{
+
// delegate_permission/common.handle_all_urls grants the app permission
+
// to handle URLs for this domain
+
"relation": []string{"delegate_permission/common.handle_all_urls"},
+
"target": map[string]interface{}{
+
"namespace": "android_app",
+
"package_name": androidPackage,
+
// List of certificate fingerprints that can sign the app
+
// Multiple fingerprints can be provided for different signing keys
+
// (e.g., debug + release)
+
"sha256_cert_fingerprints": []string{
+
androidFingerprint,
+
},
+
},
+
},
+
}
+
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
slog.Error("failed to encode assetlinks.json", "error", err)
+
http.Error(w, "internal server error", http.StatusInternalServerError)
+
return
+
}
+
+
slog.Debug("served assetlinks.json",
+
"package", androidPackage,
+
"fingerprint", androidFingerprint)
+
}
+25
internal/api/routes/wellknown.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/wellknown"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterWellKnownRoutes registers RFC 8615 well-known URI endpoints
+
// These endpoints are used for service discovery and mobile app deep linking
+
//
+
// Spec: https://www.rfc-editor.org/rfc/rfc8615.html
+
func RegisterWellKnownRoutes(r chi.Router) {
+
// iOS Universal Links configuration
+
// Required for cryptographically-bound deep linking on iOS
+
// Must be served at exact path /.well-known/apple-app-site-association
+
// Content-Type: application/json (no redirects allowed)
+
r.Get("/.well-known/apple-app-site-association", wellknown.HandleAppleAppSiteAssociation)
+
+
// Android App Links configuration
+
// Required for cryptographically-bound deep linking on Android
+
// Must be served at exact path /.well-known/assetlinks.json
+
// Content-Type: application/json (no redirects allowed)
+
r.Get("/.well-known/assetlinks.json", wellknown.HandleAssetLinks)
+
}
+1 -1
internal/api/handlers/comments/middleware.go
···
// The middleware extracts the viewer DID from the Authorization header if present and valid,
// making it available via middleware.GetUserDID(r) in the handler.
// If no valid token is present, the request continues as anonymous (empty DID).
-
func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler {
+
func OptionalAuthMiddleware(authMiddleware *middleware.OAuthAuthMiddleware, next http.HandlerFunc) http.Handler {
return authMiddleware.OptionalAuth(http.HandlerFunc(next))
}
+1 -1
internal/api/routes/community.go
···
// RegisterCommunityRoutes registers community-related XRPC endpoints on the router
// Implements social.coves.community.* lexicon endpoints
// allowedCommunityCreators restricts who can create communities. If empty, anyone can create.
-
func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware, allowedCommunityCreators []string) {
+
func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.OAuthAuthMiddleware, allowedCommunityCreators []string) {
// Initialize handlers
createHandler := community.NewCreateHandler(service, allowedCommunityCreators)
getHandler := community.NewGetHandler(service)
+1 -1
internal/api/routes/post.go
···
// RegisterPostRoutes registers post-related XRPC endpoints on the router
// Implements social.coves.community.post.* lexicon endpoints
-
func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {
+
func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
// Initialize handlers
createHandler := post.NewCreateHandler(service)
+291
tests/e2e/oauth_ratelimit_e2e_test.go
···
+
package e2e
+
+
import (
+
"Coves/internal/api/middleware"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"github.com/stretchr/testify/assert"
+
)
+
+
// TestRateLimiting_E2E_OAuthEndpoints tests OAuth-specific rate limiting
+
// OAuth endpoints have stricter rate limits to prevent:
+
// - Credential stuffing attacks on login endpoints (10 req/min)
+
// - OAuth state exhaustion
+
// - Refresh token abuse (20 req/min)
+
func TestRateLimiting_E2E_OAuthEndpoints(t *testing.T) {
+
t.Run("Login endpoints have 10 req/min limit", func(t *testing.T) {
+
// Create rate limiter matching oauth.go config: 10 requests per minute
+
loginLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
+
+
// Mock OAuth login handler
+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte("OK"))
+
})
+
+
handler := loginLimiter.Middleware(testHandler)
+
clientIP := "192.168.1.200:12345"
+
+
// Make exactly 10 requests (at limit)
+
for i := 0; i < 10; i++ {
+
req := httptest.NewRequest("GET", "/oauth/login", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
+
handler.ServeHTTP(rr, req)
+
+
assert.Equal(t, http.StatusOK, rr.Code, "Request %d should succeed", i+1)
+
}
+
+
// 11th request should be rate limited
+
req := httptest.NewRequest("GET", "/oauth/login", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
+
handler.ServeHTTP(rr, req)
+
+
assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Request 11 should be rate limited")
+
assert.Contains(t, rr.Body.String(), "Rate limit exceeded", "Should have rate limit error message")
+
})
+
+
t.Run("Mobile login endpoints have 10 req/min limit", func(t *testing.T) {
+
loginLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
+
+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
})
+
+
handler := loginLimiter.Middleware(testHandler)
+
clientIP := "192.168.1.201:12345"
+
+
// Make 10 requests
+
for i := 0; i < 10; i++ {
+
req := httptest.NewRequest("GET", "/oauth/mobile/login", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusOK, rr.Code)
+
}
+
+
// 11th request blocked
+
req := httptest.NewRequest("GET", "/oauth/mobile/login", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
+
assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Mobile login should be rate limited at 10 req/min")
+
})
+
+
t.Run("Refresh endpoint has 20 req/min limit", func(t *testing.T) {
+
// Refresh has higher limit (20 req/min) for legitimate token refresh
+
refreshLimiter := middleware.NewRateLimiter(20, 1*time.Minute)
+
+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
})
+
+
handler := refreshLimiter.Middleware(testHandler)
+
clientIP := "192.168.1.202:12345"
+
+
// Make 20 requests
+
for i := 0; i < 20; i++ {
+
req := httptest.NewRequest("POST", "/oauth/refresh", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusOK, rr.Code, "Request %d should succeed", i+1)
+
}
+
+
// 21st request blocked
+
req := httptest.NewRequest("POST", "/oauth/refresh", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
+
assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Refresh should be rate limited at 20 req/min")
+
})
+
+
t.Run("Logout endpoint has 10 req/min limit", func(t *testing.T) {
+
logoutLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
+
+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
})
+
+
handler := logoutLimiter.Middleware(testHandler)
+
clientIP := "192.168.1.203:12345"
+
+
// Make 10 requests
+
for i := 0; i < 10; i++ {
+
req := httptest.NewRequest("POST", "/oauth/logout", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusOK, rr.Code)
+
}
+
+
// 11th request blocked
+
req := httptest.NewRequest("POST", "/oauth/logout", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
+
assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Logout should be rate limited at 10 req/min")
+
})
+
+
t.Run("OAuth callback has 10 req/min limit", func(t *testing.T) {
+
// Callback uses same limiter as login (part of auth flow)
+
callbackLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
+
+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
})
+
+
handler := callbackLimiter.Middleware(testHandler)
+
clientIP := "192.168.1.204:12345"
+
+
// Make 10 requests
+
for i := 0; i < 10; i++ {
+
req := httptest.NewRequest("GET", "/oauth/callback", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusOK, rr.Code)
+
}
+
+
// 11th request blocked
+
req := httptest.NewRequest("GET", "/oauth/callback", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
handler.ServeHTTP(rr, req)
+
+
assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Callback should be rate limited at 10 req/min")
+
})
+
+
t.Run("OAuth rate limits are stricter than global limit", func(t *testing.T) {
+
// Verify OAuth limits are more restrictive than global 100 req/min
+
const globalLimit = 100
+
const oauthLoginLimit = 10
+
const oauthRefreshLimit = 20
+
+
assert.Less(t, oauthLoginLimit, globalLimit, "OAuth login limit should be stricter than global")
+
assert.Less(t, oauthRefreshLimit, globalLimit, "OAuth refresh limit should be stricter than global")
+
assert.Greater(t, oauthRefreshLimit, oauthLoginLimit, "Refresh limit should be higher than login (legitimate use case)")
+
})
+
+
t.Run("OAuth limits prevent credential stuffing", func(t *testing.T) {
+
// Simulate credential stuffing attack
+
loginLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
+
+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
// Simulate failed login attempts
+
w.WriteHeader(http.StatusUnauthorized)
+
})
+
+
handler := loginLimiter.Middleware(testHandler)
+
attackerIP := "203.0.113.50:12345"
+
+
// Attacker tries 15 login attempts (credential stuffing)
+
successfulAttempts := 0
+
blockedAttempts := 0
+
+
for i := 0; i < 15; i++ {
+
req := httptest.NewRequest("GET", "/oauth/login", nil)
+
req.RemoteAddr = attackerIP
+
rr := httptest.NewRecorder()
+
+
handler.ServeHTTP(rr, req)
+
+
if rr.Code == http.StatusUnauthorized {
+
successfulAttempts++ // Reached handler (even if auth failed)
+
} else if rr.Code == http.StatusTooManyRequests {
+
blockedAttempts++
+
}
+
}
+
+
// Rate limiter should block 5 attempts after first 10
+
assert.Equal(t, 10, successfulAttempts, "Should allow 10 login attempts")
+
assert.Equal(t, 5, blockedAttempts, "Should block 5 attempts after limit reached")
+
})
+
+
t.Run("OAuth limits are per-endpoint", func(t *testing.T) {
+
// Each endpoint gets its own rate limiter
+
// This test verifies that limits are independent per endpoint
+
loginLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
+
refreshLimiter := middleware.NewRateLimiter(20, 1*time.Minute)
+
+
loginHandler := loginLimiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
refreshHandler := refreshLimiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
clientIP := "192.168.1.205:12345"
+
+
// Exhaust login limit
+
for i := 0; i < 10; i++ {
+
req := httptest.NewRequest("GET", "/oauth/login", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
loginHandler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusOK, rr.Code)
+
}
+
+
// Login limit exhausted
+
req := httptest.NewRequest("GET", "/oauth/login", nil)
+
req.RemoteAddr = clientIP
+
rr := httptest.NewRecorder()
+
loginHandler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Login should be rate limited")
+
+
// Refresh endpoint should still work (independent limiter)
+
req = httptest.NewRequest("POST", "/oauth/refresh", nil)
+
req.RemoteAddr = clientIP
+
rr = httptest.NewRecorder()
+
refreshHandler.ServeHTTP(rr, req)
+
assert.Equal(t, http.StatusOK, rr.Code, "Refresh should not be affected by login rate limit")
+
})
+
}
+
+
// OAuth Rate Limiting Configuration Documentation
+
// ================================================
+
// This test file validates OAuth-specific rate limits applied in oauth.go:
+
//
+
// 1. Login Endpoints (Credential Stuffing Protection)
+
// - Endpoints: /oauth/login, /oauth/mobile/login, /oauth/callback
+
// - Limit: 10 requests per minute per IP
+
// - Reason: Prevent brute force and credential stuffing attacks
+
// - Implementation: internal/api/routes/oauth.go:21
+
//
+
// 2. Refresh Endpoint (Token Refresh)
+
// - Endpoint: /oauth/refresh
+
// - Limit: 20 requests per minute per IP
+
// - Reason: Allow legitimate token refresh while preventing abuse
+
// - Implementation: internal/api/routes/oauth.go:24
+
//
+
// 3. Logout Endpoint
+
// - Endpoint: /oauth/logout
+
// - Limit: 10 requests per minute per IP
+
// - Reason: Prevent session exhaustion attacks
+
// - Implementation: internal/api/routes/oauth.go:27
+
//
+
// 4. Metadata Endpoints (No Extra Limit)
+
// - Endpoints: /oauth/client-metadata.json, /oauth/jwks.json
+
// - Limit: Global 100 requests per minute (from main.go)
+
// - Reason: Public metadata, not sensitive to rate abuse
+
//
+
// Security Benefits:
+
// - Credential Stuffing: Limits password guessing to 10 attempts/min
+
// - State Exhaustion: Prevents OAuth state generation spam
+
// - Token Abuse: Limits refresh token usage while allowing legitimate refresh
+
//
+
// Rate Limit Hierarchy:
+
// - OAuth login: 10 req/min (most restrictive)
+
// - OAuth refresh: 20 req/min (moderate)
+
// - Comments: 20 req/min (expensive queries)
+
// - Global: 100 req/min (baseline)
-208
tests/integration/jwt_verification_test.go
···
-
package integration
-
-
import (
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/auth"
-
"fmt"
-
"net/http"
-
"net/http/httptest"
-
"os"
-
"strings"
-
"testing"
-
"time"
-
)
-
-
// TestJWTSignatureVerification tests end-to-end JWT signature verification
-
// with a real PDS-issued token. This verifies that AUTH_SKIP_VERIFY=false works.
-
//
-
// Flow:
-
// 1. Create account on local PDS (or use existing)
-
// 2. Authenticate to get a real signed JWT token
-
// 3. Verify our auth middleware can fetch JWKS and verify the signature
-
// 4. Test with AUTH_SKIP_VERIFY=false (production mode)
-
//
-
// NOTE: Local dev PDS (docker-compose.dev.yml) uses symmetric JWT_SECRET signing
-
// instead of asymmetric JWKS keys. This test verifies the code path works, but
-
// full JWKS verification requires a production PDS or setting up proper keys.
-
func TestJWTSignatureVerification(t *testing.T) {
-
// Skip in short mode since this requires real PDS
-
if testing.Short() {
-
t.Skip("Skipping JWT verification test in short mode")
-
}
-
-
pdsURL := os.Getenv("PDS_URL")
-
if pdsURL == "" {
-
pdsURL = "http://localhost:3001"
-
}
-
-
// Check if PDS is running
-
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
-
if err != nil {
-
t.Skipf("PDS not running at %s: %v", pdsURL, err)
-
}
-
_ = healthResp.Body.Close()
-
-
// Check if JWKS is available (production PDS) or symmetric secret (dev PDS)
-
jwksResp, _ := http.Get(pdsURL + "/oauth/jwks")
-
if jwksResp != nil {
-
defer func() { _ = jwksResp.Body.Close() }()
-
}
-
-
t.Run("JWT parsing and middleware integration", func(t *testing.T) {
-
// Step 1: Create a test account on PDS
-
// Keep handle short to avoid PDS validation errors
-
timestamp := time.Now().Unix() % 100000 // Last 5 digits
-
handle := fmt.Sprintf("jwt%d.local.coves.dev", timestamp)
-
password := "testpass123"
-
email := fmt.Sprintf("jwt%d@test.com", timestamp)
-
-
accessToken, did, err := createPDSAccount(pdsURL, handle, email, password)
-
if err != nil {
-
t.Fatalf("Failed to create PDS account: %v", err)
-
}
-
t.Logf("โœ“ Created test account: %s (DID: %s)", handle, did)
-
t.Logf("โœ“ Received JWT token from PDS (length: %d)", len(accessToken))
-
-
// Step 3: Test JWT parsing (should work regardless of verification)
-
claims, err := auth.ParseJWT(accessToken)
-
if err != nil {
-
t.Fatalf("Failed to parse JWT: %v", err)
-
}
-
t.Logf("โœ“ JWT parsed successfully")
-
t.Logf(" Subject (DID): %s", claims.Subject)
-
t.Logf(" Issuer: %s", claims.Issuer)
-
t.Logf(" Scope: %s", claims.Scope)
-
-
if claims.Subject != did {
-
t.Errorf("Token DID mismatch: expected %s, got %s", did, claims.Subject)
-
}
-
-
// Step 4: Test JWKS fetching and signature verification
-
// NOTE: Local dev PDS uses symmetric secret, not JWKS
-
// For production, we'd verify the full signature here
-
t.Log("Checking JWKS availability...")
-
-
jwksFetcher := auth.NewCachedJWKSFetcher(1 * time.Hour)
-
verifiedClaims, err := auth.VerifyJWT(httptest.NewRequest("GET", "/", nil).Context(), accessToken, jwksFetcher)
-
if err != nil {
-
// Expected for local dev PDS - log and continue
-
t.Logf("โ„น๏ธ JWKS verification skipped (expected for local dev PDS): %v", err)
-
t.Logf(" Local PDS uses symmetric JWT_SECRET instead of JWKS")
-
t.Logf(" In production, this would verify against proper JWKS keys")
-
} else {
-
// Unexpected success - means we're testing against a production PDS
-
t.Logf("โœ“ JWT signature verified successfully!")
-
t.Logf(" Verified DID: %s", verifiedClaims.Subject)
-
t.Logf(" Verified Issuer: %s", verifiedClaims.Issuer)
-
-
if verifiedClaims.Subject != did {
-
t.Errorf("Verified token DID mismatch: expected %s, got %s", did, verifiedClaims.Subject)
-
}
-
}
-
-
// Step 5: Test auth middleware with skipVerify=true (for dev PDS)
-
t.Log("Testing auth middleware with skipVerify=true (dev mode)...")
-
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS
-
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
-
-
handlerCalled := false
-
var extractedDID string
-
-
testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
handlerCalled = true
-
extractedDID = middleware.GetUserDID(r)
-
w.WriteHeader(http.StatusOK)
-
_, _ = w.Write([]byte(`{"success": true}`))
-
}))
-
-
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "DPoP "+accessToken)
-
w := httptest.NewRecorder()
-
-
testHandler.ServeHTTP(w, req)
-
-
if !handlerCalled {
-
t.Errorf("Handler was not called - auth middleware rejected valid token")
-
t.Logf("Response status: %d", w.Code)
-
t.Logf("Response body: %s", w.Body.String())
-
}
-
-
if w.Code != http.StatusOK {
-
t.Errorf("Expected status 200, got %d", w.Code)
-
t.Logf("Response body: %s", w.Body.String())
-
}
-
-
if extractedDID != did {
-
t.Errorf("Middleware extracted wrong DID: expected %s, got %s", did, extractedDID)
-
}
-
-
t.Logf("โœ… Auth middleware with signature verification working correctly!")
-
t.Logf(" Handler called: %v", handlerCalled)
-
t.Logf(" Extracted DID: %s", extractedDID)
-
t.Logf(" Response status: %d", w.Code)
-
})
-
-
t.Run("Rejects tampered JWT", func(t *testing.T) {
-
// Create valid token
-
timestamp := time.Now().Unix() % 100000
-
handle := fmt.Sprintf("tamp%d.local.coves.dev", timestamp)
-
password := "testpass456"
-
email := fmt.Sprintf("tamp%d@test.com", timestamp)
-
-
accessToken, _, err := createPDSAccount(pdsURL, handle, email, password)
-
if err != nil {
-
t.Fatalf("Failed to create PDS account: %v", err)
-
}
-
-
// Tamper with the token more aggressively to break JWT structure
-
parts := splitToken(accessToken)
-
if len(parts) != 3 {
-
t.Fatalf("Invalid JWT structure: expected 3 parts, got %d", len(parts))
-
}
-
// Replace the payload with invalid base64 that will fail decoding
-
tamperedToken := parts[0] + ".!!!invalid-base64!!!." + parts[2]
-
-
// Test with middleware (skipVerify=true since dev PDS doesn't use JWKS)
-
// Tampered payload should fail JWT parsing even without signature check
-
jwksFetcher := auth.NewCachedJWKSFetcher(1 * time.Hour)
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true)
-
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
-
-
handlerCalled := false
-
testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
handlerCalled = true
-
w.WriteHeader(http.StatusOK)
-
}))
-
-
req := httptest.NewRequest("GET", "/test", nil)
-
req.Header.Set("Authorization", "DPoP "+tamperedToken)
-
w := httptest.NewRecorder()
-
-
testHandler.ServeHTTP(w, req)
-
-
if handlerCalled {
-
t.Error("Handler was called for tampered token - should have been rejected")
-
}
-
-
if w.Code != http.StatusUnauthorized {
-
t.Errorf("Expected status 401 for tampered token, got %d", w.Code)
-
}
-
-
t.Logf("โœ… Middleware correctly rejected tampered token with status %d", w.Code)
-
})
-
-
t.Run("Rejects expired JWT with signature verification", func(t *testing.T) {
-
// For this test, we'd need to create a token and wait for expiry,
-
// or mock the time. For now, we'll just verify the validation logic exists.
-
// In production, PDS tokens expire after a certain period.
-
t.Log("โ„น๏ธ Expiration test would require waiting for token expiry or time mocking")
-
t.Log(" Token expiration validation is covered by unit tests in auth_test.go")
-
t.Skip("Skipping expiration test - requires time manipulation")
-
})
-
}
-
-
// splitToken splits a JWT into its three parts (header.payload.signature)
-
func splitToken(token string) []string {
-
return strings.Split(token, ".")
-
}
+910
tests/integration/oauth_e2e_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/oauth"
+
"context"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/http/httptest"
+
"strings"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/go-chi/chi/v5"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// TestOAuth_Components tests OAuth component functionality without requiring PDS.
+
// This validates all Coves OAuth code:
+
// - Session storage and retrieval (PostgreSQL)
+
// - Token sealing (AES-GCM encryption)
+
// - Token unsealing (decryption + validation)
+
// - Session cleanup
+
//
+
// NOTE: Full OAuth redirect flow testing requires both HTTPS PDS and HTTPS Coves deployment.
+
// The OAuth redirect flow is handled by indigo's library and enforces OAuth 2.0 spec
+
// (HTTPS required for authorization servers and redirect URIs).
+
func TestOAuth_Components(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth component test in short mode")
+
}
+
+
// Setup test database
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Run migrations to ensure OAuth tables exist
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
t.Log("๐Ÿ”ง Testing OAuth Components")
+
+
ctx := context.Background()
+
+
// Setup OAuth client and store
+
store := SetupOAuthTestStore(t, db)
+
client := SetupOAuthTestClient(t, store)
+
require.NotNil(t, client, "OAuth client should be initialized")
+
+
// Use a test DID (doesn't need to exist on PDS for component tests)
+
testDID := "did:plc:componenttest123"
+
+
// Run component tests
+
testOAuthComponentsWithMockedSession(t, ctx, nil, store, client, testDID, "")
+
+
t.Log("")
+
t.Log(strings.Repeat("=", 60))
+
t.Log("โœ… OAuth Component Tests Complete")
+
t.Log(strings.Repeat("=", 60))
+
t.Log("Components validated:")
+
t.Log(" โœ“ Session storage (PostgreSQL)")
+
t.Log(" โœ“ Token sealing (AES-GCM encryption)")
+
t.Log(" โœ“ Token unsealing (decryption + validation)")
+
t.Log(" โœ“ Session cleanup")
+
t.Log("")
+
t.Log("NOTE: Full OAuth redirect flow requires HTTPS PDS + HTTPS Coves")
+
t.Log(strings.Repeat("=", 60))
+
}
+
+
// testOAuthComponentsWithMockedSession tests OAuth components that work without PDS redirect flow.
+
// This is used when testing with localhost PDS, where the indigo library rejects http:// URLs.
+
func testOAuthComponentsWithMockedSession(t *testing.T, ctx context.Context, _ interface{}, store oauthlib.ClientAuthStore, client *oauth.OAuthClient, userDID, _ string) {
+
t.Helper()
+
+
t.Log("๐Ÿ”ง Testing OAuth components with mocked session...")
+
+
// Parse DID
+
parsedDID, err := syntax.ParseDID(userDID)
+
require.NoError(t, err, "Should parse DID")
+
+
// Component 1: Session Storage
+
t.Log(" ๐Ÿ“ฆ Component 1: Testing session storage...")
+
testSession := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: fmt.Sprintf("localhost-test-%d", time.Now().UnixNano()),
+
HostURL: "http://localhost:3001",
+
AccessToken: "mocked-access-token",
+
Scopes: []string{"atproto", "transition:generic"},
+
}
+
+
err = store.SaveSession(ctx, testSession)
+
require.NoError(t, err, "Should save session")
+
+
retrieved, err := store.GetSession(ctx, parsedDID, testSession.SessionID)
+
require.NoError(t, err, "Should retrieve session")
+
require.Equal(t, testSession.SessionID, retrieved.SessionID)
+
require.Equal(t, testSession.AccessToken, retrieved.AccessToken)
+
t.Log(" โœ… Session storage working")
+
+
// Component 2: Token Sealing
+
t.Log(" ๐Ÿ” Component 2: Testing token sealing...")
+
sealedToken, err := client.SealSession(parsedDID.String(), testSession.SessionID, time.Hour)
+
require.NoError(t, err, "Should seal token")
+
require.NotEmpty(t, sealedToken, "Sealed token should not be empty")
+
tokenPreview := sealedToken
+
if len(tokenPreview) > 50 {
+
tokenPreview = tokenPreview[:50]
+
}
+
t.Logf(" โœ… Token sealed: %s...", tokenPreview)
+
+
// Component 3: Token Unsealing
+
t.Log(" ๐Ÿ”“ Component 3: Testing token unsealing...")
+
unsealed, err := client.UnsealSession(sealedToken)
+
require.NoError(t, err, "Should unseal token")
+
require.Equal(t, userDID, unsealed.DID)
+
require.Equal(t, testSession.SessionID, unsealed.SessionID)
+
t.Log(" โœ… Token unsealing working")
+
+
// Component 4: Session Cleanup
+
t.Log(" ๐Ÿงน Component 4: Testing session cleanup...")
+
err = store.DeleteSession(ctx, parsedDID, testSession.SessionID)
+
require.NoError(t, err, "Should delete session")
+
+
_, err = store.GetSession(ctx, parsedDID, testSession.SessionID)
+
require.Error(t, err, "Session should not exist after deletion")
+
t.Log(" โœ… Session cleanup working")
+
+
t.Log("โœ… All OAuth components verified!")
+
t.Log("")
+
t.Log("๐Ÿ“ Summary: OAuth implementation validated with mocked session")
+
t.Log(" - Session storage: โœ“")
+
t.Log(" - Token sealing: โœ“")
+
t.Log(" - Token unsealing: โœ“")
+
t.Log(" - Session cleanup: โœ“")
+
t.Log("")
+
t.Log("โš ๏ธ To test full OAuth redirect flow, use a production PDS with HTTPS")
+
}
+
+
// TestOAuthE2E_TokenExpiration tests that expired sealed tokens are rejected
+
func TestOAuthE2E_TokenExpiration(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth token expiration test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("โฐ Testing OAuth token expiration...")
+
+
// Setup OAuth client and store
+
store := SetupOAuthTestStore(t, db)
+
client := SetupOAuthTestClient(t, store)
+
_ = oauth.NewOAuthHandler(client, store) // Handler created for completeness
+
+
// Create test session with past expiration
+
did, err := syntax.ParseDID("did:plc:expiredtest123")
+
require.NoError(t, err)
+
+
testSession := oauthlib.ClientSessionData{
+
AccountDID: did,
+
SessionID: "expired-session",
+
HostURL: "http://localhost:3001",
+
AccessToken: "expired-token",
+
Scopes: []string{"atproto"},
+
}
+
+
// Save session
+
err = store.SaveSession(ctx, testSession)
+
require.NoError(t, err)
+
+
// Manually update expiration to the past
+
_, err = db.ExecContext(ctx,
+
"UPDATE oauth_sessions SET expires_at = NOW() - INTERVAL '1 day' WHERE did = $1 AND session_id = $2",
+
did.String(), testSession.SessionID)
+
require.NoError(t, err)
+
+
// Try to retrieve expired session
+
_, err = store.GetSession(ctx, did, testSession.SessionID)
+
assert.Error(t, err, "Should not be able to retrieve expired session")
+
assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")
+
+
// Test cleanup of expired sessions
+
cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredSessions(ctx)
+
require.NoError(t, err, "Cleanup should succeed")
+
assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one session")
+
+
t.Logf("โœ… Expired session handling verified (cleaned %d sessions)", cleaned)
+
}
+
+
// TestOAuthE2E_InvalidToken tests that invalid/tampered tokens are rejected
+
func TestOAuthE2E_InvalidToken(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth invalid token test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
t.Log("๐Ÿ”’ Testing OAuth invalid token rejection...")
+
+
// Setup OAuth client and store
+
store := SetupOAuthTestStore(t, db)
+
client := SetupOAuthTestClient(t, store)
+
handler := oauth.NewOAuthHandler(client, store)
+
+
// Setup test server with protected endpoint
+
r := chi.NewRouter()
+
r.Get("/api/me", func(w http.ResponseWriter, r *http.Request) {
+
sessData, err := handler.GetSessionFromRequest(r)
+
if err != nil {
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
+
return
+
}
+
w.Header().Set("Content-Type", "application/json")
+
_ = json.NewEncoder(w).Encode(map[string]string{"did": sessData.AccountDID.String()})
+
})
+
+
server := httptest.NewServer(r)
+
defer server.Close()
+
+
// Test with invalid token formats
+
testCases := []struct {
+
name string
+
token string
+
}{
+
{"Empty token", ""},
+
{"Invalid base64", "not-valid-base64!!!"},
+
{"Tampered token", "dGFtcGVyZWQtdG9rZW4tZGF0YQ=="}, // Valid base64 but invalid content
+
{"Short token", "abc"},
+
}
+
+
for _, tc := range testCases {
+
t.Run(tc.name, func(t *testing.T) {
+
req, _ := http.NewRequest("GET", server.URL+"/api/me", nil)
+
if tc.token != "" {
+
req.Header.Set("Authorization", "Bearer "+tc.token)
+
}
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"Invalid token should be rejected with 401")
+
})
+
}
+
+
t.Logf("โœ… Invalid token rejection verified")
+
}
+
+
// TestOAuthE2E_SessionNotFound tests behavior when session doesn't exist in DB
+
func TestOAuthE2E_SessionNotFound(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth session not found test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("๐Ÿ” Testing OAuth session not found behavior...")
+
+
// Setup OAuth store
+
store := SetupOAuthTestStore(t, db)
+
+
// Try to retrieve non-existent session
+
nonExistentDID, err := syntax.ParseDID("did:plc:nonexistent123")
+
require.NoError(t, err)
+
+
_, err = store.GetSession(ctx, nonExistentDID, "nonexistent-session")
+
assert.Error(t, err, "Should return error for non-existent session")
+
assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound")
+
+
// Try to delete non-existent session
+
err = store.DeleteSession(ctx, nonExistentDID, "nonexistent-session")
+
assert.Error(t, err, "Should return error when deleting non-existent session")
+
assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound")
+
+
t.Logf("โœ… Session not found handling verified")
+
}
+
+
// TestOAuthE2E_MultipleSessionsPerUser tests that a user can have multiple active sessions
+
func TestOAuthE2E_MultipleSessionsPerUser(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth multiple sessions test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("๐Ÿ‘ฅ Testing multiple OAuth sessions per user...")
+
+
// Setup OAuth store
+
store := SetupOAuthTestStore(t, db)
+
+
// Create a test DID
+
did, err := syntax.ParseDID("did:plc:multisession123")
+
require.NoError(t, err)
+
+
// Create multiple sessions for the same user
+
sessions := []oauthlib.ClientSessionData{
+
{
+
AccountDID: did,
+
SessionID: "session-1-web",
+
HostURL: "http://localhost:3001",
+
AccessToken: "token-1",
+
Scopes: []string{"atproto"},
+
},
+
{
+
AccountDID: did,
+
SessionID: "session-2-mobile",
+
HostURL: "http://localhost:3001",
+
AccessToken: "token-2",
+
Scopes: []string{"atproto"},
+
},
+
{
+
AccountDID: did,
+
SessionID: "session-3-tablet",
+
HostURL: "http://localhost:3001",
+
AccessToken: "token-3",
+
Scopes: []string{"atproto"},
+
},
+
}
+
+
// Save all sessions
+
for i, session := range sessions {
+
err := store.SaveSession(ctx, session)
+
require.NoError(t, err, "Should be able to save session %d", i+1)
+
}
+
+
t.Logf("โœ… Created %d sessions for user", len(sessions))
+
+
// Verify all sessions can be retrieved independently
+
for i, session := range sessions {
+
retrieved, err := store.GetSession(ctx, did, session.SessionID)
+
require.NoError(t, err, "Should be able to retrieve session %d", i+1)
+
assert.Equal(t, session.SessionID, retrieved.SessionID, "Session ID should match")
+
assert.Equal(t, session.AccessToken, retrieved.AccessToken, "Access token should match")
+
}
+
+
t.Logf("โœ… All sessions retrieved independently")
+
+
// Delete one session and verify others remain
+
err = store.DeleteSession(ctx, did, sessions[0].SessionID)
+
require.NoError(t, err, "Should be able to delete first session")
+
+
// Verify first session is deleted
+
_, err = store.GetSession(ctx, did, sessions[0].SessionID)
+
assert.Equal(t, oauth.ErrSessionNotFound, err, "First session should be deleted")
+
+
// Verify other sessions still exist
+
for i := 1; i < len(sessions); i++ {
+
_, err := store.GetSession(ctx, did, sessions[i].SessionID)
+
require.NoError(t, err, "Session %d should still exist", i+1)
+
}
+
+
t.Logf("โœ… Multiple sessions per user verified")
+
+
// Cleanup
+
for i := 1; i < len(sessions); i++ {
+
_ = store.DeleteSession(ctx, did, sessions[i].SessionID)
+
}
+
}
+
+
// TestOAuthE2E_AuthRequestStorage tests OAuth auth request storage and retrieval
+
func TestOAuthE2E_AuthRequestStorage(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth auth request storage test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("๐Ÿ“ Testing OAuth auth request storage...")
+
+
// Setup OAuth store
+
store := SetupOAuthTestStore(t, db)
+
+
// Create test auth request data
+
did, err := syntax.ParseDID("did:plc:authrequest123")
+
require.NoError(t, err)
+
+
authRequest := oauthlib.AuthRequestData{
+
State: "test-state-12345",
+
AccountDID: &did,
+
PKCEVerifier: "test-pkce-verifier",
+
DPoPPrivateKeyMultibase: "test-dpop-key",
+
DPoPAuthServerNonce: "test-nonce",
+
AuthServerURL: "http://localhost:3001",
+
RequestURI: "http://localhost:3001/authorize",
+
AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
+
AuthServerRevocationEndpoint: "http://localhost:3001/oauth/revoke",
+
Scopes: []string{"atproto", "transition:generic"},
+
}
+
+
// Save auth request
+
err = store.SaveAuthRequestInfo(ctx, authRequest)
+
require.NoError(t, err, "Should be able to save auth request")
+
+
t.Logf("โœ… Auth request saved")
+
+
// Retrieve auth request
+
retrieved, err := store.GetAuthRequestInfo(ctx, authRequest.State)
+
require.NoError(t, err, "Should be able to retrieve auth request")
+
assert.Equal(t, authRequest.State, retrieved.State, "State should match")
+
assert.Equal(t, authRequest.PKCEVerifier, retrieved.PKCEVerifier, "PKCE verifier should match")
+
assert.Equal(t, authRequest.AuthServerURL, retrieved.AuthServerURL, "Auth server URL should match")
+
assert.Equal(t, len(authRequest.Scopes), len(retrieved.Scopes), "Scopes length should match")
+
+
t.Logf("โœ… Auth request retrieved and verified")
+
+
// Test duplicate state error
+
err = store.SaveAuthRequestInfo(ctx, authRequest)
+
assert.Error(t, err, "Should not allow duplicate state")
+
assert.Contains(t, err.Error(), "already exists", "Error should indicate duplicate")
+
+
t.Logf("โœ… Duplicate state prevention verified")
+
+
// Delete auth request
+
err = store.DeleteAuthRequestInfo(ctx, authRequest.State)
+
require.NoError(t, err, "Should be able to delete auth request")
+
+
// Verify deletion
+
_, err = store.GetAuthRequestInfo(ctx, authRequest.State)
+
assert.Equal(t, oauth.ErrAuthRequestNotFound, err, "Auth request should be deleted")
+
+
t.Logf("โœ… Auth request deletion verified")
+
+
// Test cleanup of expired auth requests
+
// Create an auth request and manually set created_at to the past
+
oldAuthRequest := oauthlib.AuthRequestData{
+
State: "old-state-12345",
+
PKCEVerifier: "old-verifier",
+
AuthServerURL: "http://localhost:3001",
+
Scopes: []string{"atproto"},
+
}
+
+
err = store.SaveAuthRequestInfo(ctx, oldAuthRequest)
+
require.NoError(t, err)
+
+
// Update created_at to 1 hour ago
+
_, err = db.ExecContext(ctx,
+
"UPDATE oauth_requests SET created_at = NOW() - INTERVAL '1 hour' WHERE state = $1",
+
oldAuthRequest.State)
+
require.NoError(t, err)
+
+
// Cleanup expired requests
+
cleaned, err := store.(*oauth.PostgresOAuthStore).CleanupExpiredAuthRequests(ctx)
+
require.NoError(t, err, "Cleanup should succeed")
+
assert.Greater(t, cleaned, int64(0), "Should have cleaned up at least one auth request")
+
+
t.Logf("โœ… Expired auth request cleanup verified (cleaned %d requests)", cleaned)
+
}
+
+
// TestOAuthE2E_TokenRefresh tests the refresh token flow
+
func TestOAuthE2E_TokenRefresh(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth token refresh test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("๐Ÿ”„ Testing OAuth token refresh flow...")
+
+
// Setup OAuth client and store
+
store := SetupOAuthTestStore(t, db)
+
client := SetupOAuthTestClient(t, store)
+
handler := oauth.NewOAuthHandler(client, store)
+
+
// Create a test DID and session
+
did, err := syntax.ParseDID("did:plc:refreshtest123")
+
require.NoError(t, err)
+
+
// Create initial session with refresh token
+
initialSession := oauthlib.ClientSessionData{
+
AccountDID: did,
+
SessionID: "refresh-session-1",
+
HostURL: "http://localhost:3001",
+
AuthServerURL: "http://localhost:3001",
+
AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
+
AuthServerRevocationEndpoint: "http://localhost:3001/oauth/revoke",
+
AccessToken: "initial-access-token",
+
RefreshToken: "initial-refresh-token",
+
DPoPPrivateKeyMultibase: "test-dpop-key",
+
DPoPAuthServerNonce: "test-nonce",
+
Scopes: []string{"atproto", "transition:generic"},
+
}
+
+
// Save the session
+
err = store.SaveSession(ctx, initialSession)
+
require.NoError(t, err, "Should save initial session")
+
+
t.Logf("โœ… Initial session created")
+
+
// Create a sealed token for this session
+
sealedToken, err := client.SealSession(did.String(), initialSession.SessionID, time.Hour)
+
require.NoError(t, err, "Should seal session token")
+
require.NotEmpty(t, sealedToken, "Sealed token should not be empty")
+
+
t.Logf("โœ… Session token sealed")
+
+
// Setup test server with refresh endpoint
+
r := chi.NewRouter()
+
r.Post("/oauth/refresh", handler.HandleRefresh)
+
+
server := httptest.NewServer(r)
+
defer server.Close()
+
+
t.Run("Valid refresh request", func(t *testing.T) {
+
// NOTE: This test verifies that the refresh endpoint can be called
+
// In a real scenario, the indigo client's RefreshTokens() would call the PDS
+
// Since we're in a component test, we're testing the Coves handler logic
+
+
// Create refresh request
+
refreshReq := map[string]interface{}{
+
"did": did.String(),
+
"session_id": initialSession.SessionID,
+
"sealed_token": sealedToken,
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
// NOTE: In component testing mode, the indigo client may not have
+
// real PDS credentials, so RefreshTokens() might fail
+
// We're testing that the handler correctly processes the request
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
// In component test mode without real PDS, we may get 401
+
// In production with real PDS, this would return 200 with new tokens
+
t.Logf("Refresh response status: %d", resp.StatusCode)
+
+
// The important thing is that the handler doesn't crash
+
// and properly validates the request structure
+
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,
+
"Refresh should return either success or auth failure, got %d", resp.StatusCode)
+
})
+
+
t.Run("Invalid DID format (with valid token)", func(t *testing.T) {
+
// Create a sealed token with an invalid DID format
+
invalidDID := "invalid-did-format"
+
// Create the token with a valid DID first, then we'll try to use it with invalid DID in request
+
validToken, err := client.SealSession(did.String(), initialSession.SessionID, 30*24*time.Hour)
+
require.NoError(t, err)
+
+
refreshReq := map[string]interface{}{
+
"did": invalidDID, // Invalid DID format in request
+
"session_id": initialSession.SessionID,
+
"sealed_token": validToken, // Valid token for different DID
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
// Should reject with 401 due to DID mismatch (not 400) since auth happens first
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"DID mismatch should be rejected with 401 (auth check happens before format validation)")
+
})
+
+
t.Run("Missing sealed_token (security test)", func(t *testing.T) {
+
refreshReq := map[string]interface{}{
+
"did": did.String(),
+
"session_id": initialSession.SessionID,
+
// Missing sealed_token - should be rejected for security
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"Missing sealed_token should be rejected (proof of possession required)")
+
})
+
+
t.Run("Invalid sealed_token", func(t *testing.T) {
+
refreshReq := map[string]interface{}{
+
"did": did.String(),
+
"session_id": initialSession.SessionID,
+
"sealed_token": "invalid-token-data",
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"Invalid sealed_token should be rejected")
+
})
+
+
t.Run("DID mismatch (security test)", func(t *testing.T) {
+
// Create a sealed token for a different DID
+
wrongDID := "did:plc:wronguser123"
+
wrongToken, err := client.SealSession(wrongDID, initialSession.SessionID, 30*24*time.Hour)
+
require.NoError(t, err)
+
+
// Try to use it to refresh the original session
+
refreshReq := map[string]interface{}{
+
"did": did.String(), // Claiming original DID
+
"session_id": initialSession.SessionID,
+
"sealed_token": wrongToken, // But token is for different DID
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"DID mismatch should be rejected (prevents session hijacking)")
+
})
+
+
t.Run("Session ID mismatch (security test)", func(t *testing.T) {
+
// Create a sealed token with wrong session ID
+
wrongSessionID := "wrong-session-id"
+
wrongToken, err := client.SealSession(did.String(), wrongSessionID, 30*24*time.Hour)
+
require.NoError(t, err)
+
+
// Try to use it to refresh the original session
+
refreshReq := map[string]interface{}{
+
"did": did.String(),
+
"session_id": initialSession.SessionID, // Claiming original session
+
"sealed_token": wrongToken, // But token is for different session
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"Session ID mismatch should be rejected (prevents session hijacking)")
+
})
+
+
t.Run("Non-existent session", func(t *testing.T) {
+
// Create a valid sealed token for a non-existent session
+
nonExistentSessionID := "nonexistent-session-id"
+
validToken, err := client.SealSession(did.String(), nonExistentSessionID, 30*24*time.Hour)
+
require.NoError(t, err)
+
+
refreshReq := map[string]interface{}{
+
"did": did.String(),
+
"session_id": nonExistentSessionID,
+
"sealed_token": validToken, // Valid token but session doesn't exist
+
}
+
+
reqBody, err := json.Marshal(refreshReq)
+
require.NoError(t, err)
+
+
req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+
require.NoError(t, err)
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
+
require.NoError(t, err)
+
defer func() { _ = resp.Body.Close() }()
+
+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
+
"Non-existent session should be rejected with 401")
+
})
+
+
t.Logf("โœ… Token refresh endpoint validation verified")
+
}
+
+
// TestOAuthE2E_SessionUpdate tests that refresh updates the session in database
+
func TestOAuthE2E_SessionUpdate(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth session update test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("๐Ÿ’พ Testing OAuth session update on refresh...")
+
+
// Setup OAuth store
+
store := SetupOAuthTestStore(t, db)
+
+
// Create a test session
+
did, err := syntax.ParseDID("did:plc:sessionupdate123")
+
require.NoError(t, err)
+
+
originalSession := oauthlib.ClientSessionData{
+
AccountDID: did,
+
SessionID: "update-session-1",
+
HostURL: "http://localhost:3001",
+
AuthServerURL: "http://localhost:3001",
+
AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
+
AccessToken: "original-access-token",
+
RefreshToken: "original-refresh-token",
+
DPoPPrivateKeyMultibase: "original-dpop-key",
+
Scopes: []string{"atproto"},
+
}
+
+
// Save original session
+
err = store.SaveSession(ctx, originalSession)
+
require.NoError(t, err)
+
+
t.Logf("โœ… Original session saved")
+
+
// Simulate a token refresh by updating the session with new tokens
+
updatedSession := originalSession
+
updatedSession.AccessToken = "new-access-token"
+
updatedSession.RefreshToken = "new-refresh-token"
+
updatedSession.DPoPAuthServerNonce = "new-nonce"
+
+
// Update the session (upsert)
+
err = store.SaveSession(ctx, updatedSession)
+
require.NoError(t, err)
+
+
t.Logf("โœ… Session updated with new tokens")
+
+
// Retrieve the session and verify it was updated
+
retrieved, err := store.GetSession(ctx, did, originalSession.SessionID)
+
require.NoError(t, err, "Should retrieve updated session")
+
+
assert.Equal(t, "new-access-token", retrieved.AccessToken,
+
"Access token should be updated")
+
assert.Equal(t, "new-refresh-token", retrieved.RefreshToken,
+
"Refresh token should be updated")
+
assert.Equal(t, "new-nonce", retrieved.DPoPAuthServerNonce,
+
"DPoP nonce should be updated")
+
+
// Verify session ID and DID remain the same
+
assert.Equal(t, originalSession.SessionID, retrieved.SessionID,
+
"Session ID should remain the same")
+
assert.Equal(t, did, retrieved.AccountDID,
+
"DID should remain the same")
+
+
t.Logf("โœ… Session update verified - tokens refreshed in database")
+
+
// Verify updated_at was changed
+
var updatedAt time.Time
+
err = db.QueryRowContext(ctx,
+
"SELECT updated_at FROM oauth_sessions WHERE did = $1 AND session_id = $2",
+
did.String(), originalSession.SessionID).Scan(&updatedAt)
+
require.NoError(t, err)
+
+
// Updated timestamp should be recent (within last minute)
+
assert.WithinDuration(t, time.Now(), updatedAt, time.Minute,
+
"Session updated_at should be recent")
+
+
t.Logf("โœ… Session timestamp update verified")
+
}
+
+
// TestOAuthE2E_RefreshTokenRotation tests refresh token rotation behavior
+
func TestOAuthE2E_RefreshTokenRotation(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth refresh token rotation test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
ctx := context.Background()
+
+
t.Log("๐Ÿ”„ Testing OAuth refresh token rotation...")
+
+
// Setup OAuth store
+
store := SetupOAuthTestStore(t, db)
+
+
// Create a test session
+
did, err := syntax.ParseDID("did:plc:rotation123")
+
require.NoError(t, err)
+
+
// Simulate multiple refresh cycles
+
sessionID := "rotation-session-1"
+
tokens := []struct {
+
access string
+
refresh string
+
}{
+
{"access-token-v1", "refresh-token-v1"},
+
{"access-token-v2", "refresh-token-v2"},
+
{"access-token-v3", "refresh-token-v3"},
+
}
+
+
for i, tokenPair := range tokens {
+
session := oauthlib.ClientSessionData{
+
AccountDID: did,
+
SessionID: sessionID,
+
HostURL: "http://localhost:3001",
+
AuthServerURL: "http://localhost:3001",
+
AuthServerTokenEndpoint: "http://localhost:3001/oauth/token",
+
AccessToken: tokenPair.access,
+
RefreshToken: tokenPair.refresh,
+
Scopes: []string{"atproto"},
+
}
+
+
// Save/update session
+
err = store.SaveSession(ctx, session)
+
require.NoError(t, err, "Should save session iteration %d", i+1)
+
+
// Retrieve and verify
+
retrieved, err := store.GetSession(ctx, did, sessionID)
+
require.NoError(t, err, "Should retrieve session iteration %d", i+1)
+
+
assert.Equal(t, tokenPair.access, retrieved.AccessToken,
+
"Access token should match iteration %d", i+1)
+
assert.Equal(t, tokenPair.refresh, retrieved.RefreshToken,
+
"Refresh token should match iteration %d", i+1)
+
+
// Small delay to ensure timestamp differences
+
time.Sleep(10 * time.Millisecond)
+
}
+
+
t.Logf("โœ… Refresh token rotation verified through %d cycles", len(tokens))
+
+
// Verify final state
+
finalSession, err := store.GetSession(ctx, did, sessionID)
+
require.NoError(t, err)
+
+
assert.Equal(t, "access-token-v3", finalSession.AccessToken,
+
"Final access token should be from last rotation")
+
assert.Equal(t, "refresh-token-v3", finalSession.RefreshToken,
+
"Final refresh token should be from last rotation")
+
+
t.Logf("โœ… Token rotation state verified")
+
}
+312
tests/integration/oauth_session_fixation_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/oauth"
+
"context"
+
"crypto/sha256"
+
"encoding/base64"
+
"net/http"
+
"net/http/httptest"
+
"net/url"
+
"testing"
+
"time"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/go-chi/chi/v5"
+
"github.com/pressly/goose/v3"
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// TestOAuth_SessionFixationAttackPrevention tests that the mobile redirect binding
+
// prevents session fixation attacks where an attacker plants a mobile_redirect_uri
+
// cookie, then the user does a web login, and credentials get sent to attacker's deep link.
+
//
+
// Attack scenario:
+
// 1. Attacker tricks user into visiting /oauth/mobile/login?redirect_uri=evil://steal
+
// 2. This plants a mobile_redirect_uri cookie (lives 10 minutes)
+
// 3. User later does normal web OAuth login via /oauth/login
+
// 4. HandleCallback sees the stale mobile_redirect_uri cookie
+
// 5. WITHOUT THE FIX: Callback sends sealed token, DID, session_id to attacker's deep link
+
// 6. WITH THE FIX: Binding mismatch is detected, mobile cookies cleared, user gets web session
+
func TestOAuth_SessionFixationAttackPrevention(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping OAuth session fixation test in short mode")
+
}
+
+
// Setup test database
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
// Run migrations
+
require.NoError(t, goose.SetDialect("postgres"))
+
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
+
+
// Setup OAuth client and store
+
store := SetupOAuthTestStore(t, db)
+
client := SetupOAuthTestClient(t, store)
+
require.NotNil(t, client, "OAuth client should be initialized")
+
+
// Setup handler
+
handler := oauth.NewOAuthHandler(client, store)
+
+
// Setup router
+
r := chi.NewRouter()
+
r.Get("/oauth/callback", handler.HandleCallback)
+
+
t.Run("attack scenario - planted mobile cookie without binding", func(t *testing.T) {
+
ctx := context.Background()
+
+
// Step 1: Simulate a successful OAuth callback (like a user did web login)
+
// We'll create a mock session to simulate what ProcessCallback would return
+
testDID := "did:plc:test123456"
+
parsedDID, err := syntax.ParseDID(testDID)
+
require.NoError(t, err)
+
+
sessionID := "test-session-" + time.Now().Format("20060102150405")
+
testSession := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: sessionID,
+
HostURL: "http://localhost:3001",
+
AccessToken: "test-access-token",
+
Scopes: []string{"atproto"},
+
}
+
+
// Save the session (simulating successful OAuth flow)
+
err = store.SaveSession(ctx, testSession)
+
require.NoError(t, err)
+
+
// Step 2: Attacker planted a mobile_redirect_uri cookie (without binding)
+
// This simulates the cookie being planted earlier by attacker
+
attackerRedirectURI := "evil://steal"
+
req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)
+
+
// Plant the attacker's cookie (URL escaped as it would be in real scenario)
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_uri",
+
Value: url.QueryEscape(attackerRedirectURI),
+
Path: "/oauth",
+
})
+
// NOTE: No mobile_redirect_binding cookie! This is the attack scenario.
+
+
rec := httptest.NewRecorder()
+
+
// Step 3: Try to process the callback
+
// This would fail because ProcessCallback needs real OAuth code/state
+
// For this test, we're verifying the handler's security checks work
+
// even before ProcessCallback is called
+
+
// The handler will try to call ProcessCallback which will fail
+
// But we're testing that even if it succeeded, the mobile redirect
+
// validation would prevent the attack
+
handler.HandleCallback(rec, req)
+
+
// Step 4: Verify the attack was prevented
+
// The handler should reject the request due to missing binding
+
// Since ProcessCallback will fail first (no real OAuth code), we expect
+
// a 400 error, but the important thing is it doesn't redirect to evil://steal
+
+
assert.NotEqual(t, http.StatusFound, rec.Code,
+
"Should not redirect when ProcessCallback fails")
+
assert.NotContains(t, rec.Header().Get("Location"), "evil://",
+
"Should never redirect to attacker's URI")
+
})
+
+
t.Run("legitimate mobile flow - with valid binding", func(t *testing.T) {
+
ctx := context.Background()
+
+
// Setup a legitimate mobile session
+
testDID := "did:plc:mobile123"
+
parsedDID, err := syntax.ParseDID(testDID)
+
require.NoError(t, err)
+
+
sessionID := "mobile-session-" + time.Now().Format("20060102150405")
+
testSession := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: sessionID,
+
HostURL: "http://localhost:3001",
+
AccessToken: "mobile-access-token",
+
Scopes: []string{"atproto"},
+
}
+
+
// Save the session
+
err = store.SaveSession(ctx, testSession)
+
require.NoError(t, err)
+
+
// Create request with BOTH mobile_redirect_uri AND valid binding
+
// Use Universal Link URI that's in the allowlist
+
legitRedirectURI := "https://coves.social/app/oauth/callback"
+
csrfToken := "valid-csrf-token-for-mobile"
+
req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)
+
+
// Add mobile redirect URI cookie
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_uri",
+
Value: url.QueryEscape(legitRedirectURI),
+
Path: "/oauth",
+
})
+
+
// Add CSRF token (required for mobile flow)
+
req.AddCookie(&http.Cookie{
+
Name: "oauth_csrf",
+
Value: csrfToken,
+
Path: "/oauth",
+
})
+
+
// Add VALID binding cookie (this is what prevents the attack)
+
// In real flow, this would be set by HandleMobileLogin
+
// The binding now includes the CSRF token for double-submit validation
+
mobileBinding := generateMobileRedirectBindingForTest(csrfToken, legitRedirectURI)
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_binding",
+
Value: mobileBinding,
+
Path: "/oauth",
+
})
+
+
rec := httptest.NewRecorder()
+
handler.HandleCallback(rec, req)
+
+
// This will also fail at ProcessCallback (no real OAuth code)
+
// but we're verifying the binding validation logic is in place
+
// In a real integration test with PDS, this would succeed
+
assert.NotEqual(t, http.StatusFound, rec.Code,
+
"Should not redirect when ProcessCallback fails (expected in mock test)")
+
})
+
+
t.Run("binding mismatch - attacker tries wrong binding", func(t *testing.T) {
+
ctx := context.Background()
+
+
// Setup session
+
testDID := "did:plc:bindingtest"
+
parsedDID, err := syntax.ParseDID(testDID)
+
require.NoError(t, err)
+
+
sessionID := "binding-test-" + time.Now().Format("20060102150405")
+
testSession := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: sessionID,
+
HostURL: "http://localhost:3001",
+
AccessToken: "binding-test-token",
+
Scopes: []string{"atproto"},
+
}
+
+
err = store.SaveSession(ctx, testSession)
+
require.NoError(t, err)
+
+
// Attacker tries to plant evil redirect with a binding from different URI
+
attackerRedirectURI := "evil://steal"
+
attackerCSRF := "attacker-csrf-token"
+
req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)
+
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_uri",
+
Value: url.QueryEscape(attackerRedirectURI),
+
Path: "/oauth",
+
})
+
+
req.AddCookie(&http.Cookie{
+
Name: "oauth_csrf",
+
Value: attackerCSRF,
+
Path: "/oauth",
+
})
+
+
// Use binding from a DIFFERENT CSRF token and URI (attacker's attempt to forge)
+
// Even if attacker knows the redirect URI, they don't know the user's CSRF token
+
wrongBinding := generateMobileRedirectBindingForTest("different-csrf", "https://coves.social/app/oauth/callback")
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_binding",
+
Value: wrongBinding,
+
Path: "/oauth",
+
})
+
+
rec := httptest.NewRecorder()
+
handler.HandleCallback(rec, req)
+
+
// Should fail due to binding mismatch (even before ProcessCallback)
+
// The binding validation happens after ProcessCallback in the real code,
+
// but the mismatch would be caught and cookies cleared
+
assert.NotContains(t, rec.Header().Get("Location"), "evil://",
+
"Should never redirect to attacker's URI on binding mismatch")
+
})
+
+
t.Run("CSRF token value mismatch - attacker tries different CSRF", func(t *testing.T) {
+
ctx := context.Background()
+
+
// Setup session
+
testDID := "did:plc:csrftest"
+
parsedDID, err := syntax.ParseDID(testDID)
+
require.NoError(t, err)
+
+
sessionID := "csrf-test-" + time.Now().Format("20060102150405")
+
testSession := oauthlib.ClientSessionData{
+
AccountDID: parsedDID,
+
SessionID: sessionID,
+
HostURL: "http://localhost:3001",
+
AccessToken: "csrf-test-token",
+
Scopes: []string{"atproto"},
+
}
+
+
err = store.SaveSession(ctx, testSession)
+
require.NoError(t, err)
+
+
// This tests the P1 security fix: CSRF token VALUE must be validated, not just presence
+
// Attack scenario:
+
// 1. User starts mobile login with CSRF token A and redirect URI X
+
// 2. Binding = hash(A + X) is stored in cookie
+
// 3. Attacker somehow gets user to have CSRF token B in cookie (different from A)
+
// 4. Callback receives CSRF token B, redirect URI X, binding = hash(A + X)
+
// 5. hash(B + X) != hash(A + X), so attack is detected
+
+
originalCSRF := "original-csrf-token-set-at-login"
+
redirectURI := "https://coves.social/app/oauth/callback"
+
// Binding was created with original CSRF token
+
originalBinding := generateMobileRedirectBindingForTest(originalCSRF, redirectURI)
+
+
// But attacker managed to change the CSRF cookie
+
attackerCSRF := "attacker-replaced-csrf"
+
+
req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)
+
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_uri",
+
Value: url.QueryEscape(redirectURI),
+
Path: "/oauth",
+
})
+
+
// Attacker's CSRF token (different from what created the binding)
+
req.AddCookie(&http.Cookie{
+
Name: "oauth_csrf",
+
Value: attackerCSRF,
+
Path: "/oauth",
+
})
+
+
// Original binding (created with original CSRF token)
+
req.AddCookie(&http.Cookie{
+
Name: "mobile_redirect_binding",
+
Value: originalBinding,
+
Path: "/oauth",
+
})
+
+
rec := httptest.NewRecorder()
+
handler.HandleCallback(rec, req)
+
+
// Should fail because hash(attackerCSRF + redirectURI) != hash(originalCSRF + redirectURI)
+
// This is the key security fix - CSRF token VALUE is now validated
+
assert.NotEqual(t, http.StatusFound, rec.Code,
+
"Should not redirect when CSRF token doesn't match binding")
+
})
+
}
+
+
// generateMobileRedirectBindingForTest generates a binding for testing
+
// This mirrors the actual logic in handlers_security.go:
+
// binding = base64(sha256(csrfToken + "|" + redirectURI)[:16])
+
func generateMobileRedirectBindingForTest(csrfToken, mobileRedirectURI string) string {
+
combined := csrfToken + "|" + mobileRedirectURI
+
hash := sha256.Sum256([]byte(combined))
+
return base64.URLEncoding.EncodeToString(hash[:16])
+
}
+169
tests/integration/oauth_token_verification_test.go
···
+
package integration
+
+
import (
+
"Coves/internal/api/middleware"
+
"fmt"
+
"net/http"
+
"net/http/httptest"
+
"os"
+
"testing"
+
"time"
+
)
+
+
// TestOAuthTokenVerification tests end-to-end OAuth token verification
+
// with real PDS-issued OAuth tokens. This replaces the old JWT verification test
+
// since we now use OAuth sealed session tokens instead of raw JWTs.
+
//
+
// Flow:
+
// 1. Create account on local PDS (or use existing)
+
// 2. Authenticate to get OAuth tokens and create sealed session token
+
// 3. Verify our auth middleware can unseal and validate the token
+
// 4. Test token validation and session retrieval
+
//
+
// NOTE: This test uses the E2E OAuth middleware which mocks the session unsealing
+
// for testing purposes. Real OAuth tokens from PDS would be sealed using the
+
// OAuth client's seal secret.
+
func TestOAuthTokenVerification(t *testing.T) {
+
// Skip in short mode since this requires real PDS
+
if testing.Short() {
+
t.Skip("Skipping OAuth token verification test in short mode")
+
}
+
+
pdsURL := os.Getenv("PDS_URL")
+
if pdsURL == "" {
+
pdsURL = "http://localhost:3001"
+
}
+
+
// Check if PDS is running
+
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
+
if err != nil {
+
t.Skipf("PDS not running at %s: %v", pdsURL, err)
+
}
+
_ = healthResp.Body.Close()
+
+
t.Run("OAuth token validation and middleware integration", func(t *testing.T) {
+
// Step 1: Create a test account on PDS
+
// Keep handle short to avoid PDS validation errors
+
timestamp := time.Now().Unix() % 100000 // Last 5 digits
+
handle := fmt.Sprintf("oauth%d.local.coves.dev", timestamp)
+
password := "testpass123"
+
email := fmt.Sprintf("oauth%d@test.com", timestamp)
+
+
_, did, err := createPDSAccount(pdsURL, handle, email, password)
+
if err != nil {
+
t.Fatalf("Failed to create PDS account: %v", err)
+
}
+
t.Logf("โœ“ Created test account: %s (DID: %s)", handle, did)
+
+
// Step 2: Create OAuth middleware with mock unsealer for testing
+
// In production, this would unseal real OAuth tokens from PDS
+
t.Log("Testing OAuth middleware with sealed session tokens...")
+
+
e2eAuth := NewE2EOAuthMiddleware()
+
testToken := e2eAuth.AddUser(did)
+
+
handlerCalled := false
+
var extractedDID string
+
+
testHandler := e2eAuth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
extractedDID = middleware.GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte(`{"success": true}`))
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+testToken)
+
w := httptest.NewRecorder()
+
+
testHandler.ServeHTTP(w, req)
+
+
if !handlerCalled {
+
t.Errorf("Handler was not called - auth middleware rejected valid token")
+
t.Logf("Response status: %d", w.Code)
+
t.Logf("Response body: %s", w.Body.String())
+
}
+
+
if w.Code != http.StatusOK {
+
t.Errorf("Expected status 200, got %d", w.Code)
+
t.Logf("Response body: %s", w.Body.String())
+
}
+
+
if extractedDID != did {
+
t.Errorf("Middleware extracted wrong DID: expected %s, got %s", did, extractedDID)
+
}
+
+
t.Logf("โœ… OAuth middleware with token validation working correctly!")
+
t.Logf(" Handler called: %v", handlerCalled)
+
t.Logf(" Extracted DID: %s", extractedDID)
+
t.Logf(" Response status: %d", w.Code)
+
})
+
+
t.Run("Rejects tampered/invalid sealed tokens", func(t *testing.T) {
+
// Create valid user
+
timestamp := time.Now().Unix() % 100000
+
handle := fmt.Sprintf("tamp%d.local.coves.dev", timestamp)
+
password := "testpass456"
+
email := fmt.Sprintf("tamp%d@test.com", timestamp)
+
+
_, did, err := createPDSAccount(pdsURL, handle, email, password)
+
if err != nil {
+
t.Fatalf("Failed to create PDS account: %v", err)
+
}
+
+
// Create OAuth middleware
+
e2eAuth := NewE2EOAuthMiddleware()
+
validToken := e2eAuth.AddUser(did)
+
+
// Create various invalid tokens to test
+
testCases := []struct {
+
name string
+
token string
+
}{
+
{"Empty token", ""},
+
{"Invalid base64", "not-valid-base64!!!"},
+
{"Tampered token", "dGFtcGVyZWQtdG9rZW4tZGF0YQ=="}, // Valid base64 but not a real sealed session
+
{"Short token", "abc"},
+
{"Modified valid token", validToken + "extra"},
+
}
+
+
for _, tc := range testCases {
+
t.Run(tc.name, func(t *testing.T) {
+
handlerCalled := false
+
testHandler := e2eAuth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
if tc.token != "" {
+
req.Header.Set("Authorization", "Bearer "+tc.token)
+
}
+
w := httptest.NewRecorder()
+
+
testHandler.ServeHTTP(w, req)
+
+
if handlerCalled {
+
t.Error("Handler was called for invalid token - should have been rejected")
+
}
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401 for invalid token, got %d", w.Code)
+
}
+
+
t.Logf("โœ“ Middleware correctly rejected %s with status %d", tc.name, w.Code)
+
})
+
}
+
+
t.Logf("โœ… All invalid token types correctly rejected")
+
})
+
+
t.Run("Session expiration handling", func(t *testing.T) {
+
// OAuth session expiration is handled at the database level
+
// See TestOAuthE2E_TokenExpiration in oauth_e2e_test.go for full expiration testing
+
t.Log("โ„น๏ธ Session expiration testing is covered in oauth_e2e_test.go")
+
t.Log(" OAuth sessions expire based on database timestamps and are cleaned up periodically")
+
t.Log(" This is different from JWT expiration which was timestamp-based in the token itself")
+
t.Skip("Session expiration is tested in oauth_e2e_test.go - see TestOAuthE2E_TokenExpiration")
+
})
+
}
+16 -20
tests/integration/community_e2e_test.go
···
package integration
import (
-
"Coves/internal/api/middleware"
"Coves/internal/api/routes"
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
···
t.Logf("โœ… Authenticated - Instance DID: %s", instanceDID)
-
// Initialize auth middleware with skipVerify=true
-
// IMPORTANT: PDS password authentication returns Bearer tokens (not DPoP-bound tokens).
-
// E2E tests use these Bearer tokens with the DPoP scheme header, which only works
-
// because skipVerify=true bypasses signature and DPoP binding verification.
-
// In production, skipVerify=false requires proper DPoP-bound tokens from OAuth flow.
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
-
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
+
// Initialize OAuth auth middleware for E2E testing
+
e2eAuth := NewE2EOAuthMiddleware()
+
// Register the instance user for OAuth authentication
+
token := e2eAuth.AddUser(instanceDID)
// V2.0: Extract instance domain for community provisioning
var instanceDomain string
···
// Setup HTTP server with XRPC routes
r := chi.NewRouter()
-
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators
+
routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators
httpServer := httptest.NewServer(r)
defer httpServer.Close()
···
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
-
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "DPoP "+accessToken)
+
// Use OAuth token for Coves API authentication
+
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
-
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "DPoP "+accessToken)
+
// Use OAuth token for Coves API authentication
+
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create request: %v", err)
req.Header.Set("Content-Type", "application/json")
-
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "DPoP "+accessToken)
+
// Use OAuth token for Coves API authentication
+
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create block request: %v", err)
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "DPoP "+accessToken)
+
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create block request: %v", err)
blockHttpReq.Header.Set("Content-Type", "application/json")
-
blockHttpReq.Header.Set("Authorization", "DPoP "+accessToken)
+
blockHttpReq.Header.Set("Authorization", "Bearer "+token)
blockResp, err := http.DefaultClient.Do(blockHttpReq)
if err != nil {
···
t.Fatalf("Failed to create unblock request: %v", err)
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "DPoP "+accessToken)
+
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
···
t.Fatalf("Failed to create request: %v", err)
req.Header.Set("Content-Type", "application/json")
-
// Use real PDS access token for E2E authentication
-
req.Header.Set("Authorization", "DPoP "+accessToken)
+
// Use OAuth token for Coves API authentication
+
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
+7 -9
tests/integration/post_e2e_test.go
···
import (
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
···
postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, pdsURL) // nil aggregatorService, blobService, unfurlService for user-only tests
-
// Setup auth middleware (skip JWT verification for testing)
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
-
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
+
// Setup OAuth auth middleware for E2E testing
+
e2eAuth := NewE2EOAuthMiddleware()
// Setup HTTP handler
createHandler := post.NewCreateHandler(postService)
···
req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
req.Header.Set("Content-Type", "application/json")
-
// Create a simple JWT for testing (Phase 1: no signature verification)
-
// In production, this would be a real OAuth token from PDS
-
testJWT := createSimpleTestJWT(author.DID)
-
req.Header.Set("Authorization", "DPoP "+testJWT)
+
// Register the author user with OAuth middleware and get test token
+
// For Coves API handlers, use Bearer scheme with OAuth middleware
+
token := e2eAuth.AddUser(author.DID)
+
req.Header.Set("Authorization", "Bearer "+token)
// Execute request through auth middleware + handler
rr := httptest.NewRecorder()
-
handler := authMiddleware.RequireAuth(http.HandlerFunc(createHandler.HandleCreate))
+
handler := e2eAuth.RequireAuth(http.HandlerFunc(createHandler.HandleCreate))
handler.ServeHTTP(rr, req)
// Check response
+22 -19
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"
···
"testing"
"time"
-
timelineCore "Coves/internal/core/timeline"
-
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+
timelineCore "Coves/internal/core/timeline"
)
// TestFullUserJourney_E2E tests the complete user experience from signup to interaction:
···
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
// Setup HTTP server with all routes
-
// IMPORTANT: skipVerify=true because PDS password auth returns Bearer tokens (not DPoP-bound).
-
// E2E tests use Bearer tokens with DPoP scheme header, which only works with skipVerify=true.
-
// In production, skipVerify=false requires proper DPoP-bound tokens from OAuth flow.
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true)
-
defer authMiddleware.Stop() // Clean up DPoP replay cache goroutine
+
// Setup HTTP server with all routes using OAuth middleware
+
e2eAuth := NewE2EOAuthMiddleware()
r := chi.NewRouter()
-
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators
-
routes.RegisterPostRoutes(r, postService, authMiddleware)
-
routes.RegisterTimelineRoutes(r, timelineService, authMiddleware)
+
routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators
+
routes.RegisterPostRoutes(r, postService, e2eAuth.OAuthAuthMiddleware)
+
routes.RegisterTimelineRoutes(r, timelineService, e2eAuth.OAuthAuthMiddleware)
httpServer := httptest.NewServer(r)
defer httpServer.Close()
···
var (
userAHandle string
userADID string
-
userAToken string
+
userAToken string // PDS access token for direct PDS requests
+
userAAPIToken string // Coves API token for Coves API requests
userBHandle string
userBDID string
-
userBToken string
+
userBToken string // PDS access token for direct PDS requests
+
userBAPIToken string // Coves API token for Coves API requests
communityDID string
communityHandle string
postURI string
···
userA := createTestUser(t, db, userAHandle, userADID)
require.NotNil(t, userA)
+
// Register user with OAuth middleware for Coves API requests
+
userAAPIToken = e2eAuth.AddUser(userADID)
+
t.Logf("โœ… User A indexed in AppView")
})
···
httpServer.URL+"/xrpc/social.coves.community.create",
bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "DPoP "+userAToken)
+
req.Header.Set("Authorization", "Bearer "+userAAPIToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
···
httpServer.URL+"/xrpc/social.coves.community.post.create",
bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "DPoP "+userAToken)
+
req.Header.Set("Authorization", "Bearer "+userAAPIToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
···
userB := createTestUser(t, db, userBHandle, userBDID)
require.NotNil(t, userB)
+
// Register user with OAuth middleware for Coves API requests
+
userBAPIToken = e2eAuth.AddUser(userBDID)
+
t.Logf("โœ… User B indexed in AppView")
})
···
httpServer.URL+"/xrpc/social.coves.community.subscribe",
bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "DPoP "+userBToken)
+
req.Header.Set("Authorization", "Bearer "+userBAPIToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
···
t.Run("9. User B - Verify Timeline Feed Shows Subscribed Community Posts", func(t *testing.T) {
t.Log("\n๐Ÿ“ฐ Part 9: User B checks timeline feed...")
-
// Use HTTP client to properly go through auth middleware with DPoP token
+
// Use HTTP client to properly go through auth middleware with Bearer token
req, _ := http.NewRequest(http.MethodGet,
httpServer.URL+"/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)
-
req.Header.Set("Authorization", "DPoP "+userBToken)
+
req.Header.Set("Authorization", "Bearer "+userBAPIToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
+2
go.mod
···
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-chi/cors v1.2.2 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
+
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
+5
go.sum
···
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
+
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
···
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+5
.env.dev
···
# Also supports base64: prefix for consistency
OAUTH_COOKIE_SECRET=f1132c01b1a625a865c6c455a75ee793572cedb059cebe0c4c1ae4c446598f7d
+
# Seal secret for OAuth session tokens (AES-256-GCM encryption)
+
# Generate with: openssl rand -base64 32
+
# This must be 32 bytes when base64-decoded for AES-256
+
# OAUTH_SEAL_SECRET=ryW6xNVxYhP6hCDA90NGCmK58Q2ONnkYXbHL0oZN2no=
+
# AppView public URL (used for OAuth callback and client metadata)
# Dev: http://127.0.0.1:8081 (use 127.0.0.1 instead of localhost per RFC 8252)
# Prod: https://coves.social
-73
cmd/genjwks/main.go
···
-
package main
-
-
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"crypto/rand"
-
"encoding/json"
-
"fmt"
-
"log"
-
"os"
-
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
)
-
-
// genjwks generates an ES256 keypair for OAuth client authentication
-
// The private key is stored in the config/env, public key is served at /oauth/jwks.json
-
//
-
// Usage:
-
//
-
// go run cmd/genjwks/main.go
-
//
-
// This will output a JSON private key that should be stored in OAUTH_PRIVATE_JWK
-
func main() {
-
fmt.Println("Generating ES256 keypair for OAuth client authentication...")
-
-
// Generate ES256 (NIST P-256) private key
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-
if err != nil {
-
log.Fatalf("Failed to generate private key: %v", err)
-
}
-
-
// Convert to JWK
-
jwkKey, err := jwk.FromRaw(privateKey)
-
if err != nil {
-
log.Fatalf("Failed to create JWK from private key: %v", err)
-
}
-
-
// Set key parameters
-
if err = jwkKey.Set(jwk.KeyIDKey, "oauth-client-key"); err != nil {
-
log.Fatalf("Failed to set kid: %v", err)
-
}
-
if err = jwkKey.Set(jwk.AlgorithmKey, "ES256"); err != nil {
-
log.Fatalf("Failed to set alg: %v", err)
-
}
-
if err = jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
-
log.Fatalf("Failed to set use: %v", err)
-
}
-
-
// Marshal to JSON
-
jsonData, err := json.MarshalIndent(jwkKey, "", " ")
-
if err != nil {
-
log.Fatalf("Failed to marshal JWK: %v", err)
-
}
-
-
// Output instructions
-
fmt.Println("\nโœ… ES256 keypair generated successfully!")
-
fmt.Println("\n๐Ÿ“ Add this to your .env.dev file:")
-
fmt.Println("\nOAUTH_PRIVATE_JWK='" + string(jsonData) + "'")
-
fmt.Println("\nโš ๏ธ IMPORTANT:")
-
fmt.Println(" - Keep this private key SECRET")
-
fmt.Println(" - Never commit it to version control")
-
fmt.Println(" - Generate a new key for production")
-
fmt.Println(" - The public key will be automatically derived and served at /oauth/jwks.json")
-
-
// Optionally write to a file (not committed)
-
if len(os.Args) > 1 && os.Args[1] == "--save" {
-
filename := "oauth-private-key.json"
-
if err := os.WriteFile(filename, jsonData, 0o600); err != nil {
-
log.Fatalf("Failed to write key file: %v", err)
-
}
-
fmt.Printf("\n๐Ÿ’พ Private key saved to %s (remember to add to .gitignore!)\n", filename)
-
}
-
}
-122
internal/atproto/auth/did_key_fetcher.go
···
-
package auth
-
-
import (
-
"context"
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"encoding/base64"
-
"fmt"
-
"math/big"
-
"strings"
-
-
indigoCrypto "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:
-
// - indigoCrypto.PublicKey for secp256k1 (ES256K) keys - use indigo for verification
-
// - *ecdsa.PublicKey for NIST curves (P-256, P-384, P-521) - compatible with golang-jwt
-
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 check curve type
-
jwk, err := pubKey.JWK()
-
if err != nil {
-
return nil, fmt.Errorf("failed to convert public key to JWK: %w", err)
-
}
-
-
// For secp256k1 (ES256K), return indigo's PublicKey directly
-
// since Go's crypto/ecdsa doesn't support this curve
-
if jwk.Curve == "secp256k1" {
-
return pubKey, nil
-
}
-
-
// For NIST curves, convert to Go's ecdsa.PublicKey for golang-jwt compatibility
-
return atcryptoJWKToECDSA(jwk)
-
}
-
-
// atcryptoJWKToECDSA converts an indigoCrypto.JWK to a Go ecdsa.PublicKey.
-
// Note: secp256k1 is handled separately in FetchPublicKey by returning indigo's PublicKey directly.
-
func atcryptoJWKToECDSA(jwk *indigoCrypto.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()
-
default:
-
// secp256k1 should be handled before calling this function
-
return nil, fmt.Errorf("unsupported JWK curve for Go ecdsa: %s (secp256k1 uses indigo)", 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
-
}
-1308
internal/atproto/auth/dpop_test.go
···
-
package auth
-
-
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"crypto/rand"
-
"crypto/sha256"
-
"encoding/base64"
-
"encoding/json"
-
"strings"
-
"testing"
-
"time"
-
-
indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto"
-
"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)
-
}
-
-
// === ES256K (secp256k1) Test Helpers ===
-
-
// testES256KKey holds a test ES256K key pair using indigo
-
type testES256KKey struct {
-
privateKey indigoCrypto.PrivateKey
-
publicKey indigoCrypto.PublicKey
-
jwk map[string]interface{}
-
thumbprint string
-
}
-
-
// generateTestES256KKey generates a test ES256K (secp256k1) key pair and JWK
-
func generateTestES256KKey(t *testing.T) *testES256KKey {
-
t.Helper()
-
-
privateKey, err := indigoCrypto.GeneratePrivateKeyK256()
-
if err != nil {
-
t.Fatalf("Failed to generate ES256K test key: %v", err)
-
}
-
-
publicKey, err := privateKey.PublicKey()
-
if err != nil {
-
t.Fatalf("Failed to get public key from ES256K private key: %v", err)
-
}
-
-
// Get the JWK representation
-
jwkStruct, err := publicKey.JWK()
-
if err != nil {
-
t.Fatalf("Failed to get JWK from ES256K public key: %v", err)
-
}
-
jwk := map[string]interface{}{
-
"kty": jwkStruct.KeyType,
-
"crv": jwkStruct.Curve,
-
"x": jwkStruct.X,
-
"y": jwkStruct.Y,
-
}
-
-
// Calculate thumbprint
-
thumbprint, err := CalculateJWKThumbprint(jwk)
-
if err != nil {
-
t.Fatalf("Failed to calculate ES256K thumbprint: %v", err)
-
}
-
-
return &testES256KKey{
-
privateKey: privateKey,
-
publicKey: publicKey,
-
jwk: jwk,
-
thumbprint: thumbprint,
-
}
-
}
-
-
// createES256KDPoPProof creates a DPoP proof JWT using ES256K for testing
-
func createES256KDPoPProof(t *testing.T, key *testES256KKey, method, uri string, iat time.Time, jti string) string {
-
t.Helper()
-
-
// Build claims
-
claims := map[string]interface{}{
-
"jti": jti,
-
"iat": iat.Unix(),
-
"htm": method,
-
"htu": uri,
-
}
-
-
// Build header
-
header := map[string]interface{}{
-
"typ": "dpop+jwt",
-
"alg": "ES256K",
-
"jwk": key.jwk,
-
}
-
-
// Encode header and claims
-
headerJSON, err := json.Marshal(header)
-
if err != nil {
-
t.Fatalf("Failed to marshal header: %v", err)
-
}
-
claimsJSON, err := json.Marshal(claims)
-
if err != nil {
-
t.Fatalf("Failed to marshal claims: %v", err)
-
}
-
-
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
-
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
-
-
// Sign with indigo
-
signingInput := headerB64 + "." + claimsB64
-
signature, err := key.privateKey.HashAndSign([]byte(signingInput))
-
if err != nil {
-
t.Fatalf("Failed to sign ES256K proof: %v", err)
-
}
-
-
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
-
return signingInput + "." + signatureB64
-
}
-
-
// === ES256K Tests ===
-
-
func TestVerifyDPoPProof_ES256K_Valid(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256KKey(t)
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now()
-
jti := uuid.New().String()
-
-
proof := createES256KDPoPProof(t, key, method, uri, iat, jti)
-
-
result, err := verifier.VerifyDPoPProof(proof, method, uri)
-
if err != nil {
-
t.Fatalf("VerifyDPoPProof failed for valid ES256K 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.Thumbprint != key.thumbprint {
-
t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint)
-
}
-
}
-
-
func TestVerifyDPoPProof_ES256K_InvalidSignature(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256KKey(t)
-
wrongKey := generateTestES256KKey(t)
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now()
-
jti := uuid.New().String()
-
-
// Create proof with one key
-
proof := createES256KDPoPProof(t, key, method, uri, iat, jti)
-
-
// Tamper by replacing JWK with wrong key
-
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 ES256K signature, got nil")
-
}
-
if err != nil && !contains(err.Error(), "signature verification failed") {
-
t.Errorf("Expected signature verification error, got: %v", err)
-
}
-
}
-
-
func TestCalculateJWKThumbprint_ES256K(t *testing.T) {
-
// Test thumbprint calculation for secp256k1 keys
-
key := generateTestES256KKey(t)
-
-
thumbprint, err := CalculateJWKThumbprint(key.jwk)
-
if err != nil {
-
t.Fatalf("CalculateJWKThumbprint failed for ES256K: %v", err)
-
}
-
-
if thumbprint == "" {
-
t.Error("Expected non-empty thumbprint for ES256K key")
-
}
-
-
// Verify it's valid base64url
-
_, err = base64.RawURLEncoding.DecodeString(thumbprint)
-
if err != nil {
-
t.Errorf("ES256K thumbprint is not valid base64url: %v", err)
-
}
-
-
// Verify length (SHA-256 produces 32 bytes = 43 base64url chars)
-
if len(thumbprint) != 43 {
-
t.Errorf("Expected ES256K thumbprint length 43, got %d", len(thumbprint))
-
}
-
}
-
-
// === Algorithm-Curve Binding Tests ===
-
-
func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256KWithP256Key(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256Key(t) // P-256 key
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now()
-
jti := uuid.New().String()
-
-
// Create a proof claiming ES256K but using P-256 key
-
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["alg"] = "ES256K" // Claim ES256K
-
token.Header["jwk"] = key.jwk // But use P-256 key
-
-
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 ES256K algorithm with P-256 curve, got nil")
-
}
-
if err != nil && !contains(err.Error(), "requires curve secp256k1") {
-
t.Errorf("Expected curve mismatch error, got: %v", err)
-
}
-
}
-
-
func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256WithSecp256k1Key(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256KKey(t) // secp256k1 key
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now()
-
jti := uuid.New().String()
-
-
// Build claims
-
claims := map[string]interface{}{
-
"jti": jti,
-
"iat": iat.Unix(),
-
"htm": method,
-
"htu": uri,
-
}
-
-
// Build header claiming ES256 but using secp256k1 key
-
header := map[string]interface{}{
-
"typ": "dpop+jwt",
-
"alg": "ES256", // Claim ES256
-
"jwk": key.jwk, // But use secp256k1 key
-
}
-
-
headerJSON, _ := json.Marshal(header)
-
claimsJSON, _ := json.Marshal(claims)
-
-
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
-
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
-
-
signingInput := headerB64 + "." + claimsB64
-
signature, err := key.privateKey.HashAndSign([]byte(signingInput))
-
if err != nil {
-
t.Fatalf("Failed to sign: %v", err)
-
}
-
-
proof := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
-
-
_, err = verifier.VerifyDPoPProof(proof, method, uri)
-
if err == nil {
-
t.Error("Expected error for ES256 algorithm with secp256k1 curve, got nil")
-
}
-
if err != nil && !contains(err.Error(), "requires curve P-256") {
-
t.Errorf("Expected curve mismatch error, got: %v", err)
-
}
-
}
-
-
// === exp/nbf Validation Tests ===
-
-
func TestVerifyDPoPProof_ExpiredWithExpClaim(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256Key(t)
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now().Add(-2 * time.Minute)
-
exp := time.Now().Add(-1 * time.Minute) // Expired 1 minute ago
-
jti := uuid.New().String()
-
-
claims := &DPoPClaims{
-
RegisteredClaims: jwt.RegisteredClaims{
-
ID: jti,
-
IssuedAt: jwt.NewNumericDate(iat),
-
ExpiresAt: jwt.NewNumericDate(exp),
-
},
-
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 expired proof with exp claim, got nil")
-
}
-
if err != nil && !contains(err.Error(), "expired") {
-
t.Errorf("Expected expiration error, got: %v", err)
-
}
-
}
-
-
func TestVerifyDPoPProof_NotYetValidWithNbfClaim(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256Key(t)
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now()
-
nbf := time.Now().Add(5 * time.Minute) // Not valid for another 5 minutes
-
jti := uuid.New().String()
-
-
claims := &DPoPClaims{
-
RegisteredClaims: jwt.RegisteredClaims{
-
ID: jti,
-
IssuedAt: jwt.NewNumericDate(iat),
-
NotBefore: jwt.NewNumericDate(nbf),
-
},
-
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 not-yet-valid proof with nbf claim, got nil")
-
}
-
if err != nil && !contains(err.Error(), "not valid before") {
-
t.Errorf("Expected not-before error, got: %v", err)
-
}
-
}
-
-
func TestVerifyDPoPProof_ValidWithExpClaimInFuture(t *testing.T) {
-
verifier := NewDPoPVerifier()
-
key := generateTestES256Key(t)
-
-
method := "POST"
-
uri := "https://api.example.com/resource"
-
iat := time.Now()
-
exp := time.Now().Add(5 * time.Minute) // Valid for 5 more minutes
-
jti := uuid.New().String()
-
-
claims := &DPoPClaims{
-
RegisteredClaims: jwt.RegisteredClaims{
-
ID: jti,
-
IssuedAt: jwt.NewNumericDate(iat),
-
ExpiresAt: jwt.NewNumericDate(exp),
-
},
-
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)
-
}
-
-
result, err := verifier.VerifyDPoPProof(proof, method, uri)
-
if err != nil {
-
t.Fatalf("VerifyDPoPProof failed for valid proof with exp in future: %v", err)
-
}
-
-
if result == nil {
-
t.Error("Expected non-nil result for valid proof")
-
}
-
}
+1
docker-compose.prod.yml
···
# Instance identity
INSTANCE_DID: did:web:coves.social
INSTANCE_DOMAIN: coves.social
+
APPVIEW_PUBLIC_URL: https://coves.social
# PDS connection (separate domain!)
PDS_URL: https://coves.me
+6 -5
internal/api/routes/oauth.go
···
// Use login limiter since callback completes the authentication flow
r.With(corsMiddleware(allowedOrigins), loginLimiter.Middleware).Get("/oauth/callback", handler.HandleCallback)
-
// Mobile Universal Link callback route
-
// This route is used for iOS Universal Links and Android App Links
-
// Path must match the path in .well-known/apple-app-site-association
-
// Uses the same handler as web callback - the system routes it to the mobile app
-
r.With(loginLimiter.Middleware).Get("/app/oauth/callback", handler.HandleCallback)
+
// Mobile Universal Link callback route (fallback when app doesn't intercept)
+
// This route exists for iOS Universal Links and Android App Links.
+
// When properly configured, the mobile OS intercepts this URL and opens the app
+
// BEFORE the request reaches the server. If this handler is reached, it means
+
// Universal Links failed to intercept.
+
r.With(loginLimiter.Middleware).Get("/app/oauth/callback", handler.HandleMobileDeepLinkFallback)
// Session management - dedicated rate limits
r.With(logoutLimiter.Middleware).Post("/oauth/logout", handler.HandleLogout)
+11
static/.well-known/apple-app-site-association
···
+
{
+
"applinks": {
+
"apps": [],
+
"details": [
+
{
+
"appID": "TEAM_ID.social.coves",
+
"paths": ["/app/oauth/callback"]
+
}
+
]
+
}
+
}
+10
static/.well-known/assetlinks.json
···
+
[{
+
"relation": ["delegate_permission/common.handle_all_urls"],
+
"target": {
+
"namespace": "android_app",
+
"package_name": "social.coves",
+
"sha256_cert_fingerprints": [
+
"0B:D8:8C:99:66:25:E5:CD:06:54:80:88:01:6F:B7:38:B9:F4:5B:41:71:F7:95:C8:68:94:87:AD:EA:9F:D9:ED"
+
]
+
}
+
}]
+16 -9
internal/atproto/oauth/handlers_test.go
···
}
// TestIsMobileRedirectURI tests mobile redirect URI validation with EXACT URI matching
-
// Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security
+
// Per atproto spec, custom schemes must match client_id hostname in reverse-domain order
func TestIsMobileRedirectURI(t *testing.T) {
tests := []struct {
uri string
expected bool
}{
-
{"https://coves.social/app/oauth/callback", true}, // Universal Link - allowed
-
{"coves-app://oauth/callback", false}, // Custom scheme - blocked (insecure)
-
{"coves://oauth/callback", false}, // Custom scheme - blocked (insecure)
-
{"coves-app://callback", false}, // Custom scheme - blocked
-
{"coves://oauth", false}, // Custom scheme - blocked
-
{"myapp://oauth", false}, // Not in allowlist
-
{"https://example.com", false}, // Wrong domain
-
{"http://localhost", false}, // HTTP not allowed
+
// Custom scheme per atproto spec (reverse domain of coves.social)
+
{"social.coves:/callback", true},
+
{"social.coves://callback", true},
+
{"social.coves:/oauth/callback", true},
+
{"social.coves://oauth/callback", true},
+
// Universal Link - allowed (strongest security)
+
{"https://coves.social/app/oauth/callback", true},
+
// Wrong custom schemes - not reverse-domain of coves.social
+
{"coves-app://oauth/callback", false},
+
{"coves://oauth/callback", false},
+
{"coves.social://callback", false}, // Not reversed
+
{"myapp://oauth", false},
+
// Wrong domain/scheme
+
{"https://example.com", false},
+
{"http://localhost", false},
{"", false},
{"not-a-uri", false},
}
+41
internal/atproto/lexicon/social/coves/feed/vote/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.feed.vote.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a vote on a post or comment",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["subject"],
+
"properties": {
+
"subject": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the post or comment to remove the vote from"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {}
+
}
+
},
+
"errors": [
+
{
+
"name": "VoteNotFound",
+
"description": "No vote found for this subject"
+
},
+
{
+
"name": "NotAuthorized",
+
"description": "User is not authorized to delete this vote"
+
}
+
]
+
}
+
}
+
}
+115
internal/api/handlers/vote/create_vote.go
···
+
package vote
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// CreateVoteHandler handles vote creation
+
type CreateVoteHandler struct {
+
service votes.Service
+
}
+
+
// NewCreateVoteHandler creates a new create vote handler
+
func NewCreateVoteHandler(service votes.Service) *CreateVoteHandler {
+
return &CreateVoteHandler{
+
service: service,
+
}
+
}
+
+
// CreateVoteInput represents the request body for creating a vote
+
type CreateVoteInput struct {
+
Subject struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"subject"`
+
Direction string `json:"direction"`
+
}
+
+
// CreateVoteOutput represents the response body for creating a vote
+
type CreateVoteOutput struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// HandleCreateVote creates a vote on a post or comment
+
// POST /xrpc/social.coves.vote.create
+
//
+
// Request body: { "subject": { "uri": "at://...", "cid": "..." }, "direction": "up" }
+
// Response: { "uri": "at://...", "cid": "..." }
+
//
+
// Behavior:
+
// - If no vote exists: creates new vote with given direction
+
// - If vote exists with same direction: deletes vote (toggle off)
+
// - If vote exists with different direction: updates to new direction
+
func (h *CreateVoteHandler) HandleCreateVote(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var input CreateVoteInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// Validate required fields
+
if input.Subject.URI == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.uri is required")
+
return
+
}
+
if input.Subject.CID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.cid is required")
+
return
+
}
+
if input.Direction == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "direction is required")
+
return
+
}
+
+
// Validate direction
+
if input.Direction != "up" && input.Direction != "down" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "direction must be 'up' or 'down'")
+
return
+
}
+
+
// Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Create vote request
+
req := votes.CreateVoteRequest{
+
Subject: votes.StrongRef{
+
URI: input.Subject.URI,
+
CID: input.Subject.CID,
+
},
+
Direction: input.Direction,
+
}
+
+
// Call service to create vote
+
response, err := h.service.CreateVote(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response
+
output := CreateVoteOutput{
+
URI: response.URI,
+
CID: response.CID,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+93
internal/api/handlers/vote/delete_vote.go
···
+
package vote
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// DeleteVoteHandler handles vote deletion
+
type DeleteVoteHandler struct {
+
service votes.Service
+
}
+
+
// NewDeleteVoteHandler creates a new delete vote handler
+
func NewDeleteVoteHandler(service votes.Service) *DeleteVoteHandler {
+
return &DeleteVoteHandler{
+
service: service,
+
}
+
}
+
+
// DeleteVoteInput represents the request body for deleting a vote
+
type DeleteVoteInput struct {
+
Subject struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
} `json:"subject"`
+
}
+
+
// DeleteVoteOutput represents the response body for deleting a vote
+
// Per lexicon: output is an empty object
+
type DeleteVoteOutput struct{}
+
+
// HandleDeleteVote removes a vote from a post or comment
+
// POST /xrpc/social.coves.vote.delete
+
//
+
// Request body: { "subject": { "uri": "at://...", "cid": "..." } }
+
// Response: { "success": true }
+
func (h *DeleteVoteHandler) HandleDeleteVote(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var input DeleteVoteInput
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// Validate required fields
+
if input.Subject.URI == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.uri is required")
+
return
+
}
+
if input.Subject.CID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "subject.cid is required")
+
return
+
}
+
+
// Get OAuth session from context (injected by auth middleware)
+
session := middleware.GetOAuthSession(r)
+
if session == nil {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Create delete vote request
+
req := votes.DeleteVoteRequest{
+
Subject: votes.StrongRef{
+
URI: input.Subject.URI,
+
CID: input.Subject.CID,
+
},
+
}
+
+
// Call service to delete vote
+
err := h.service.DeleteVote(r.Context(), session, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response (empty object per lexicon)
+
output := DeleteVoteOutput{}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(output); err != nil {
+
log.Printf("Failed to encode response: %v", err)
+
}
+
}
+24
internal/api/routes/vote.go
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/vote"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/votes"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterVoteRoutes registers vote-related XRPC endpoints on the router
+
// Implements social.coves.feed.vote.* lexicon endpoints
+
func RegisterVoteRoutes(r chi.Router, voteService votes.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+
// Initialize handlers
+
createHandler := vote.NewCreateVoteHandler(voteService)
+
deleteHandler := vote.NewDeleteVoteHandler(voteService)
+
+
// Procedure endpoints (POST) - require authentication
+
// social.coves.feed.vote.create - create or update a vote on a post/comment
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.create", createHandler.HandleCreateVote)
+
+
// social.coves.feed.vote.delete - delete a vote from a post/comment
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.delete", deleteHandler.HandleDeleteVote)
+
}
+16
tests/integration/helpers.go
···
// AddSession adds a session to the store
func (m *MockOAuthStore) AddSession(did, sessionID, accessToken string) {
+
m.AddSessionWithPDS(did, sessionID, accessToken, getTestPDSURL())
+
}
+
+
// AddSessionWithPDS adds a session to the store with a specific PDS URL
+
func (m *MockOAuthStore) AddSessionWithPDS(did, sessionID, accessToken, pdsURL string) {
key := did + ":" + sessionID
parsedDID, _ := syntax.ParseDID(did)
m.sessions[key] = &oauthlib.ClientSessionData{
AccountDID: parsedDID,
SessionID: sessionID,
AccessToken: accessToken,
+
HostURL: pdsURL,
}
}
···
e.store.AddSession(did, sessionID, "access-token-"+did)
return token
}
+
+
// AddUserWithPDSToken registers a user with their real PDS access token
+
// Use this for E2E tests that need to write to the real PDS
+
func (e *E2EOAuthMiddleware) AddUserWithPDSToken(did, pdsAccessToken, pdsURL string) string {
+
token := "test-token-" + did
+
sessionID := "session-" + did
+
e.unsealer.AddSession(token, did, sessionID)
+
e.store.AddSessionWithPDS(did, sessionID, pdsAccessToken, pdsURL)
+
return token
+
}
+3
.beads/beads.left.jsonl
···
+
{"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]}
+
{"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."}
+
{"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."}
+1
.beads/beads.left.meta.json
···
+
{"version":"0.23.1","timestamp":"2025-12-02T18:25:24.009187871-08:00","commit":"00d7d8d"}
-3
internal/api/handlers/vote/errors.go
···
case errors.Is(err, votes.ErrVoteNotFound):
// Matches: social.coves.feed.vote.delete#VoteNotFound
writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject")
-
case errors.Is(err, votes.ErrSubjectNotFound):
-
// Matches: social.coves.feed.vote.create#SubjectNotFound
-
writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")
case errors.Is(err, votes.ErrInvalidDirection):
writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
case errors.Is(err, votes.ErrInvalidSubject):
+4 -4
internal/atproto/oauth/handlers_security.go
···
// - Android: Verified via /.well-known/assetlinks.json
var allowedMobileRedirectURIs = map[string]bool{
// Custom scheme per atproto spec (reverse-domain of coves.social)
-
"social.coves:/callback": true,
-
"social.coves://callback": true, // Some platforms add double slash
-
"social.coves:/oauth/callback": true, // Alternative path
-
"social.coves://oauth/callback": true,
+
"social.coves:/callback": true,
+
"social.coves://callback": true, // Some platforms add double slash
+
"social.coves:/oauth/callback": true, // Alternative path
+
"social.coves://oauth/callback": true,
// Universal Links - cryptographically bound to app (preferred for security)
"https://coves.social/app/oauth/callback": true,
}
+14 -27
internal/core/votes/service_impl.go
···
// voteService implements the Service interface for vote operations
type voteService struct {
-
repo Repository
-
subjectValidator SubjectValidator
-
oauthClient *oauthclient.OAuthClient
-
oauthStore oauth.ClientAuthStore
-
logger *slog.Logger
+
repo Repository
+
oauthClient *oauthclient.OAuthClient
+
oauthStore oauth.ClientAuthStore
+
logger *slog.Logger
}
// NewService creates a new vote service instance
-
// subjectValidator can be nil to skip subject existence checks (not recommended for production)
-
func NewService(repo Repository, subjectValidator SubjectValidator, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {
+
func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {
if logger == nil {
logger = slog.Default()
}
return &voteService{
-
repo: repo,
-
subjectValidator: subjectValidator,
-
oauthClient: oauthClient,
-
oauthStore: oauthStore,
-
logger: logger,
+
repo: repo,
+
oauthClient: oauthClient,
+
oauthStore: oauthStore,
+
logger: logger,
}
}
···
return nil, ErrInvalidSubject
}
-
// Validate subject exists in AppView (post or comment)
-
// This prevents creating votes on non-existent content
-
if s.subjectValidator != nil {
-
exists, err := s.subjectValidator.SubjectExists(ctx, req.Subject.URI)
-
if err != nil {
-
s.logger.Error("failed to validate subject existence",
-
"error", err,
-
"subject", req.Subject.URI)
-
return nil, fmt.Errorf("failed to validate subject: %w", err)
-
}
-
if !exists {
-
return nil, ErrSubjectNotFound
-
}
-
}
+
// Note: We intentionally don't validate subject existence here.
+
// The vote record goes to the user's PDS regardless. The Jetstream consumer
+
// handles orphaned votes correctly by only updating counts for non-deleted subjects.
+
// This avoids race conditions and eventual consistency issues.
// Check for existing vote by querying PDS directly (source of truth)
// This avoids eventual consistency issues with the AppView database
···
// Parse the listRecords response
var result struct {
+
Cursor string `json:"cursor"`
Records []struct {
URI string `json:"uri"`
CID string `json:"cid"`
···
CreatedAt string `json:"createdAt"`
} `json:"value"`
} `json:"records"`
-
Cursor string `json:"cursor"`
}
if err := json.Unmarshal(body, &result); err != nil {
+3 -2
internal/db/postgres/vote_repo.go
···
return nil
}
-
// GetByURI retrieves a vote by its AT-URI
+
// GetByURI retrieves an active vote by its AT-URI
// Used by Jetstream consumer for DELETE operations
+
// Returns ErrVoteNotFound for soft-deleted votes
func (r *postgresVoteRepo) GetByURI(ctx context.Context, uri string) (*votes.Vote, error) {
query := `
SELECT
···
subject_uri, subject_cid, direction,
created_at, indexed_at, deleted_at
FROM votes
-
WHERE uri = $1
+
WHERE uri = $1 AND deleted_at IS NULL
`
var vote votes.Vote