···1212 "net/http"
1313 "regexp"
1414 "strings"
1515+ "sync"
1516 "time"
1617)
1718···2021var 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])?$`)
21222223type communityService struct {
2323- repo Repository
2424- provisioner *PDSAccountProvisioner
2424+ // Interfaces and pointers first (better alignment)
2525+ repo Repository
2626+ provisioner *PDSAccountProvisioner
2727+2828+ // Token refresh concurrency control
2929+ // Each community gets its own mutex to prevent concurrent refresh attempts
3030+ refreshMutexes map[string]*sync.Mutex
3131+3232+ // Strings
2533 pdsURL string
2634 instanceDID string
2735 instanceDomain string
2836 pdsAccessToken string
3737+3838+ // Sync primitives last
3939+ mapMutex sync.RWMutex // Protects refreshMutexes map itself
2940}
4141+4242+const (
4343+ // Maximum recommended size for mutex cache (warning threshold, not hard limit)
4444+ // At 10,000 entries × 16 bytes = ~160KB memory (negligible overhead)
4545+ // Map can grow larger in production - even 100,000 entries = 1.6MB is acceptable
4646+ maxMutexCacheSize = 10000
4747+)
30483149// NewCommunityService creates a new community service
3250func NewCommunityService(repo Repository, pdsURL, instanceDID, instanceDomain string, provisioner *PDSAccountProvisioner) Service {
···5068 instanceDID: instanceDID,
5169 instanceDomain: instanceDomain,
5270 provisioner: provisioner,
7171+ refreshMutexes: make(map[string]*sync.Mutex),
5372 }
5473}
5574···235254 return nil, err
236255 }
237256257257+ // CRITICAL: Ensure fresh PDS access token before write operation
258258+ // Community PDS tokens expire every ~2 hours and must be refreshed
259259+ existing, err = s.ensureFreshToken(ctx, existing)
260260+ if err != nil {
261261+ return nil, fmt.Errorf("failed to ensure fresh credentials: %w", err)
262262+ }
263263+238264 // Authorization: verify user is the creator
239265 // TODO(Communities-Auth): Add moderator check when moderation system is implemented
240266 if existing.CreatedByDID != req.UpdatedByDID {
···347373 updated.UpdatedAt = time.Now()
348374349375 return &updated, nil
376376+}
377377+378378+// getOrCreateRefreshMutex returns a mutex for the given community DID
379379+// Thread-safe with read-lock fast path for existing entries
380380+// SAFETY: Does NOT evict entries to avoid race condition where:
381381+// 1. Thread A holds mutex for community-123
382382+// 2. Thread B evicts community-123 from map
383383+// 3. Thread C creates NEW mutex for community-123
384384+// 4. Now two threads can refresh community-123 concurrently (mutex defeated!)
385385+func (s *communityService) getOrCreateRefreshMutex(did string) *sync.Mutex {
386386+ // Fast path: check if mutex already exists (read lock)
387387+ s.mapMutex.RLock()
388388+ mutex, exists := s.refreshMutexes[did]
389389+ s.mapMutex.RUnlock()
390390+391391+ if exists {
392392+ return mutex
393393+ }
394394+395395+ // Slow path: create new mutex (write lock)
396396+ s.mapMutex.Lock()
397397+ defer s.mapMutex.Unlock()
398398+399399+ // Double-check after acquiring write lock (another goroutine might have created it)
400400+ mutex, exists = s.refreshMutexes[did]
401401+ if exists {
402402+ return mutex
403403+ }
404404+405405+ // Create new mutex
406406+ mutex = &sync.Mutex{}
407407+ s.refreshMutexes[did] = mutex
408408+409409+ // SAFETY: No eviction to prevent race condition
410410+ // Map will grow beyond maxMutexCacheSize but this is safer than evicting in-use mutexes
411411+ if len(s.refreshMutexes) > maxMutexCacheSize {
412412+ memoryKB := len(s.refreshMutexes) * 16 / 1024
413413+ 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",
414414+ len(s.refreshMutexes), maxMutexCacheSize, memoryKB)
415415+ }
416416+417417+ return mutex
418418+}
419419+420420+// ensureFreshToken checks if a community's access token needs refresh and updates if needed
421421+// Returns updated community with fresh credentials (or original if no refresh needed)
422422+// Thread-safe: Uses per-community mutex to prevent concurrent refresh attempts
423423+func (s *communityService) ensureFreshToken(ctx context.Context, community *Community) (*Community, error) {
424424+ // Get or create mutex for this specific community DID
425425+ mutex := s.getOrCreateRefreshMutex(community.DID)
426426+427427+ // Lock for this specific community (allows other communities to refresh concurrently)
428428+ mutex.Lock()
429429+ defer mutex.Unlock()
430430+431431+ // Re-fetch community from DB (another goroutine might have already refreshed it)
432432+ fresh, err := s.repo.GetByDID(ctx, community.DID)
433433+ if err != nil {
434434+ return nil, fmt.Errorf("failed to re-fetch community: %w", err)
435435+ }
436436+437437+ // Check if token needs refresh (5-minute buffer before expiration)
438438+ needsRefresh, err := NeedsRefresh(fresh.PDSAccessToken)
439439+ if err != nil {
440440+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: token_parse_failed, Error: %v", fresh.DID, err)
441441+ return nil, fmt.Errorf("failed to check token expiration: %w", err)
442442+ }
443443+444444+ if !needsRefresh {
445445+ // Token still valid, no refresh needed
446446+ return fresh, nil
447447+ }
448448+449449+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: token_refresh_started, Message: Access token expiring soon", fresh.DID)
450450+451451+ // Attempt token refresh using refresh token
452452+ newAccessToken, newRefreshToken, err := refreshPDSToken(ctx, fresh.PDSURL, fresh.PDSAccessToken, fresh.PDSRefreshToken)
453453+ if err != nil {
454454+ // Check if refresh token expired (need password fallback)
455455+ if strings.Contains(err.Error(), "expired or invalid") {
456456+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: refresh_token_expired, Message: Re-authenticating with password", fresh.DID)
457457+458458+ // Fallback: Re-authenticate with stored password
459459+ newAccessToken, newRefreshToken, err = reauthenticateWithPassword(
460460+ ctx,
461461+ fresh.PDSURL,
462462+ fresh.PDSEmail,
463463+ fresh.PDSPassword, // Retrieved decrypted from DB
464464+ )
465465+ if err != nil {
466466+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: password_auth_failed, Error: %v", fresh.DID, err)
467467+ return nil, fmt.Errorf("failed to re-authenticate community: %w", err)
468468+ }
469469+470470+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: password_fallback_success, Message: Re-authenticated after refresh token expiry", fresh.DID)
471471+ } else {
472472+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: refresh_failed, Error: %v", fresh.DID, err)
473473+ return nil, fmt.Errorf("failed to refresh token: %w", err)
474474+ }
475475+ }
476476+477477+ // CRITICAL: Update database with new tokens immediately
478478+ // Refresh tokens are SINGLE-USE - old one is now invalid
479479+ // Use retry logic to handle transient DB failures
480480+ const maxRetries = 3
481481+ var updateErr error
482482+ for attempt := 0; attempt < maxRetries; attempt++ {
483483+ updateErr = s.repo.UpdateCredentials(ctx, fresh.DID, newAccessToken, newRefreshToken)
484484+ if updateErr == nil {
485485+ break // Success
486486+ }
487487+488488+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: db_update_retry, Attempt: %d/%d, Error: %v",
489489+ fresh.DID, attempt+1, maxRetries, updateErr)
490490+491491+ if attempt < maxRetries-1 {
492492+ // Exponential backoff: 100ms, 200ms, 400ms
493493+ backoff := time.Duration(1<<attempt) * 100 * time.Millisecond
494494+ time.Sleep(backoff)
495495+ }
496496+ }
497497+498498+ if updateErr != nil {
499499+ // CRITICAL: Community is now locked out - old refresh token invalid, new one not saved
500500+ log.Printf("[TOKEN-REFRESH] CRITICAL: Community %s LOCKED OUT - failed to persist credentials after %d retries: %v",
501501+ fresh.DID, maxRetries, updateErr)
502502+ // TODO: Send alert to monitoring system (add in Beta)
503503+ return nil, fmt.Errorf("failed to persist refreshed credentials after %d retries (COMMUNITY LOCKED OUT): %w",
504504+ maxRetries, updateErr)
505505+ }
506506+507507+ // Return updated community object with fresh tokens
508508+ updatedCommunity := *fresh
509509+ updatedCommunity.PDSAccessToken = newAccessToken
510510+ updatedCommunity.PDSRefreshToken = newRefreshToken
511511+512512+ log.Printf("[TOKEN-REFRESH] Community: %s, Event: token_refreshed, Message: Access token refreshed successfully", fresh.DID)
513513+514514+ return &updatedCommunity, nil
350515}
351516352517// ListCommunities queries AppView DB for communities with filters
+99
internal/core/communities/token_refresh.go
···11+package communities
22+33+import (
44+ "context"
55+ "errors"
66+ "fmt"
77+ "strings"
88+99+ "github.com/bluesky-social/indigo/api/atproto"
1010+ "github.com/bluesky-social/indigo/xrpc"
1111+)
1212+1313+// refreshPDSToken exchanges a refresh token for new access and refresh tokens
1414+// Uses com.atproto.server.refreshSession endpoint via Indigo SDK
1515+// CRITICAL: Refresh tokens are single-use - old refresh token is revoked on success
1616+func refreshPDSToken(ctx context.Context, pdsURL, currentAccessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) {
1717+ if pdsURL == "" {
1818+ return "", "", fmt.Errorf("PDS URL is required")
1919+ }
2020+ if refreshToken == "" {
2121+ return "", "", fmt.Errorf("refresh token is required")
2222+ }
2323+2424+ // Create XRPC client with auth credentials
2525+ // The refresh endpoint requires authentication with the refresh token
2626+ client := &xrpc.Client{
2727+ Host: pdsURL,
2828+ Auth: &xrpc.AuthInfo{
2929+ AccessJwt: currentAccessToken, // Can be expired (not used for refresh auth)
3030+ RefreshJwt: refreshToken, // This is what authenticates the refresh request
3131+ },
3232+ }
3333+3434+ // Call com.atproto.server.refreshSession
3535+ output, err := atproto.ServerRefreshSession(ctx, client)
3636+ if err != nil {
3737+ // Check for expired refresh token (401 Unauthorized)
3838+ // Try typed error first (more reliable), fallback to string check
3939+ var xrpcErr *xrpc.Error
4040+ if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 401 {
4141+ return "", "", fmt.Errorf("refresh token expired or invalid (needs password re-auth)")
4242+ }
4343+4444+ // Fallback: string-based detection (in case error isn't wrapped as xrpc.Error)
4545+ errStr := err.Error()
4646+ if strings.Contains(errStr, "401") || strings.Contains(errStr, "Unauthorized") {
4747+ return "", "", fmt.Errorf("refresh token expired or invalid (needs password re-auth)")
4848+ }
4949+5050+ return "", "", fmt.Errorf("failed to refresh session: %w", err)
5151+ }
5252+5353+ // Validate response
5454+ if output.AccessJwt == "" || output.RefreshJwt == "" {
5555+ return "", "", fmt.Errorf("refresh response missing tokens")
5656+ }
5757+5858+ return output.AccessJwt, output.RefreshJwt, nil
5959+}
6060+6161+// reauthenticateWithPassword creates a new session using stored credentials
6262+// This is the fallback when refresh tokens expire (after ~2 months)
6363+// Uses com.atproto.server.createSession endpoint via Indigo SDK
6464+func reauthenticateWithPassword(ctx context.Context, pdsURL, email, password string) (accessToken, refreshToken string, err error) {
6565+ if pdsURL == "" {
6666+ return "", "", fmt.Errorf("PDS URL is required")
6767+ }
6868+ if email == "" {
6969+ return "", "", fmt.Errorf("email is required")
7070+ }
7171+ if password == "" {
7272+ return "", "", fmt.Errorf("password is required")
7373+ }
7474+7575+ // Create unauthenticated XRPC client
7676+ client := &xrpc.Client{
7777+ Host: pdsURL,
7878+ }
7979+8080+ // Prepare createSession input
8181+ // The identifier can be either email or handle
8282+ input := &atproto.ServerCreateSession_Input{
8383+ Identifier: email,
8484+ Password: password,
8585+ }
8686+8787+ // Call com.atproto.server.createSession
8888+ output, err := atproto.ServerCreateSession(ctx, client, input)
8989+ if err != nil {
9090+ return "", "", fmt.Errorf("failed to create session: %w", err)
9191+ }
9292+9393+ // Validate response
9494+ if output.AccessJwt == "" || output.RefreshJwt == "" {
9595+ return "", "", fmt.Errorf("createSession response missing tokens")
9696+ }
9797+9898+ return output.AccessJwt, output.RefreshJwt, nil
9999+}
+66
internal/core/communities/token_utils.go
···11+package communities
22+33+import (
44+ "encoding/base64"
55+ "encoding/json"
66+ "fmt"
77+ "strings"
88+ "time"
99+)
1010+1111+// parseJWTExpiration extracts the expiration time from a JWT access token
1212+// This function does NOT verify the signature - it only parses the exp claim
1313+// atproto access tokens use standard JWT format with 'exp' claim (Unix timestamp)
1414+func parseJWTExpiration(token string) (time.Time, error) {
1515+ // Remove "Bearer " prefix if present
1616+ token = strings.TrimPrefix(token, "Bearer ")
1717+ token = strings.TrimSpace(token)
1818+1919+ // JWT format: header.payload.signature
2020+ parts := strings.Split(token, ".")
2121+ if len(parts) != 3 {
2222+ return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
2323+ }
2424+2525+ // Decode payload (second part) - use RawURLEncoding (no padding)
2626+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
2727+ if err != nil {
2828+ return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
2929+ }
3030+3131+ // Extract exp claim (Unix timestamp)
3232+ var claims struct {
3333+ Exp int64 `json:"exp"` // Expiration time (seconds since Unix epoch)
3434+ }
3535+ if err := json.Unmarshal(payload, &claims); err != nil {
3636+ return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
3737+ }
3838+3939+ if claims.Exp == 0 {
4040+ return time.Time{}, fmt.Errorf("JWT missing 'exp' claim")
4141+ }
4242+4343+ // Convert Unix timestamp to time.Time
4444+ return time.Unix(claims.Exp, 0), nil
4545+}
4646+4747+// NeedsRefresh checks if an access token should be refreshed
4848+// Returns true if the token expires within the next 5 minutes (or is already expired)
4949+// Uses a 5-minute buffer to ensure we refresh before actual expiration
5050+func NeedsRefresh(accessToken string) (bool, error) {
5151+ if accessToken == "" {
5252+ return false, fmt.Errorf("access token is empty")
5353+ }
5454+5555+ expiration, err := parseJWTExpiration(accessToken)
5656+ if err != nil {
5757+ return false, fmt.Errorf("failed to parse token expiration: %w", err)
5858+ }
5959+6060+ // Refresh if token expires within 5 minutes
6161+ // This prevents service interruptions from expired tokens
6262+ bufferTime := 5 * time.Minute
6363+ expiresWithinBuffer := time.Now().Add(bufferTime).After(expiration)
6464+6565+ return expiresWithinBuffer, nil
6666+}
+26
internal/db/postgres/community_repo.go
···291291 return community, nil
292292}
293293294294+// UpdateCredentials atomically updates community's PDS access and refresh tokens
295295+// CRITICAL: Both tokens must be updated together because refresh tokens are single-use
296296+// After a successful token refresh, the old refresh token is immediately revoked by the PDS
297297+func (r *postgresCommunityRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error {
298298+ query := `
299299+ UPDATE communities
300300+ SET
301301+ pds_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
302302+ pds_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
303303+ updated_at = NOW()
304304+ WHERE did = $1
305305+ RETURNING did`
306306+307307+ var returnedDID string
308308+ err := r.db.QueryRowContext(ctx, query, did, accessToken, refreshToken).Scan(&returnedDID)
309309+310310+ if err == sql.ErrNoRows {
311311+ return communities.ErrCommunityNotFound
312312+ }
313313+ if err != nil {
314314+ return fmt.Errorf("failed to update credentials: %w", err)
315315+ }
316316+317317+ return nil
318318+}
319319+294320// Delete removes a community from the database
295321func (r *postgresCommunityRepo) Delete(ctx context.Context, did string) error {
296322 query := `DELETE FROM communities WHERE did = $1`
+243
tests/integration/token_refresh_test.go
···11+package integration
22+33+import (
44+ "Coves/internal/core/communities"
55+ "Coves/internal/db/postgres"
66+ "context"
77+ "encoding/base64"
88+ "encoding/json"
99+ "fmt"
1010+ "testing"
1111+ "time"
1212+)
1313+1414+// TestTokenRefresh_ExpirationDetection tests the NeedsRefresh function with various token states
1515+func TestTokenRefresh_ExpirationDetection(t *testing.T) {
1616+ tests := []struct {
1717+ name string
1818+ token string
1919+ shouldRefresh bool
2020+ expectError bool
2121+ }{
2222+ {
2323+ name: "Token expiring in 2 minutes (should refresh)",
2424+ token: createTestJWT(time.Now().Add(2 * time.Minute)),
2525+ shouldRefresh: true,
2626+ expectError: false,
2727+ },
2828+ {
2929+ name: "Token expiring in 10 minutes (should not refresh)",
3030+ token: createTestJWT(time.Now().Add(10 * time.Minute)),
3131+ shouldRefresh: false,
3232+ expectError: false,
3333+ },
3434+ {
3535+ name: "Token already expired (should refresh)",
3636+ token: createTestJWT(time.Now().Add(-1 * time.Minute)),
3737+ shouldRefresh: true,
3838+ expectError: false,
3939+ },
4040+ {
4141+ name: "Token expiring in exactly 5 minutes (should not refresh - edge case)",
4242+ token: createTestJWT(time.Now().Add(6 * time.Minute)),
4343+ shouldRefresh: false,
4444+ expectError: false,
4545+ },
4646+ {
4747+ name: "Token expiring in 4 minutes (should refresh)",
4848+ token: createTestJWT(time.Now().Add(4 * time.Minute)),
4949+ shouldRefresh: true,
5050+ expectError: false,
5151+ },
5252+ {
5353+ name: "Invalid JWT format (too many parts)",
5454+ token: "not.a.valid.jwt.format.extra",
5555+ shouldRefresh: false,
5656+ expectError: true,
5757+ },
5858+ {
5959+ name: "Invalid JWT format (too few parts)",
6060+ token: "invalid.token",
6161+ shouldRefresh: false,
6262+ expectError: true,
6363+ },
6464+ {
6565+ name: "Empty token",
6666+ token: "",
6767+ shouldRefresh: false,
6868+ expectError: true,
6969+ },
7070+ }
7171+7272+ for _, tt := range tests {
7373+ t.Run(tt.name, func(t *testing.T) {
7474+ result, err := communities.NeedsRefresh(tt.token)
7575+7676+ if tt.expectError {
7777+ if err == nil {
7878+ t.Errorf("Expected error but got none")
7979+ }
8080+ return
8181+ }
8282+8383+ if err != nil {
8484+ t.Fatalf("Unexpected error: %v", err)
8585+ }
8686+8787+ if result != tt.shouldRefresh {
8888+ t.Errorf("Expected NeedsRefresh=%v, got %v", tt.shouldRefresh, result)
8989+ }
9090+ })
9191+ }
9292+}
9393+9494+// TestTokenRefresh_UpdateCredentials tests the repository UpdateCredentials method
9595+func TestTokenRefresh_UpdateCredentials(t *testing.T) {
9696+ if testing.Short() {
9797+ t.Skip("skipping integration test in short mode")
9898+ }
9999+100100+ ctx := context.Background()
101101+ db := setupTestDB(t)
102102+ defer func() {
103103+ if err := db.Close(); err != nil {
104104+ t.Logf("Failed to close database: %v", err)
105105+ }
106106+ }()
107107+108108+ repo := postgres.NewCommunityRepository(db)
109109+110110+ // Create a test community first
111111+ community := &communities.Community{
112112+ DID: "did:plc:test123",
113113+ Handle: "test.communities.coves.social",
114114+ Name: "test",
115115+ OwnerDID: "did:plc:test123",
116116+ CreatedByDID: "did:plc:creator",
117117+ HostedByDID: "did:web:coves.social",
118118+ PDSEmail: "test@coves.social",
119119+ PDSPassword: "original-password",
120120+ PDSAccessToken: "original-access-token",
121121+ PDSRefreshToken: "original-refresh-token",
122122+ PDSURL: "http://localhost:3001",
123123+ Visibility: "public",
124124+ MemberCount: 0,
125125+ SubscriberCount: 0,
126126+ RecordURI: "at://did:plc:test123/social.coves.community.profile/self",
127127+ RecordCID: "bafytest",
128128+ }
129129+130130+ created, err := repo.Create(ctx, community)
131131+ if err != nil {
132132+ t.Fatalf("Failed to create test community: %v", err)
133133+ }
134134+135135+ // Update credentials
136136+ newAccessToken := "new-access-token-12345"
137137+ newRefreshToken := "new-refresh-token-67890"
138138+139139+ err = repo.UpdateCredentials(ctx, created.DID, newAccessToken, newRefreshToken)
140140+ if err != nil {
141141+ t.Fatalf("UpdateCredentials failed: %v", err)
142142+ }
143143+144144+ // Verify tokens were updated
145145+ retrieved, err := repo.GetByDID(ctx, created.DID)
146146+ if err != nil {
147147+ t.Fatalf("Failed to retrieve community: %v", err)
148148+ }
149149+150150+ if retrieved.PDSAccessToken != newAccessToken {
151151+ t.Errorf("Access token not updated: expected %q, got %q", newAccessToken, retrieved.PDSAccessToken)
152152+ }
153153+154154+ if retrieved.PDSRefreshToken != newRefreshToken {
155155+ t.Errorf("Refresh token not updated: expected %q, got %q", newRefreshToken, retrieved.PDSRefreshToken)
156156+ }
157157+158158+ // Verify password unchanged (should not be affected)
159159+ if retrieved.PDSPassword != "original-password" {
160160+ t.Errorf("Password should remain unchanged: expected %q, got %q", "original-password", retrieved.PDSPassword)
161161+ }
162162+}
163163+164164+// TestTokenRefresh_E2E_UpdateAfterTokenRefresh tests end-to-end token refresh during community update
165165+func TestTokenRefresh_E2E_UpdateAfterTokenRefresh(t *testing.T) {
166166+ if testing.Short() {
167167+ t.Skip("skipping E2E test in short mode")
168168+ }
169169+170170+ ctx := context.Background()
171171+ db := setupTestDB(t)
172172+ defer func() {
173173+ if err := db.Close(); err != nil {
174174+ t.Logf("Failed to close database: %v", err)
175175+ }
176176+ }()
177177+178178+ // This test requires a real PDS for token refresh
179179+ // For now, we'll test the token expiration detection logic
180180+ // Full E2E test with PDS will be added in manual testing phase
181181+182182+ repo := postgres.NewCommunityRepository(db)
183183+184184+ // Create community with expiring token
185185+ expiringToken := createTestJWT(time.Now().Add(2 * time.Minute)) // Expires in 2 minutes
186186+187187+ community := &communities.Community{
188188+ DID: "did:plc:expiring123",
189189+ Handle: "expiring.communities.coves.social",
190190+ Name: "expiring",
191191+ OwnerDID: "did:plc:expiring123",
192192+ CreatedByDID: "did:plc:creator",
193193+ HostedByDID: "did:web:coves.social",
194194+ PDSEmail: "expiring@coves.social",
195195+ PDSPassword: "test-password",
196196+ PDSAccessToken: expiringToken,
197197+ PDSRefreshToken: "test-refresh-token",
198198+ PDSURL: "http://localhost:3001",
199199+ Visibility: "public",
200200+ RecordURI: "at://did:plc:expiring123/social.coves.community.profile/self",
201201+ RecordCID: "bafytest",
202202+ }
203203+204204+ created, err := repo.Create(ctx, community)
205205+ if err != nil {
206206+ t.Fatalf("Failed to create community: %v", err)
207207+ }
208208+209209+ // Verify token is stored
210210+ if created.PDSAccessToken != expiringToken {
211211+ t.Errorf("Token not stored correctly")
212212+ }
213213+214214+ t.Logf("✅ Created community with expiring token (expires in 2 minutes)")
215215+ t.Logf(" Community DID: %s", created.DID)
216216+ t.Logf(" NOTE: Full refresh flow requires real PDS - tested in manual/staging tests")
217217+}
218218+219219+// Helper: Create a test JWT with specific expiration time
220220+func createTestJWT(expiresAt time.Time) string {
221221+ // Create JWT header
222222+ header := map[string]interface{}{
223223+ "alg": "ES256",
224224+ "typ": "JWT",
225225+ }
226226+ headerJSON, _ := json.Marshal(header)
227227+ headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
228228+229229+ // Create JWT payload with expiration
230230+ payload := map[string]interface{}{
231231+ "sub": "did:plc:test",
232232+ "iss": "https://pds.example.com",
233233+ "exp": expiresAt.Unix(),
234234+ "iat": time.Now().Unix(),
235235+ }
236236+ payloadJSON, _ := json.Marshal(payload)
237237+ payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
238238+239239+ // Fake signature (not verified in our tests)
240240+ signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
241241+242242+ return fmt.Sprintf("%s.%s.%s", headerB64, payloadB64, signature)
243243+}