+103
-5
docs/PRD_BACKLOG.md
+103
-5
docs/PRD_BACKLOG.md
······**Problem:** Community PDS access tokens expire (~2hrs). Updates fail until manual intervention.-**Solution:** Auto-refresh tokens before PDS operations. Parse JWT exp claim, use refresh token when expired, update DB.+- ✅ JWT expiration parsing without signature verification (`parseJWTExpiration`, `needsRefresh`)+- [internal/core/communities/token_utils.go](../internal/core/communities/token_utils.go) - JWT parsing utilities+- [internal/core/communities/token_refresh.go](../internal/core/communities/token_refresh.go) - Refresh and re-auth logic+- [tests/integration/token_refresh_test.go](../tests/integration/token_refresh_test.go) - Integration tests+- [internal/core/communities/service.go](../internal/core/communities/service.go) - Added `ensureFreshToken` + concurrency control+- [internal/core/communities/interfaces.go](../internal/core/communities/interfaces.go) - Added `UpdateCredentials` interface+- [internal/db/postgres/community_repo.go](../internal/db/postgres/community_repo.go) - Implemented `UpdateCredentials`+**Documentation:** See [IMPLEMENTATION_TOKEN_REFRESH.md](../docs/IMPLEMENTATION_TOKEN_REFRESH.md) for full details+**Impact:** ✅ Communities can now be updated 24+ hours after creation without manual intervention···+**Problem:** Current write-forward implementation assumes all users are on the same PDS as the Coves instance. This breaks federation when users from external PDSs try to interact with communities.+- [service.go:736](../internal/core/communities/service.go#L736): `createRecordOnPDSAs` hardcodes `s.pdsURL`+- [service.go:753](../internal/core/communities/service.go#L753): `putRecordOnPDSAs` hardcodes `s.pdsURL`+- [service.go:767](../internal/core/communities/service.go#L767): `deleteRecordOnPDSAs` hardcodes `s.pdsURL`···+- [internal/core/communities/service.go](../internal/core/communities/service.go) - Added `ensureFreshToken` method+- [internal/core/communities/interfaces.go](../internal/core/communities/interfaces.go) - Added `UpdateCredentials` interface+- [internal/db/postgres/community_repo.go](../internal/db/postgres/community_repo.go) - Implemented `UpdateCredentials`
+25
-12
docs/PRD_COMMUNITIES.md
+25
-12
docs/PRD_COMMUNITIES.md
·········+- [x] **Automatic Token Refresh:** Tokens refresh 5 minutes before expiration (completed 2025-10-17)···+- [x] **Token Refresh Tests:** JWT parsing, credential updates, concurrency (completed 2025-10-17)···+- ✅ Service: `BlockCommunity()` / `UnblockCommunity()` / `GetBlockedCommunities()` / `IsBlocked()`···
+3
internal/core/communities/interfaces.go
+3
internal/core/communities/interfaces.go
···List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) // Returns communities + total count
+167
-2
internal/core/communities/service.go
+167
-2
internal/core/communities/service.go
······var communityHandleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)func NewCommunityService(repo Repository, pdsURL, instanceDID, instanceDomain string, provisioner *PDSAccountProvisioner) Service {·········+log.Printf("[TOKEN-REFRESH] WARN: Mutex cache size (%d) exceeds recommended limit (%d) - this is safe but may indicate high community churn. Memory usage: ~%d KB",+func (s *communityService) ensureFreshToken(ctx context.Context, community *Community) (*Community, error) {+log.Printf("[TOKEN-REFRESH] Community: %s, Event: token_parse_failed, Error: %v", fresh.DID, err)+log.Printf("[TOKEN-REFRESH] Community: %s, Event: token_refresh_started, Message: Access token expiring soon", fresh.DID)+newAccessToken, newRefreshToken, err := refreshPDSToken(ctx, fresh.PDSURL, fresh.PDSAccessToken, fresh.PDSRefreshToken)+log.Printf("[TOKEN-REFRESH] Community: %s, Event: refresh_token_expired, Message: Re-authenticating with password", fresh.DID)+log.Printf("[TOKEN-REFRESH] Community: %s, Event: password_auth_failed, Error: %v", fresh.DID, err)+log.Printf("[TOKEN-REFRESH] Community: %s, Event: password_fallback_success, Message: Re-authenticated after refresh token expiry", fresh.DID)+log.Printf("[TOKEN-REFRESH] Community: %s, Event: db_update_retry, Attempt: %d/%d, Error: %v",+log.Printf("[TOKEN-REFRESH] CRITICAL: Community %s LOCKED OUT - failed to persist credentials after %d retries: %v",+return nil, fmt.Errorf("failed to persist refreshed credentials after %d retries (COMMUNITY LOCKED OUT): %w",+log.Printf("[TOKEN-REFRESH] Community: %s, Event: token_refreshed, Message: Access token refreshed successfully", fresh.DID)
+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
···
+26
internal/db/postgres/community_repo.go
+26
internal/db/postgres/community_repo.go
···+func (r *postgresCommunityRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error {+pds_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),+pds_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
+243
tests/integration/token_refresh_test.go
+243
tests/integration/token_refresh_test.go
···+// TestTokenRefresh_ExpirationDetection tests the NeedsRefresh function with various token states+t.Errorf("Access token not updated: expected %q, got %q", newAccessToken, retrieved.PDSAccessToken)+t.Errorf("Refresh token not updated: expected %q, got %q", newRefreshToken, retrieved.PDSRefreshToken)+t.Errorf("Password should remain unchanged: expected %q, got %q", "original-password", retrieved.PDSPassword)+// TestTokenRefresh_E2E_UpdateAfterTokenRefresh tests end-to-end token refresh during community update