+5
-5
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
+5
-5
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
············
+7
-4
docs/FEED_SYSTEM_IMPLEMENTATION.md
+7
-4
docs/FEED_SYSTEM_IMPLEMENTATION.md
···'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \···
+3
-3
docs/PRD_OAUTH.md
+3
-3
docs/PRD_OAUTH.md
······Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ...···
+3
-1
docs/aggregators/SETUP_GUIDE.md
+3
-1
docs/aggregators/SETUP_GUIDE.md
······
+8
-2
docs/federation-prd.md
+8
-2
docs/federation-prd.md
·········
+1
-1
scripts/aggregator-setup/README.md
+1
-1
scripts/aggregator-setup/README.md
···
+1
tests/integration/blob_upload_e2e_test.go
+1
tests/integration/blob_upload_e2e_test.go
···assert.Equal(t, "/xrpc/com.atproto.repo.uploadBlob", r.URL.Path, "Should hit uploadBlob endpoint")assert.Equal(t, "image/png", r.Header.Get("Content-Type"), "Should have correct content type")
+477
internal/atproto/oauth/handlers_security_test.go
+477
internal/atproto/oauth/handlers_security_test.go
···+// TestIsAllowedMobileRedirectURI tests the mobile redirect URI allowlist with EXACT URI matching+assert.Greater(t, len(token1), 40, "CSRF token should be reasonably long (32 bytes base64 encoded)")+// TestHandleMobileLogin_RedirectURIValidation tests that HandleMobileLogin validates redirect URIs+// TestHandleCallback_CSRFValidation tests that HandleCallback validates CSRF tokens for mobile flow+// TestHandleMobileCallback_RevalidatesRedirectURI tests that handleMobileCallback re-validates the redirect URI+binding1 := generateMobileRedirectBinding(csrfToken, "https://coves.social/app/oauth/callback")+binding3 := generateMobileRedirectBinding("different-csrf-token", "https://coves.social/app/oauth/callback")+assert.NotEqual(t, binding1, binding3, "different CSRF tokens should produce different bindings")+assert.False(t, validateMobileRedirectBinding(attackerCSRF, attackerRedirectURI, userBinding),
+152
internal/atproto/oauth/seal.go
+152
internal/atproto/oauth/seal.go
···
+331
internal/atproto/oauth/seal_test.go
+331
internal/atproto/oauth/seal_test.go
···
+614
internal/atproto/oauth/store.go
+614
internal/atproto/oauth/store.go
···+func (s *PostgresOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {+func (s *PostgresOAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {+func (s *PostgresOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {+func (s *PostgresOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {+func (s *PostgresOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {+if strings.Contains(err.Error(), "duplicate key") && strings.Contains(err.Error(), "oauth_requests_state_key") {+func (w *MobileAwareStoreWrapper) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {+func (w *MobileAwareStoreWrapper) GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) {+// SaveMobileOAuthData implements MobileOAuthStore interface by delegating to underlying store+func (w *MobileAwareStoreWrapper) SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error {+func (s *PostgresOAuthStore) SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error {+func (s *PostgresOAuthStore) GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) {
+522
internal/atproto/oauth/store_test.go
+522
internal/atproto/oauth/store_test.go
···+`, did1.String(), "expired_session", "test.handle", "https://pds.example.com", "https://pds.example.com",
+99
internal/atproto/oauth/transport.go
+99
internal/atproto/oauth/transport.go
···
+132
internal/atproto/oauth/transport_test.go
+132
internal/atproto/oauth/transport_test.go
···
+124
internal/db/migrations/019_update_oauth_for_indigo.sql
+124
internal/db/migrations/019_update_oauth_for_indigo.sql
···+-- Make handle and pds_url nullable too (derived from DID resolution, not always available at auth request time)+-- Note: This will leave the multibase column NULL for now since conversion requires crypto logic+CREATE INDEX idx_oauth_requests_request_uri ON oauth_requests(request_uri) WHERE request_uri IS NOT NULL;+-- Populate session_id for existing sessions (use DID as default for single-session per account)+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';
+23
internal/db/migrations/020_add_mobile_oauth_csrf.sql
+23
internal/db/migrations/020_add_mobile_oauth_csrf.sql
···+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';
+137
internal/api/handlers/wellknown/universal_links.go
+137
internal/api/handlers/wellknown/universal_links.go
···+// Spec: https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app+// Debug: keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android+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"
+25
internal/api/routes/wellknown.go
+25
internal/api/routes/wellknown.go
···
+1
-1
internal/api/handlers/comments/middleware.go
+1
-1
internal/api/handlers/comments/middleware.go
···-func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler {+func OptionalAuthMiddleware(authMiddleware *middleware.OAuthAuthMiddleware, next http.HandlerFunc) http.Handler {
+1
-1
internal/api/routes/community.go
+1
-1
internal/api/routes/community.go
···// 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) {
+1
-1
internal/api/routes/post.go
+1
-1
internal/api/routes/post.go
···-func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {+func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+1
-1
internal/api/routes/timeline.go
+1
-1
internal/api/routes/timeline.go
···
+291
tests/e2e/oauth_ratelimit_e2e_test.go
+291
tests/e2e/oauth_ratelimit_e2e_test.go
···+assert.Contains(t, rr.Body.String(), "Rate limit exceeded", "Should have rate limit error message")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Mobile login should be rate limited at 10 req/min")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Refresh should be rate limited at 20 req/min")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Logout should be rate limited at 10 req/min")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Callback should be rate limited at 10 req/min")+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)")+loginHandler := loginLimiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+refreshHandler := refreshLimiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+910
tests/integration/oauth_e2e_test.go
+910
tests/integration/oauth_e2e_test.go
···+// 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) {+"UPDATE oauth_sessions SET expires_at = NOW() - INTERVAL '1 day' WHERE did = $1 AND session_id = $2",+assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")+assert.Equal(t, authRequest.PKCEVerifier, retrieved.PKCEVerifier, "PKCE verifier should match")+assert.Equal(t, authRequest.AuthServerURL, retrieved.AuthServerURL, "Auth server URL should match")+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,+// 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)+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+312
tests/integration/oauth_session_fixation_test.go
+312
tests/integration/oauth_session_fixation_test.go
···+// 6. WITH THE FIX: Binding mismatch is detected, mobile cookies cleared, user gets web session+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)+wrongBinding := generateMobileRedirectBindingForTest("different-csrf", "https://coves.social/app/oauth/callback")+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)
+169
tests/integration/oauth_token_verification_test.go
+169
tests/integration/oauth_token_verification_test.go
···+testHandler := e2eAuth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+{"Tampered token", "dGFtcGVyZWQtdG9rZW4tZGF0YQ=="}, // Valid base64 but not a real sealed session+testHandler := e2eAuth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+t.Skip("Session expiration is tested in oauth_e2e_test.go - see TestOAuthE2E_TokenExpiration")
+2
go.mod
+2
go.mod
···
+5
.env.dev
+5
.env.dev
···
-73
cmd/genjwks/main.go
-73
cmd/genjwks/main.go
···
-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) {
-616
internal/atproto/auth/dpop.go
-616
internal/atproto/auth/dpop.go
···-func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, 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)-func parseJWKToIndigoPublicKey(jwkMap map[string]interface{}) (indigoCrypto.PublicKey, error) {-// parseJWTHeaderAndClaims manually parses a JWT's header and claims without using golang-jwt.-func parseJWTHeaderAndClaims(tokenString string) (map[string]interface{}, *DPoPClaims, error) {-// This is used instead of golang-jwt for algorithms not supported by golang-jwt (like ES256K).
-189
internal/atproto/auth/jwks_fetcher.go
-189
internal/atproto/auth/jwks_fetcher.go
···-func (f *CachedJWKSFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
-496
internal/atproto/auth/jwt_test.go
-496
internal/atproto/auth/jwt_test.go
···-func (m *mockJWKSFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {-func createHS256Token(t *testing.T, subject, issuer, secret string, expiry time.Duration) string {-tokenString := createHS256Token(t, "did:plc:attacker", "https://victim-pds.example.com", "some-secret", 1*time.Hour)-// SECURITY TEST: When no issuers are whitelisted for HS256, all HS256 tokens should be rejected-tokenString := createHS256Token(t, "did:plc:test123", "https://any-pds.example.com", "some-secret", 1*time.Hour)-// Create RS256-signed token (can't actually sign without RSA key, but we can test the header check)-tokenString := createHS256Token(t, "did:plc:test123", "https://test.example.com", "secret", 1*time.Hour)-tokenString := createHS256Token(t, "did:plc:test123", "https://test.example.com", "secret", 1*time.Hour)
+1
docker-compose.prod.yml
+1
docker-compose.prod.yml
+3
-1
internal/atproto/oauth/client.go
+3
-1
internal/atproto/oauth/client.go
···
-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'">
+6
-5
internal/api/routes/oauth.go
+6
-5
internal/api/routes/oauth.go
···r.With(corsMiddleware(allowedOrigins), loginLimiter.Middleware).Get("/oauth/callback", handler.HandleCallback)+r.With(loginLimiter.Middleware).Get("/app/oauth/callback", handler.HandleMobileDeepLinkFallback)
+11
static/.well-known/apple-app-site-association
+11
static/.well-known/apple-app-site-association
+10
static/.well-known/assetlinks.json
+10
static/.well-known/assetlinks.json
···+"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
+16
-9
internal/atproto/oauth/handlers_test.go
···
+115
internal/api/handlers/vote/create_vote.go
+115
internal/api/handlers/vote/create_vote.go
···
+93
internal/api/handlers/vote/delete_vote.go
+93
internal/api/handlers/vote/delete_vote.go
···
+24
internal/api/routes/vote.go
+24
internal/api/routes/vote.go
···+func RegisterVoteRoutes(r chi.Router, voteService votes.Service, authMiddleware *middleware.OAuthAuthMiddleware) {+r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.create", createHandler.HandleCreateVote)+r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.delete", deleteHandler.HandleDeleteVote)
+3
.beads/beads.left.jsonl
+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
+1
.beads/beads.left.meta.json
···
-3
internal/api/handlers/vote/errors.go
-3
internal/api/handlers/vote/errors.go
···-writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
+4
-4
internal/atproto/oauth/handlers_security.go
+4
-4
internal/atproto/oauth/handlers_security.go
···
-3
internal/core/votes/errors.go
-3
internal/core/votes/errors.go
···
+14
-27
internal/core/votes/service_impl.go
+14
-27
internal/core/votes/service_impl.go
···-// 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 {·········
+3
-2
internal/db/postgres/vote_repo.go
+3
-2
internal/db/postgres/vote_repo.go
······