+591
docs/aggregators/SETUP_GUIDE.md
+591
docs/aggregators/SETUP_GUIDE.md
···
···+**Aggregators** are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers - self-managed external services that integrate with the platform.+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed script documentation.+Your aggregator needs its own atProto identity (DID). The easiest way is to create an account on an existing PDS.+**Save these credentials securely!** You'll need the DID and access token for all subsequent operations.+To register with Coves, you must prove you own a domain by serving your DID at `https://yourdomain.com/.well-known/atproto-did`.+"message": "Aggregator registered successfully. Next step: create a service declaration record at at://did:plc:abc123.../social.coves.aggregator.service/self"+Write a `social.coves.aggregator.service` record to your repository. This contains metadata about your aggregator and gets indexed by Coves' Jetstream consumer.+**Wait 5-10 seconds** for Jetstream to index your service declaration into the `aggregators` table.+2. **Authorization record**: Moderator writes `social.coves.aggregator.authorization` to community's repo+curl "https://api.coves.social/xrpc/social.coves.aggregator.getAuthorizations?aggregatorDid=did:plc:abc123...&enabledOnly=true"+3. Verify record was created: Check PDS at `at://your-did/social.coves.aggregator.service/self`
+95
scripts/aggregator-setup/1-create-pds-account.sh
+95
scripts/aggregator-setup/1-create-pds-account.sh
···
···
+93
scripts/aggregator-setup/2-setup-wellknown.sh
+93
scripts/aggregator-setup/2-setup-wellknown.sh
···
···
+103
scripts/aggregator-setup/3-register-with-coves.sh
+103
scripts/aggregator-setup/3-register-with-coves.sh
···
···
+125
scripts/aggregator-setup/4-create-service-declaration.sh
+125
scripts/aggregator-setup/4-create-service-declaration.sh
···
···
+252
scripts/aggregator-setup/README.md
+252
scripts/aggregator-setup/README.md
···
···+This directory contains scripts to help you set up and register your aggregator with Coves instances.+Aggregators are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers. To use aggregators with Coves, you need to:+- **Domain ownership**: You must own a domain where you can host the `.well-known/atproto-did` file+For a reference implementation of automated setup, see the Kagi News aggregator at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh).+The Kagi script shows how to automate all 4 steps (with the manual .well-known upload step in between).+1. Creates a `social.coves.aggregator.service` record at `at://your-did/social.coves.aggregator.service/self`+Authorizations are created by community moderators, not by aggregators. The moderator writes a `social.coves.aggregator.authorization` record to their community's repository.+- Verify `.well-known/atproto-did` is accessible: `curl https://yourdomain.com/.well-known/atproto-did`+- Verify the record was created: Check your PDS at `at://your-did/social.coves.aggregator.service/self`+For a complete reference implementation, see the Kagi News aggregator at `aggregators/kagi-news/`.+The Kagi aggregator includes an automated setup script at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh) that demonstrates how to:+This shows how you can package scripts 1-4 into a single automated flow for your specific aggregator.
+188
-1
aggregators/kagi-news/README.md
+188
-1
aggregators/kagi-news/README.md
·········
······+Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves.+**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`.+See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step.···+The easiest way to deploy the Kagi aggregator is using Docker. The cron job runs inside the container automatically.+- **`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)
+195
aggregators/kagi-news/scripts/setup.sh
+195
aggregators/kagi-news/scripts/setup.sh
···
···
+55
aggregators/kagi-news/.dockerignore
+55
aggregators/kagi-news/.dockerignore
···
···
+53
aggregators/kagi-news/Dockerfile
+53
aggregators/kagi-news/Dockerfile
···
···
+48
aggregators/kagi-news/docker-compose.yml
+48
aggregators/kagi-news/docker-compose.yml
···
···
+41
aggregators/kagi-news/docker-entrypoint.sh
+41
aggregators/kagi-news/docker-entrypoint.sh
···
···
+20
.beads/.gitignore
+20
.beads/.gitignore
···
···
+56
.beads/config.yaml
+56
.beads/config.yaml
···
···+# - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set)
+4
.beads/metadata.json
+4
.beads/metadata.json
+3
.gitattributes
+3
.gitattributes
+131
AGENTS.md
+131
AGENTS.md
···
···+**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.+6. **Commit together**: Always commit the `.beads/issues.jsonl` file together with the code changes so issue state stays in sync with code state
+8
-8
internal/core/communities/community.go
+8
-8
internal/core/communities/community.go
······
······
+2
-2
internal/core/communities/interfaces.go
+2
-2
internal/core/communities/interfaces.go
···-List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) // Returns communities + total count···GetCommunity(ctx context.Context, identifier string) (*Community, error) // identifier can be DID or handle
······GetCommunity(ctx context.Context, identifier string) (*Community, error) // identifier can be DID or handle
+57
scripts/backup.sh
+57
scripts/backup.sh
···
···+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
+133
scripts/deploy.sh
···
···+if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then+if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then+if docker compose -f "$COMPOSE_FILE" exec -T appview /app/coves-server migrate 2>/dev/null; then+warn "โ ๏ธ Migration command not available or failed - AppView will run migrations on startup"+if docker compose -f "$COMPOSE_FILE" exec -T appview wget --spider -q http://localhost:8080/xrpc/_health 2>/dev/null; then+warn "โ ๏ธ AppView health check failed - check logs with: docker compose -f docker-compose.prod.yml logs appview"+if docker compose -f "$COMPOSE_FILE" exec -T pds wget --spider -q http://localhost:3000/xrpc/_health 2>/dev/null; then+warn "โ ๏ธ PDS health check failed - check logs with: docker compose -f docker-compose.prod.yml logs pds"+log " Rollback: docker compose -f docker-compose.prod.yml down && git checkout HEAD~1 && ./scripts/deploy.sh"
+149
scripts/generate-did-keys.sh
+149
scripts/generate-did-keys.sh
···
···+PUBLIC_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+106
scripts/setup-production.sh
+106
scripts/setup-production.sh
···
···+until docker compose -f docker-compose.prod.yml exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
+19
static/.well-known/did.json.template
+19
static/.well-known/did.json.template
···
···
+18
static/client-metadata.json
+18
static/client-metadata.json
···
···
+97
static/oauth/callback.html
+97
static/oauth/callback.html
···
···+<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
+2
-1
Dockerfile
+2
-1
Dockerfile
···
+187
scripts/derive-did-from-key.sh
+187
scripts/derive-did-from-key.sh
···
···+PUBLIC_KEY_HEX=$(openssl ec -in "$TEMP_DIR/private.pem" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+3
-2
internal/api/routes/community.go
+3
-2
internal/api/routes/community.go
···-func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {
···+func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware, allowedCommunityCreators []string) {
+1
-1
internal/api/handlers/aggregator/register.go
+1
-1
internal/api/handlers/aggregator/register.go
+1
-2
internal/api/handlers/community/list.go
+1
-2
internal/api/handlers/community/list.go
+1
-2
internal/core/communities/service.go
+1
-2
internal/core/communities/service.go
+2
-4
internal/db/postgres/community_repo.go
+2
-4
internal/db/postgres/community_repo.go
······
······
+1
-2
tests/e2e/ratelimit_e2e_test.go
+1
-2
tests/e2e/ratelimit_e2e_test.go
+14
-14
tests/integration/aggregator_registration_test.go
+14
-14
tests/integration/aggregator_registration_test.go
······wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {·········wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {·········wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {·········wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
······wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {·········wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {·········wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {·········wellKnownServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+13
-14
tests/integration/community_e2e_test.go
+13
-14
tests/integration/community_e2e_test.go
·····················
·····················
+2
-3
tests/integration/community_repo_test.go
+2
-3
tests/integration/community_repo_test.go
+23
static/.well-known/did.json
+23
static/.well-known/did.json
···
···
+1
-1
docs/E2E_TESTING.md
+1
-1
docs/E2E_TESTING.md
+3
-3
internal/api/routes/user.go
+3
-3
internal/api/routes/user.go
···
···
+4
-4
tests/integration/user_test.go
+4
-4
tests/integration/user_test.go
···-req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil)·········-req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil)
···+req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=did:plc:endpoint123", nil)·········+req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=nonexistent.test", nil)
+52
internal/atproto/auth/combined_key_fetcher.go
+52
internal/atproto/auth/combined_key_fetcher.go
···
···+func NewCombinedKeyFetcher(directory indigoIdentity.Directory, jwksFetcher JWKSFetcher) *CombinedKeyFetcher {+func (f *CombinedKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+116
internal/atproto/auth/did_key_fetcher.go
+116
internal/atproto/auth/did_key_fetcher.go
···
···+func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+5
.env.dev
+5
.env.dev
···
+484
internal/atproto/auth/dpop.go
+484
internal/atproto/auth/dpop.go
···
···+func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) {+verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) {+func (v *DPoPVerifier) validateDPoPClaims(claims *DPoPClaims, expectedMethod, expectedURI string) error {+return fmt.Errorf("DPoP proof htm mismatch: expected %s, got %s", expectedMethod, claims.HTTPMethod)+return fmt.Errorf("DPoP proof htu mismatch: expected %s, got %s", expectedURIBase, claimURIBase)+return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)+func (v *DPoPVerifier) VerifyTokenBinding(proof *DPoPProof, expectedThumbprint string) error {+// Serialize to JSON (Go's json.Marshal produces lexicographically ordered keys for map[string]string)
+921
internal/atproto/auth/dpop_test.go
+921
internal/atproto/auth/dpop_test.go
···
···+func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {
+148
-6
internal/api/middleware/auth.go
+148
-6
internal/api/middleware/auth.go
······func NewAtProtoAuthMiddleware(jwksFetcher auth.JWKSFetcher, skipVerify bool) *AtProtoAuthMiddleware {···············
······func NewAtProtoAuthMiddleware(jwksFetcher auth.JWKSFetcher, skipVerify bool) *AtProtoAuthMiddleware {······+log.Printf("[AUTH_FAILURE] type=missing_dpop ip=%s method=%s path=%s error=token has cnf.jkt but no DPoP header",+log.Printf("[AUTH_WARNING] type=unexpected_dpop ip=%s method=%s path=%s warning=DPoP header present but token has no cnf.jkt",······+log.Printf("[AUTH_WARNING] Optional auth: token has cnf.jkt but no DPoP header - treating as unauthenticated (potential token theft)")+log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err)···+func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader string) (*auth.DPoPProof, error) {
+416
internal/api/middleware/auth_test.go
+416
internal/api/middleware/auth_test.go
·········+// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)+handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+134
-2
internal/atproto/auth/README.md
+134
-2
internal/atproto/auth/README.md
·········
···+DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.+DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:+> โ ๏ธ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**+1. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)+**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ>โ+โ<โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:+// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)······
+5
-6
go.mod
+5
-6
go.mod
·········
·········
+6
-8
go.sum
+6
-8
go.sum
···-github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=-github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=-github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=···github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=······github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
···+github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 h1:Vc+l4sltxQfBT8qC3dm87PRYInmxlGyF1dmpjaW0WkU=+github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=···github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=+github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=+github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=······github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=