-12
.env.test.example
-12
.env.test.example
···-TEST_DATABASE_URL=postgres://your_test_user:your_test_password@localhost:5434/coves_test?sslmode=disable
+17
-10
docs/LOCAL_DEVELOPMENT.md
+17
-10
docs/LOCAL_DEVELOPMENT.md
······+All configuration is in [.env.dev](../.env.dev) - a single file for both development and testing:·········
-10
internal/core/users/repository.go
-10
internal/core/users/repository.go
-272
internal/core/users/service_test.go
-272
internal/core/users/service_test.go
···
-32
internal/db/migrations/005_add_user_maps_indices.sql
-32
internal/db/migrations/005_add_user_maps_indices.sql
···-IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'user_maps' AND column_name = 'did') THEN-IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'user_maps' AND column_name = 'created_at') THEN
-1
.claude/commands/create-pr.md
-1
.claude/commands/create-pr.md
···
+254
internal/jetstream/user_consumer.go
+254
internal/jetstream/user_consumer.go
···+func NewUserEventConsumer(userService users.UserService, wsURL string, pdsFilter string) *UserEventConsumer {+func (c *UserEventConsumer) HandleIdentityEventPublic(ctx context.Context, event *JetstreamEvent) error {+func (c *UserEventConsumer) handleIdentityEvent(ctx context.Context, event *JetstreamEvent) error {+func (c *UserEventConsumer) handleAccountEvent(ctx context.Context, event *JetstreamEvent) error {+return contains(errStr, "already exists") || contains(errStr, "already taken") || contains(errStr, "duplicate")+return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && anySubstring(s, substr))
-8
.idea/.gitignore
-8
.idea/.gitignore
-9
.idea/Coves.iml
-9
.idea/Coves.iml
···
-17
.idea/dataSources.xml
-17
.idea/dataSources.xml
···-<data-source source="LOCAL" name="postgres@localhost" uuid="66e25b60-2901-4e78-bdb8-343f2c71fb79">
-8
.idea/modules.xml
-8
.idea/modules.xml
-6
.idea/vcs.xml
-6
.idea/vcs.xml
+88
internal/atproto/identity/caching_resolver.go
+88
internal/atproto/identity/caching_resolver.go
···+func (r *cachingResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) {
+45
internal/atproto/identity/errors.go
+45
internal/atproto/identity/errors.go
···
+40
internal/atproto/identity/resolver.go
+40
internal/atproto/identity/resolver.go
···
+35
internal/atproto/identity/types.go
+35
internal/atproto/identity/types.go
···
+58
internal/db/migrations/002_create_identity_cache_table.sql
+58
internal/db/migrations/002_create_identity_cache_table.sql
···
+1
-1
PRD.md
+1
-1
PRD.md
···-- **Indigo PDS Integration** - Use existing atProto infrastructure (no CAR file reimplementation!)+- **Indigo PDS Integration**1 - Use existing atProto infrastructure (no CAR file reimplementation!)
+69
internal/db/migrations/003_create_oauth_tables.sql
+69
internal/db/migrations/003_create_oauth_tables.sql
···
+39
internal/db/migrations/006_encrypt_community_credentials.sql
+39
internal/db/migrations/006_encrypt_community_credentials.sql
···+CREATE INDEX idx_communities_encrypted_tokens ON communities(did) WHERE pds_access_token_encrypted IS NOT NULL;+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';
+4
-4
internal/api/middleware/ratelimit.go
+4
-4
internal/api/middleware/ratelimit.go
···
+2
-2
internal/core/errors/errors.go
+2
-2
internal/core/errors/errors.go
+8
-8
internal/core/users/user.go
+8
-8
internal/core/users/user.go
······
+35
internal/db/migrations/007_add_password_encryption.sql
+35
internal/db/migrations/007_add_password_encryption.sql
···+COMMENT ON COLUMN communities.pds_password_encrypted IS 'Encrypted community PDS password (pgp_sym_encrypt) - required for session recovery when tokens expire';+COMMENT ON COLUMN communities.pds_password_hash IS 'bcrypt hash of community PDS password (DEPRECATED - cannot recover plaintext)';
-80
internal/atproto/did/generator.go
-80
internal/atproto/did/generator.go
···-// isDevEnv: true for local development (no PLC registration), false for production (register with PLC)
-127
internal/atproto/did/generator_test.go
-127
internal/atproto/did/generator_test.go
···
-210
internal/api/handlers/oauth/callback.go
-210
internal/api/handlers/oauth/callback.go
···
-131
internal/api/handlers/oauth/env_test.go
-131
internal/api/handlers/oauth/env_test.go
···-envValue: "base64:" + base64.StdEncoding.EncodeToString([]byte("f1132c01b1a625a865c6c455a75ee793")),-realJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
-53
internal/api/handlers/oauth/jwks.go
-53
internal/api/handlers/oauth/jwks.go
···
-177
internal/api/handlers/oauth/login.go
-177
internal/api/handlers/oauth/login.go
···-func NewLoginHandler(identityResolver identity.Resolver, sessionStore oauthCore.SessionStore) *LoginHandler {-http.Error(w, "Failed to fetch authorization server metadata", http.StatusInternalServerError)-parResp, err := client.SendPARRequest(r.Context(), authMeta, handle, "atproto transition:generic", dpopKey)
-86
internal/api/handlers/oauth/metadata.go
-86
internal/api/handlers/oauth/metadata.go
···-if !strings.HasPrefix(appviewURL, "http://localhost") && !strings.HasPrefix(appviewURL, "http://127.0.0.1") {-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"
-167
internal/atproto/oauth/dpop.go
-167
internal/atproto/oauth/dpop.go
···-// - nonce: Optional server-provided nonce (empty on first request, use nonce from 401 response on retry)-func CreateDPoPProof(privateKey jwk.Key, method, uri, nonce, accessToken string) (string, error) {-signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers)))
-172
internal/atproto/oauth/dpop_test.go
-172
internal/atproto/oauth/dpop_test.go
···
-201
internal/atproto/xrpc/dpop_transport.go
-201
internal/atproto/xrpc/dpop_transport.go
···-func NewDPoPTransport(base http.RoundTripper, session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*DPoPTransport, error) {-func (t *DPoPTransport) retryWithNewNonce(req *http.Request, newNonce string) (*http.Response, error) {-func NewAuthenticatedClient(session *oauthCore.OAuthSession, sessionStore oauthCore.SessionStore) (*http.Client, error) {
-91
internal/core/oauth/auth_service.go
-91
internal/core/oauth/auth_service.go
···-func (s *AuthService) ValidateSession(ctx context.Context, did string) (*OAuthSession, error) {-func (s *AuthService) RefreshTokenIfNeeded(ctx context.Context, session *OAuthSession, threshold time.Duration) (*OAuthSession, error) {-if err := s.sessionStore.RefreshSession(session.DID, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt); err != nil {-if updateErr := s.sessionStore.UpdateAuthServerNonce(session.DID, tokenResp.DpopAuthserverNonce); updateErr != nil {
-353
internal/core/oauth/repository.go
-353
internal/core/oauth/repository.go
···-// GetAndDeleteRequest atomically retrieves and deletes an OAuth request to prevent replay attacks-func (s *PostgresSessionStore) RefreshSession(did, newAccessToken, newRefreshToken string, expiresAt time.Time) error {
-59
internal/core/oauth/session.go
-59
internal/core/oauth/session.go
···-GetAndDeleteRequest(state string) (*OAuthRequest, error) // Atomic get-and-delete for CSRF protection
+23
internal/db/migrations/008_add_content_visibility_to_subscriptions.sql
+23
internal/db/migrations/008_add_content_visibility_to_subscriptions.sql
···+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)';
+136
internal/atproto/jetstream/community_jetstream_connector.go
+136
internal/atproto/jetstream/community_jetstream_connector.go
···+// NewCommunityJetstreamConnector creates a new Jetstream WebSocket connector for community events+func NewCommunityJetstreamConnector(consumer *CommunityEventConsumer, wsURL string) *CommunityJetstreamConnector {
-6
tests/lexicon-test-data/actor/subscription-invalid-visibility.json
-6
tests/lexicon-test-data/actor/subscription-invalid-visibility.json
-6
tests/lexicon-test-data/actor/subscription-valid.json
-6
tests/lexicon-test-data/actor/subscription-valid.json
+6
tests/lexicon-test-data/community/subscription-invalid-visibility.json
+6
tests/lexicon-test-data/community/subscription-invalid-visibility.json
+6
tests/lexicon-test-data/community/subscription-valid.json
+6
tests/lexicon-test-data/community/subscription-valid.json
+5
-1
internal/core/communities/errors.go
+5
-1
internal/core/communities/errors.go
······
+99
internal/core/communities/token_refresh.go
+99
internal/core/communities/token_refresh.go
···+func refreshPDSToken(ctx context.Context, pdsURL, currentAccessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) {+func reauthenticateWithPassword(ctx context.Context, pdsURL, email, password string) (accessToken, refreshToken string, err error) {
+66
internal/core/communities/token_utils.go
+66
internal/core/communities/token_utils.go
···
-6
tests/lexicon-test-data/actor/membership-invalid-reputation.json
-6
tests/lexicon-test-data/actor/membership-invalid-reputation.json
-6
tests/lexicon-test-data/actor/membership-valid.json
-6
tests/lexicon-test-data/actor/membership-valid.json
-38
internal/atproto/lexicon/social/coves/actor/membership.json
-38
internal/atproto/lexicon/social/coves/actor/membership.json
···
+68
internal/db/migrations/010_migrate_community_handles_to_singular.sql
+68
internal/db/migrations/010_migrate_community_handles_to_singular.sql
···+RAISE EXCEPTION 'Migration incomplete: % communities still have .communities. format', old_format_count;+RAISE EXCEPTION 'Rollback incomplete: % communities still have .community. format', new_format_count;
+1
-1
tests/lexicon-test-data/community/profile-valid.json
+1
-1
tests/lexicon-test-data/community/profile-valid.json
+9
-7
internal/core/communities/pds_provisioning.go
+9
-7
internal/core/communities/pds_provisioning.go
······-email := fmt.Sprintf("community-%s@communities.%s", strings.ToLower(communityName), p.instanceDomain)+email := fmt.Sprintf("community-%s@community.%s", strings.ToLower(communityName), p.instanceDomain)···
+51
internal/db/migrations/011_create_posts_table.sql
+51
internal/db/migrations/011_create_posts_table.sql
···+CONSTRAINT fk_community FOREIGN KEY (community_did) REFERENCES communities(did) ON DELETE CASCADE+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_content_search ON posts USING gin(to_tsvector('english', content)) WHERE deleted_at IS NULL;+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';
+125
internal/atproto/jetstream/post_jetstream_connector.go
+125
internal/atproto/jetstream/post_jetstream_connector.go
···+func NewPostJetstreamConnector(consumer *PostEventConsumer, wsURL string) *PostJetstreamConnector {+if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+38
internal/core/communityFeeds/errors.go
+38
internal/core/communityFeeds/errors.go
···
+25
internal/core/communityFeeds/interfaces.go
+25
internal/core/communityFeeds/interfaces.go
···+// GetAuthorFeed(ctx context.Context, authorDID string, limit int, cursor *string) (*FeedResponse, error)+GetCommunityFeed(ctx context.Context, req GetCommunityFeedRequest) ([]*FeedViewPost, *string, error)+// 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
+23
internal/api/routes/communityFeed.go
···
+214
internal/db/migrations/012_create_aggregators_tables.sql
+214
internal/db/migrations/012_create_aggregators_tables.sql
···+-- Source: Aggregator's own repository (at://aggregator_did/social.coves.aggregator.service/self)+created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When the aggregator service was created (from lexicon createdAt field)+communities_using INTEGER NOT NULL DEFAULT 0, -- Count of communities with enabled authorizations+COMMENT ON TABLE aggregators IS 'Aggregator service declarations indexed from social.coves.aggregator.service records';+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';+-- Source: Community's repository (at://community_did/social.coves.aggregator.authorization/rkey)+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+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);+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);+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';+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+CREATE INDEX idx_aggregator_posts_rate_limit ON aggregator_posts(aggregator_did, community_did, created_at DESC);+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);+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)';+COMMENT ON FUNCTION update_aggregator_communities_count IS 'Maintains aggregators.communities_using count when authorizations change';+COMMENT ON FUNCTION update_aggregator_posts_count IS 'Maintains aggregators.posts_created count when posts are tracked';+DROP TRIGGER IF EXISTS trigger_update_aggregator_communities_count ON aggregator_authorizations;
+136
internal/atproto/jetstream/aggregator_jetstream_connector.go
+136
internal/atproto/jetstream/aggregator_jetstream_connector.go
···+// AggregatorJetstreamConnector handles WebSocket connection to Jetstream for aggregator events+// NewAggregatorJetstreamConnector creates a new Jetstream WebSocket connector for aggregator events+func NewAggregatorJetstreamConnector(consumer *AggregatorEventConsumer, wsURL string) *AggregatorJetstreamConnector {
+41
aggregators/kagi-news/.gitignore
+41
aggregators/kagi-news/.gitignore
···
+3
aggregators/kagi-news/src/__init__.py
+3
aggregators/kagi-news/src/__init__.py
+165
aggregators/kagi-news/src/config.py
+165
aggregators/kagi-news/src/config.py
···+logger.info(f"Loaded configuration with {len(feeds)} feeds ({sum(1 for f in feeds if f.enabled)} enabled)")
+71
aggregators/kagi-news/src/rss_fetcher.py
+71
aggregators/kagi-news/src/rss_fetcher.py
···
+213
aggregators/kagi-news/src/state_manager.py
+213
aggregators/kagi-news/src/state_manager.py
···
+1
aggregators/kagi-news/tests/__init__.py
+1
aggregators/kagi-news/tests/__init__.py
···
+12
aggregators/kagi-news/tests/fixtures/sample_rss_item.xml
+12
aggregators/kagi-news/tests/fixtures/sample_rss_item.xml
···+<description><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><li>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 .</li><li>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 .</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><li>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. (<a href='https://www.rt.com/news/626890-white-house-announces-trump-xi-meeting/'>RT</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><li><a href='https://www.rt.com/news/626890-white-house-announces-trump-xi-meeting/'>White House announces Trump-Xi meeting</a> - rt.com</li><li><a href='https://www.thehindu.com/news/international/trump-to-meet-xi-in-south-korea-as-part-of-asia-swing/article70195667.ece'>Trump to meet Xi in South Korea as part of Asia swing</a> - thehindu.com</li><li><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'>White House confirms Trump to meet Xi in South Korea as part of Asia tour</a> - aljazeera.com</li></ul></description>+<guid isPermaLink="true">https://kite.kagi.com/96cf948f-8a1b-4281-9ba4-8a9e1ad7b3c6/world/10</guid>
+246
aggregators/kagi-news/tests/test_config.py
+246
aggregators/kagi-news/tests/test_config.py
···
+122
aggregators/kagi-news/tests/test_html_parser.py
+122
aggregators/kagi-news/tests/test_html_parser.py
···+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'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>"""+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"+assert result['sources'][0]['title'] == "Trump to meet Xi in South Korea on Oct 30 as part of Asia swing"
+299
aggregators/kagi-news/tests/test_richtext_formatter.py
+299
aggregators/kagi-news/tests/test_richtext_formatter.py
···+summary="The White House confirmed President Trump will hold a bilateral meeting with Chinese President Xi Jinping in South Korea on October 30.",
+91
aggregators/kagi-news/tests/test_rss_fetcher.py
+91
aggregators/kagi-news/tests/test_rss_fetcher.py
···
+227
aggregators/kagi-news/tests/test_state_manager.py
+227
aggregators/kagi-news/tests/test_state_manager.py
···
+6
aggregators/kagi-news/.env.example
+6
aggregators/kagi-news/.env.example
+29
aggregators/kagi-news/config.example.yaml
+29
aggregators/kagi-news/config.example.yaml
···
+5
aggregators/kagi-news/crontab
+5
aggregators/kagi-news/crontab
+12
aggregators/kagi-news/pytest.ini
+12
aggregators/kagi-news/pytest.ini
+17
aggregators/kagi-news/requirements.txt
+17
aggregators/kagi-news/requirements.txt
···
+30
internal/api/routes/discover.go
+30
internal/api/routes/discover.go
···
+71
internal/core/discover/service.go
+71
internal/core/discover/service.go
···+func (s *discoverService) GetDiscover(ctx context.Context, req GetDiscoverRequest) (*DiscoverResponse, error) {+return NewValidationError("timeframe", "timeframe must be one of: hour, day, week, month, year, all")
+76
internal/core/timeline/service.go
+76
internal/core/timeline/service.go
···+func (s *timelineService) GetTimeline(ctx context.Context, req GetTimelineRequest) (*TimelineResponse, error) {+return NewValidationError("timeframe", "timeframe must be one of: hour, day, week, month, year, all")
+19
internal/api/handlers/errors.go
+19
internal/api/handlers/errors.go
···
+125
internal/atproto/jetstream/vote_jetstream_connector.go
+125
internal/atproto/jetstream/vote_jetstream_connector.go
···+func NewVoteJetstreamConnector(consumer *VoteEventConsumer, wsURL string) *VoteJetstreamConnector {+if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+22
internal/db/migrations/014_remove_votes_voter_fk.sql
+22
internal/db/migrations/014_remove_votes_voter_fk.sql
···
+20
-3
Makefile
+20
-3
Makefile
······
+14
scripts/dev-run.sh
+14
scripts/dev-run.sh
···
+68
scripts/setup-mobile-ports.sh
+68
scripts/setup-mobile-ports.sh
···+echo "Install Android SDK Platform Tools: https://developer.android.com/studio/releases/platform-tools"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${GREEN}PDS (3000):${NC} localhost:3001 โ device:3000 ${YELLOW}(DID document port)${NC}"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}๐ก Note: Port forwarding persists until device disconnects or you run:${NC}"
+116
scripts/start-ngrok.sh
+116
scripts/start-ngrok.sh
···+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+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)+URLS=($(echo "$TUNNELS" | jq -r '.tunnels[] | select(.proto=="https") | .public_url' 2>/dev/null))+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+if [[ "$PDS_URL" == "ERROR" ]] || [[ "$PLC_URL" == "ERROR" ]] || [[ "$APPVIEW_URL" == "ERROR" ]]; then
+26
scripts/stop-ngrok.sh
+26
scripts/stop-ngrok.sh
···
-67
internal/atproto/lexicon/social/coves/interaction/createVote.json
-67
internal/atproto/lexicon/social/coves/interaction/createVote.json
···
-37
internal/atproto/lexicon/social/coves/interaction/deleteVote.json
-37
internal/atproto/lexicon/social/coves/interaction/deleteVote.json
···
+27
-27
internal/core/aggregators/aggregator.go
+27
-27
internal/core/aggregators/aggregator.go
···-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-CreatedAt time.Time `json:"createdAt" db:"created_at"` // When aggregator was created (from lexicon)-RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://did/social.coves.aggregator.service/self// Stored in community's repository: at://community_did/social.coves.aggregator.authorization/{rkey}-DisabledBy string `json:"disabledBy,omitempty" db:"disabled_by"` // Moderator DID who disabled it-DisabledAt *time.Time `json:"disabledAt,omitempty" db:"disabled_at"` // When authorization was disabled (for modlog/audit)-RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://community_did/social.coves.aggregator.authorization/{rkey}
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
···+-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure+COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+1
-1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
+1
-1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
···
+5
-6
tests/lexicon-test-data/post/post-invalid-missing-community.json
+5
-6
tests/lexicon-test-data/post/post-invalid-missing-community.json
···
+6
-7
tests/lexicon-test-data/post/post-valid-text.json
+6
-7
tests/lexicon-test-data/post/post-valid-text.json
···-"text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",+"content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",···
+17
-17
docs/PRD_POSTS.md
+17
-17
docs/PRD_POSTS.md
·········-- [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)···- [x] **E2E Test:** Create text post โ Write to **community's PDS** โ Index via Jetstream โ Verify in AppView โ·········- โ ๏ธ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering)···············- Post "type" is derived from structure (has embed? what embed type? has title? text length?)
+3
-3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
+3
-3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
···โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ···
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
···
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
···
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
···
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
···
-41
internal/atproto/lexicon/social/coves/post/delete.json
-41
internal/atproto/lexicon/social/coves/post/delete.json
···
-294
internal/atproto/lexicon/social/coves/post/get.json
-294
internal/atproto/lexicon/social/coves/post/get.json
···-"description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs.",-"description": "Post is blocked due to viewer blocking author/community, or community moderation",-"description": "What caused the block: viewer blocked author, viewer blocked community, or post was removed by moderators"
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
···
-80
internal/atproto/lexicon/social/coves/post/search.json
-80
internal/atproto/lexicon/social/coves/post/search.json
···
-104
internal/atproto/lexicon/social/coves/post/update.json
-104
internal/atproto/lexicon/social/coves/post/update.json
···
+156
internal/atproto/lexicon/com/atproto/label/defs.json
+156
internal/atproto/lexicon/com/atproto/label/defs.json
···+"description": "AT URI of the record, repository (account), or other resource that this label applies to."+"description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."+"description": "Metadata tags on an atproto record, published by the author within the record.",+"description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",+"description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",+"description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",+"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.",+"description": "Does the user need to have adult content enabled in order to configure this label?"+"description": "Strings which describe the label in the UI, localized into a specific language.",
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
···
+9
tests/lexicon-test-data/feed/vote-valid.json
+9
tests/lexicon-test-data/feed/vote-valid.json
-5
tests/lexicon-test-data/interaction/vote-valid.json
-5
tests/lexicon-test-data/interaction/vote-valid.json
-33
internal/atproto/lexicon/social/coves/actor/block.json
-33
internal/atproto/lexicon/social/coves/actor/block.json
···
-59
internal/atproto/lexicon/social/coves/actor/blockUser.json
-59
internal/atproto/lexicon/social/coves/actor/blockUser.json
···
-85
internal/atproto/lexicon/social/coves/actor/getSaved.json
-85
internal/atproto/lexicon/social/coves/actor/getSaved.json
···
-198
internal/atproto/lexicon/social/coves/actor/preferences.json
-198
internal/atproto/lexicon/social/coves/actor/preferences.json
···
-63
internal/atproto/lexicon/social/coves/actor/saveItem.json
-63
internal/atproto/lexicon/social/coves/actor/saveItem.json
···
-37
internal/atproto/lexicon/social/coves/actor/saved.json
-37
internal/atproto/lexicon/social/coves/actor/saved.json
···
-39
internal/atproto/lexicon/social/coves/actor/subscription.json
-39
internal/atproto/lexicon/social/coves/actor/subscription.json
···
-37
internal/atproto/lexicon/social/coves/actor/unblockUser.json
-37
internal/atproto/lexicon/social/coves/actor/unblockUser.json
···
-37
internal/atproto/lexicon/social/coves/actor/unsaveItem.json
-37
internal/atproto/lexicon/social/coves/actor/unsaveItem.json
···
+8
-1
cmd/validate-lexicon/main.go
+8
-1
cmd/validate-lexicon/main.go
···
-5
tests/lexicon-test-data/actor/block-invalid-did.json
-5
tests/lexicon-test-data/actor/block-invalid-did.json
-6
tests/lexicon-test-data/actor/block-valid.json
-6
tests/lexicon-test-data/actor/block-valid.json
-7
tests/lexicon-test-data/actor/preferences-invalid-enum.json
-7
tests/lexicon-test-data/actor/preferences-invalid-enum.json
-40
tests/lexicon-test-data/actor/preferences-valid.json
-40
tests/lexicon-test-data/actor/preferences-valid.json
···
-6
tests/lexicon-test-data/actor/profile-invalid-handle-format.json
-6
tests/lexicon-test-data/actor/profile-invalid-handle-format.json
-4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
-4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
-1
tests/lexicon-test-data/actor/profile-valid.json
-1
tests/lexicon-test-data/actor/profile-valid.json
-32
internal/atproto/lexicon/social/coves/federation/post.json
-32
internal/atproto/lexicon/social/coves/federation/post.json
···
-31
internal/atproto/lexicon/social/coves/interaction/share.json
-31
internal/atproto/lexicon/social/coves/interaction/share.json
···
-33
internal/atproto/lexicon/social/coves/interaction/tag.json
-33
internal/atproto/lexicon/social/coves/interaction/tag.json
···
-9
tests/lexicon-test-data/community/moderator-invalid-permissions.json
-9
tests/lexicon-test-data/community/moderator-invalid-permissions.json
···
-5
tests/lexicon-test-data/interaction/share-valid-no-community.json
-5
tests/lexicon-test-data/interaction/share-valid-no-community.json
-6
tests/lexicon-test-data/interaction/share-valid.json
-6
tests/lexicon-test-data/interaction/share-valid.json
-6
tests/lexicon-test-data/interaction/tag-invalid-empty.json
-6
tests/lexicon-test-data/interaction/tag-invalid-empty.json
-6
tests/lexicon-test-data/interaction/tag-valid-custom.json
-6
tests/lexicon-test-data/interaction/tag-valid-custom.json
-6
tests/lexicon-test-data/interaction/tag-valid-known.json
-6
tests/lexicon-test-data/interaction/tag-valid-known.json
+13
scripts/validate-schemas.sh
+13
scripts/validate-schemas.sh
+63
internal/db/migrations/016_create_comments_table.sql
+63
internal/db/migrations/016_create_comments_table.sql
···+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;+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}]})';
+125
internal/atproto/jetstream/comment_jetstream_connector.go
+125
internal/atproto/jetstream/comment_jetstream_connector.go
···+func NewCommentJetstreamConnector(consumer *CommentEventConsumer, wsURL string) *CommentJetstreamConnector {+if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+11
internal/core/comments/interfaces.go
+11
internal/core/comments/interfaces.go
···GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
+200
internal/core/unfurl/circuit_breaker.go
+200
internal/core/unfurl/circuit_breaker.go
···+"[UNFURL-CIRCUIT] Opening circuit for provider '%s' after %d consecutive failures. Last error: %v",
+175
internal/core/unfurl/circuit_breaker_test.go
+175
internal/core/unfurl/circuit_breaker_test.go
···
+202
internal/core/unfurl/kagi_test.go
+202
internal/core/unfurl/kagi_test.go
···
+269
internal/core/unfurl/opengraph_test.go
+269
internal/core/unfurl/opengraph_test.go
···+assert.Equal(t, "Meta description fallback", og.Description, "Should fall back to meta description")
+170
internal/core/unfurl/service.go
+170
internal/core/unfurl/service.go
···
+27
internal/core/unfurl/types.go
+27
internal/core/unfurl/types.go
···
+14
internal/core/unfurl/errors.go
+14
internal/core/unfurl/errors.go
···
+19
internal/core/unfurl/interfaces.go
+19
internal/core/unfurl/interfaces.go
···
+117
internal/core/unfurl/repository.go
+117
internal/core/unfurl/repository.go
···+func (r *postgresUnfurlRepo) Set(ctx context.Context, url string, result *UnfurlResult, ttl time.Duration) error {+_, err = r.db.ExecContext(ctx, query, url, result.Provider, metadataJSON, thumbnailURL, intervalStr)
+23
internal/db/migrations/017_create_unfurl_cache.sql
+23
internal/db/migrations/017_create_unfurl_cache.sql
···+COMMENT ON TABLE unfurl_cache IS 'Cache for oEmbed/URL unfurl results to reduce external API calls';+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.expires_at IS 'When this cache entry should be refetched (TTL-based)';
+9
internal/core/blobs/types.go
+9
internal/core/blobs/types.go
+81
internal/core/posts/blob_transform.go
+81
internal/core/posts/blob_transform.go
···
+312
internal/core/posts/blob_transform_test.go
+312
internal/core/posts/blob_transform_test.go
···+"http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:testcommunity&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",+expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafytest"+"http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",+expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafytest"
+4
-3
aggregators/kagi-news/src/main.py
+4
-3
aggregators/kagi-news/src/main.py
···
+134
scripts/post_streamable.py
+134
scripts/post_streamable.py
···+STREAMABLE_TITLE = "NBACentral - \"Your son don't wanna be here, we know it's your last weekend. Enjoy ..."+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."
-2
docker-compose.dev.yml
-2
docker-compose.dev.yml
+34
internal/db/migrations/018_migrate_comment_namespace.sql
+34
internal/db/migrations/018_migrate_comment_namespace.sql
···+-- Migration: Update comment URIs from social.coves.feed.comment to social.coves.community.comment+SET root_uri = REPLACE(root_uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')+SET parent_uri = REPLACE(parent_uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')+-- Rollback: Revert comment URIs from social.coves.community.comment to social.coves.feed.comment+SET root_uri = REPLACE(root_uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')+SET parent_uri = REPLACE(parent_uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')
+1
-1
internal/atproto/utils/record_utils.go
+1
-1
internal/atproto/utils/record_utils.go
···
+2
-2
internal/core/comments/view_models.go
+2
-2
internal/core/comments/view_models.go
······
+3
-2
internal/core/unfurl/providers.go
+3
-2
internal/core/unfurl/providers.go
···
+130
-28
docs/PRD_ALPHA_GO_LIVE.md
+130
-28
docs/PRD_ALPHA_GO_LIVE.md
······**Test Quality**: Enhanced with comprehensive database record verification to catch race conditions+- **Jetstream**: Connects to Bluesky's production firehose (wss://jetstream2.us-east.bsky.network)This document tracks the remaining work required to launch Coves alpha with real users. Focus is on critical functionality, security, and operational readiness.···+**Important**: Jetstream connects to Bluesky's production firehose, which automatically includes events from all production PDS instances (including coves.me once it's live)+- **Location**: [internal/atproto/jetstream/community_consumer.go](../internal/atproto/jetstream/community_consumer.go)······+**Total**: 16-23 hours (added 4-6 hours for PDS deployment, reduced from original due to did:web completion)···**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······-**๐ Major Milestone**: All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.+- 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
+19
-16
docs/PRD_BACKLOG.md
···-**Added:** 2025-10-11 | **Updated:** 2025-10-16 | **Effort:** 2-3 days | **Priority:** ALPHA BLOCKER+**Added:** 2025-10-11 | **Updated:** 2025-11-16 | **Completed:** 2025-11-16 | **Status:** โ DONE1. **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···-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:-3. **Auto-populate hostedByDID**: Remove from client API, derive from instance configuration in service layer+3. โ **Auto-populate hostedByDID**: Removed from client API, derived from instance configuration in service layer+- **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+- **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)
+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
···
+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
+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 handleSearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
+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
···
+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 | \
+1
-2
internal/api/handlers/aggregator/errors.go
+1
-2
internal/api/handlers/aggregator/errors.go
+1
-2
internal/api/handlers/comments/errors.go
+1
-2
internal/api/handlers/comments/errors.go
+1
-2
internal/api/handlers/comments/service_adapter.go
+1
-2
internal/api/handlers/comments/service_adapter.go
+2
-3
internal/api/handlers/community/block.go
+2
-3
internal/api/handlers/community/block.go
···
+2
-3
internal/api/handlers/community/create.go
+2
-3
internal/api/handlers/community/create.go
+1
-2
internal/api/handlers/community/errors.go
+1
-2
internal/api/handlers/community/errors.go
+1
-2
internal/api/handlers/community/list.go
+1
-2
internal/api/handlers/community/list.go
+1
-2
internal/api/handlers/communityFeed/errors.go
+1
-2
internal/api/handlers/communityFeed/errors.go
+1
-2
internal/api/handlers/discover/errors.go
+1
-2
internal/api/handlers/discover/errors.go
+2
-3
internal/api/handlers/post/errors.go
+2
-3
internal/api/handlers/post/errors.go
+1
-2
internal/api/handlers/timeline/errors.go
+1
-2
internal/api/handlers/timeline/errors.go
+1
-2
internal/atproto/jetstream/aggregator_consumer.go
+1
-2
internal/atproto/jetstream/aggregator_consumer.go
+2
-3
internal/atproto/jetstream/comment_consumer.go
+2
-3
internal/atproto/jetstream/comment_consumer.go
······
+3
-4
internal/atproto/jetstream/community_consumer.go
+3
-4
internal/atproto/jetstream/community_consumer.go
······
+2
-3
internal/atproto/jetstream/user_consumer.go
+2
-3
internal/atproto/jetstream/user_consumer.go
······
+1
-2
internal/core/aggregators/service.go
+1
-2
internal/core/aggregators/service.go
+1
-2
internal/core/blobs/service.go
+1
-2
internal/core/blobs/service.go
+3
-4
internal/core/comments/comment_service.go
+3
-4
internal/core/comments/comment_service.go
······
+5
-6
internal/core/comments/comment_service_test.go
+5
-6
internal/core/comments/comment_service_test.go
······-func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {+func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+1
-2
internal/core/communityFeeds/service.go
+1
-2
internal/core/communityFeeds/service.go
+1
-2
internal/core/communityFeeds/types.go
+1
-2
internal/core/communityFeeds/types.go
+1
-2
internal/core/discover/types.go
+1
-2
internal/core/discover/types.go
+5
-6
internal/core/posts/service.go
+5
-6
internal/core/posts/service.go
······
+1
-2
internal/core/timeline/types.go
+1
-2
internal/core/timeline/types.go
+1
-2
internal/db/postgres/aggregator_repo.go
+1
-2
internal/db/postgres/aggregator_repo.go
+1
-2
internal/db/postgres/comment_repo.go
+1
-2
internal/db/postgres/comment_repo.go
+1
-2
internal/db/postgres/community_repo_blocks.go
+1
-2
internal/db/postgres/community_repo_blocks.go
+1
-2
internal/db/postgres/community_repo_subscriptions.go
+1
-2
internal/db/postgres/community_repo_subscriptions.go
+1
-2
internal/db/postgres/discover_repo.go
+1
-2
internal/db/postgres/discover_repo.go
+1
-2
internal/db/postgres/timeline_repo.go
+1
-2
internal/db/postgres/timeline_repo.go
+7
-8
tests/e2e/error_recovery_test.go
+7
-8
tests/e2e/error_recovery_test.go
············+_, _ = w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
+3
-4
tests/integration/aggregator_test.go
+3
-4
tests/integration/aggregator_test.go
···
+14
-14
tests/integration/block_handle_resolution_test.go
+14
-14
tests/integration/block_handle_resolution_test.go
·······································
+3
-4
tests/integration/comment_consumer_test.go
+3
-4
tests/integration/comment_consumer_test.go
···
+3
-4
tests/integration/comment_query_test.go
+3
-4
tests/integration/comment_query_test.go
······
+4
-5
tests/integration/comment_vote_test.go
+4
-5
tests/integration/comment_vote_test.go
···
+2
-3
tests/integration/community_blocking_test.go
+2
-3
tests/integration/community_blocking_test.go
···
+4
-5
tests/integration/community_hostedby_security_test.go
+4
-5
tests/integration/community_hostedby_security_test.go
······// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain······
+2
-3
tests/integration/community_identifier_resolution_test.go
+2
-3
tests/integration/community_identifier_resolution_test.go
······
+2
-3
tests/integration/community_repo_test.go
+2
-3
tests/integration/community_repo_test.go
···
+2
-3
tests/integration/community_service_integration_test.go
+2
-3
tests/integration/community_service_integration_test.go
······
+3
-4
tests/integration/community_v2_validation_test.go
+3
-4
tests/integration/community_v2_validation_test.go
···
+6
-7
tests/integration/concurrent_scenarios_test.go
+6
-7
tests/integration/concurrent_scenarios_test.go
······
+4
-5
tests/integration/feed_test.go
+4
-5
tests/integration/feed_test.go
······
+3
-4
tests/integration/jetstream_consumer_test.go
+3
-4
tests/integration/jetstream_consumer_test.go
···
+3
-4
tests/integration/post_consumer_test.go
+3
-4
tests/integration/post_consumer_test.go
···
+5
-6
tests/integration/post_handler_test.go
+5
-6
tests/integration/post_handler_test.go
······
+5
-6
tests/integration/post_thumb_validation_test.go
+5
-6
tests/integration/post_thumb_validation_test.go
······
+5
-6
tests/integration/post_unfurl_test.go
+5
-6
tests/integration/post_unfurl_test.go
······
+2
-3
tests/integration/subscription_indexing_test.go
+2
-3
tests/integration/subscription_indexing_test.go
···
+23
static/.well-known/did.json
+23
static/.well-known/did.json
···
+4
-1
.gitignore
+4
-1
.gitignore
+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>' \···
+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) {
+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) {
-208
tests/integration/jwt_verification_test.go
-208
tests/integration/jwt_verification_test.go
···-verifiedClaims, err := auth.VerifyJWT(httptest.NewRequest("GET", "/", nil).Context(), accessToken, jwksFetcher)-authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS-testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-testHandler := authMiddleware.RequireAuth(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")
+16
-20
tests/integration/community_e2e_test.go
+16
-20
tests/integration/community_e2e_test.go
·········-routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators+routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators·····················
+7
-9
tests/integration/post_e2e_test.go
+7
-9
tests/integration/post_e2e_test.go
······postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, pdsURL) // nil aggregatorService, blobService, unfurlService for user-only tests···req := httptest.NewRequest("POST", "/xrpc/social.coves.community.post.create", bytes.NewReader(reqJSON))
+22
-19
tests/integration/user_journey_e2e_test.go
+22
-19
tests/integration/user_journey_e2e_test.go
·········-// 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.-routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators+routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators·····················t.Run("9. User B - Verify Timeline Feed Shows Subscribed Community Posts", func(t *testing.T) {
+2
go.mod
+2
go.mod
···
+5
go.sum
+5
go.sum
······
+5
.env.dev
+5
.env.dev
···
-73
cmd/genjwks/main.go
-73
cmd/genjwks/main.go
···
-122
internal/atproto/auth/did_key_fetcher.go
-122
internal/atproto/auth/did_key_fetcher.go
···-func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {-// Note: secp256k1 is handled separately in FetchPublicKey by returning indigo's PublicKey directly.-return nil, fmt.Errorf("unsupported JWK curve for Go ecdsa: %s (secp256k1 uses indigo)", jwk.Curve)
-1308
internal/atproto/auth/dpop_test.go
-1308
internal/atproto/auth/dpop_test.go
···-func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {-func createES256KDPoPProof(t *testing.T, key *testES256KKey, method, uri string, iat time.Time, jti string) string {
+1
docker-compose.prod.yml
+1
docker-compose.prod.yml
+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)
+16
tests/integration/helpers.go
+16
tests/integration/helpers.go
······
+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
···
+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
······