Skip to content

Commit a85599f

Browse files
committed
1. User sets "logged-out visibility: disabled" which adds the !no-unauthenticated self-label to their profile
2. The AppView returns the profile data WITH the label 3. The patched @atproto/api library was always skipping this label in moderation checks (line 70 in decision.js) 4. So the moderation system never created a "blur" for it 5. The ScreenHider component never triggered 6. Profile was visible! fallback mechanism only works with logged users. and checks the label for privacy no-authenticated label. This resolves the issue folkloringjamaica.blacksky.app was experiencing @ind3fatigable.online was experiencing mute and follow issues which surrounded cache invalidation issues that was resolved. I tested with the follow: Great! Now for Issue 2 (Unfollows/Mutes Not Persisting), here's what you should test: Test Plan for Issue 2 Test 1: Unfollow Persistence 1. Follow a user (if not already following) 2. Unfollow them by clicking the "Following" button 3. ✅ Expected: Button immediately changes to "Follow" 4. Wait 1-2 seconds (for the AppView indexing lag compensation to kick in) 5. Refresh the page (hard refresh or navigate away and back) 6. ✅ Expected: Still shows "Follow" button (unfollow persisted) 7. Bonus: Check that their posts no longer appear in your Following feed Test 2: Follow/Unfollow Multiple Times Rapidly 1. Follow a user 2. Immediately unfollow them 3. Repeat this 2-3 times quickly (stress test the mutation queue) 4. Refresh the page 5. ✅ Expected: Final state matches what you see (if you ended on "Follow", it stays "Follow" after refresh) Test 3: Mute Persistence 1. Navigate to a user's profile 2. Mute them (via the three-dot menu → Mute) 3. ✅ Expected: Profile should indicate they're muted 4. Refresh the page 5. ✅ Expected: Still shows as muted 6. Go to your home feed 7. ✅ Expected: Their posts should no longer appear in your feed Test 4: Unmute Persistence 1. Unmute a previously muted user 2. Refresh the page 3. ✅ Expected: No longer shows as muted 4. Check your feed 5. ✅ Expected: Their posts can now appear again Test 5: Cross-Tab Consistency (Advanced) 1. Open the same profile in two browser tabs 2. In Tab 1: Unfollow the user 3. In Tab 2: Refresh the page after 1-2 seconds 4. ✅ Expected: Tab 2 should now show "Follow" button (cache was invalidated) What Was Fixed: - Before: Cache wasn't being invalidated, so refreshing would show stale data from cache - After: - Immediate cache invalidation on mutation success - 500ms delayed refetch to handle AppView indexing lag - Comprehensive invalidation of profile, feed, and related caches If Tests Fail: If you still see the old behavior (unfollows/mutes reverting after refresh), let me know and we can investigate further. The fixes should handle: - ✅ Cache invalidation - ✅ AppView indexing lag - ✅ Optimistic updates staying in sync with server state The issue identified was: **Date:** November 2, 2025 **Scope:** Analysis of fallback mechanism impact on social interactions, user safety, and moderation **Repository:** blacksky.community --- **User-Reported Issue: "Unfollows don't stick, mutes don't persist"** After comprehensive investigation, the root cause is **NOT the fallback mechanism** as initially suspected. The actual causes are: 1. **Missing cache invalidation** in the unfollow mutation (`useProfileUnfollowMutation`) 2. **Incomplete cache invalidation** in the mute mutation (`useProfileMuteMutation`) 3. **AppView indexing lag** creating race conditions with optimistic UI updates 4. These bugs affect **BOTH** fallback mode AND normal AppView queries **Critical Safety Finding:** The fallback mechanism has a **CRITICAL SAFETY GAP** regarding moderation labeler subscriptions: - 🔴 **HIGH SEVERITY**: Moderation labels are completely bypassed in fallback mode - Fallback always returns `labels: []` regardless of user's labeler subscriptions - Users who subscribe to safety labelers (CSAM, self-harm, harassment filters) will see harmful content WITHOUT warnings when fallback is active - Vulnerable populations are at increased risk **Fallback Safety Assessment by Category:** | Category | Safety Rating | Notes | |----------|---------------|-------| | Blocks | ✅ Excellent | Cache-first approach prevents privacy bypasses | | Mutes | ✅ Good | Cache-first approach maintains privacy | | Follows/Unfollows | ⚠️ Medium | Incomplete but not dangerous | | Moderation Labels | 🔴 Critical | Completely bypassed, major safety issue | | Lists | ⚠️ Medium | Mixed functionality | | Likes/Reposts | ✅ Good | Returns empty, no safety impact | The fallback mechanism is a **well-designed feature with excellent safety properties for its intended use case** (viewing suspended user content and handling AppView outages). It successfully prevents privacy bypasses from blocks and mutes. However: - It's **not causing the reported unfollow/mute bugs** (those are cache invalidation issues) - It **does have a critical moderation label gap** that needs immediate attention - The transparent design (hiding fallback from users) creates UX confusion when data is incomplete --- **Storage Location:** AppView database only (NOT stored in PDS) **API:** `app.bsky.graph.muteActor` and `app.bsky.graph.unmuteActor` (procedures, not records) **Lexicon:** `/atproto/lexicons/app/bsky/graph/muteActor.json` ``` User mutes someone: 1. Client calls agent.mute(did) 2. Request goes to AppView: app.bsky.graph.muteActor 3. Stored in AppView's PostgreSQL database 4. NEVER written to user's PDS repository 5. Retrieved via app.bsky.graph.getMutes ``` **Privacy Rationale:** Mutes are described as "private in Bluesky" - they never leave the AppView's database, ensuring the muted user cannot discover they've been muted by inspecting your PDS. **Code:** `src/state/queries/microcosm-fallback.ts:149-177` ```typescript function buildViewerState(queryClient: QueryClient, did: string) { const muteStatus = isDidMuted(queryClient, did) const viewer: any = {} if (muteStatus.muted) { viewer.muted = true } // Mute status comes from React Query cache, not PDS return viewer } ``` **Function:** `src/state/queries/my-muted-accounts.ts:64-88` ```typescript export function isDidMuted(queryClient: QueryClient, did: string): {muted: boolean} { const queryDatas = queryClient.getQueriesData< InfiniteData<AppBskyGraphGetMutes.OutputSchema> >({ queryKey: [RQKEY_ROOT], }) // Searches through cached mute list from app.bsky.graph.getMutes for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) { continue } for (const page of queryData.pages) { if (page.mutes.find(m => m.did === did)) { return {muted: true} } } } return {muted: false} } ``` The fallback checks the local React Query cache populated by previous `getMutes` calls. This means: ✅ **Safety preserved:** If you've muted someone and that data is in cache, they stay muted even in fallback mode ✅ **Privacy maintained:** Muted users don't see different behavior that would reveal the mute ⚠️ **UX issue:** If cache is stale/empty and fallback triggers, recently muted users may appear unmuted **Code:** `src/state/queries/profile.ts:417-434` ```typescript export function useProfileMuteMutation() { const queryClient = useQueryClient() return useMutation<void, Error, {did: string}>({ mutationFn: async ({did}) => { const agent = getAgent() await agent.mute(did) }, onSuccess(_, {did}) { // ✅ Invalidates mute list cache queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) // ❌ MISSING: Doesn't invalidate profile cache for the muted DID // ❌ MISSING: Doesn't invalidate feed caches that might contain their posts // ❌ MISSING: Doesn't call resetProfilePostsQueries() }, }) } ``` **Compare with block mutation** which works correctly: ```typescript export function useProfileBlockMutation() { const queryClient = useQueryClient() return useMutation<void, Error, {did: string}>({ mutationFn: async ({did}) => { const agent = getAgent() const result = await agent.app.bsky.graph.block.create( {repo: agent.session!.did}, {subject: did, createdAt: new Date().toISOString()}, ) return result }, onSuccess(data, {did}) { // ✅ Invalidates block list queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) // ✅ Invalidates profile and all related caches resetProfilePostsQueries(queryClient, did, 1000) }, }) } ``` ``` 1. User mutes @badactor.com 2. Mutation succeeds, cache updated: {queryKey: ['my-muted-accounts'], data: [..., {did: 'did:plc:badactor'}]} 3. User continues browsing 4. Feed still shows @badactor.com's posts (feed cache not invalidated) 5. User clicks on profile → Shows as unmuted (profile cache not invalidated) 6. User confused: "The mute didn't stick!" ``` The fallback mechanism is **innocent** here. Whether fallback is active or not: - The mute **did succeed** at the AppView - The cache invalidation bug causes stale data to display - The user sees the same bug in both modes **Fallback doesn't make it worse** because `isDidMuted()` checks the cache first, which was already updated by the mutation. **P0 - Fix the actual bug:** ```typescript onSuccess(_, {did}) { queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) // Add comprehensive cache invalidation: resetProfilePostsQueries(queryClient, did, 1000) queryClient.invalidateQueries({queryKey: ['profile', did]}) queryClient.invalidateQueries({queryKey: ['post-feed']}) queryClient.invalidateQueries({queryKey: ['notifications']}) } ``` --- **Storage Location:** User's PDS repository (records) **Collection:** `app.bsky.graph.follow` **Lexicon:** `/atproto/lexicons/app/bsky/graph/follow.json` ```json { "lexicon": 1, "id": "app.bsky.graph.follow", "defs": { "main": { "type": "record", "description": "Record declaring a social 'follow' relationship...", "key": "tid", "record": { "type": "object", "required": ["subject", "createdAt"], "properties": { "subject": { "type": "string", "format": "did", "description": "DID of the account to be followed" }, "createdAt": { "type": "string", "format": "datetime" } } } } } } ``` ``` User follows someone: 1. Client calls agent.follow(did) 2. Creates record: at://[your-did]/app.bsky.graph.follow/[tid] 3. Record value: {subject: target-did, createdAt: timestamp} 4. Written to YOUR PDS repository 5. PDS notifies AppView of new record 6. AppView indexes the follow relationship User unfollows someone: 1. Client calls agent.deleteFollow(followUri) 2. DELETES the record from YOUR PDS 3. PDS notifies AppView of record deletion 4. AppView removes the follow relationship from index ``` **Code:** `src/state/queries/microcosm-fallback.ts:173-174` ```typescript // We can't determine blockedBy, mutedByList, following, or followedBy from PDS alone // These require AppView indexing, so we leave them undefined ``` The fallback **explicitly does not populate** `viewer.following` or `viewer.followedBy` fields. **Why?** To determine if you're following someone, the fallback would need to: ```typescript // Hypothetical implementation (NOT implemented): async function getFollowingStatus(did: string, targetDid: string): Promise<string | undefined> { // 1. Query YOUR PDS for all YOUR follow records const pdsUrl = await getPdsUrl(did) const response = await fetch( `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.graph.follow&limit=100` ) const {records} = await response.json() // 2. Filter through ALL your follows to find the target const followRecord = records.find(r => r.value.subject === targetDid) // 3. If you have 1000+ follows, need to paginate... return followRecord?.uri } ``` This is **expensive** (requires listing and filtering potentially thousands of records) and **not implemented** in the current fallback. **Code:** `src/state/queries/profile.ts:354-364` ```typescript export function useProfileUnfollowMutation(logContext: LogEvents['profile:unfollow']['logContext']) { const queryClient = useQueryClient() return useMutation<void, Error, {did: string; followUri: string}>({ mutationFn: async ({followUri}) => { track('Profile:Unfollow', logContext) const agent = getAgent() return await agent.deleteFollow(followUri) }, // ❌ NO onSuccess CALLBACK AT ALL // ❌ NO queryClient.invalidateQueries() // ❌ NO cache updates }) } ``` **Compare with follow mutation:** ```typescript export function useProfileFollowMutation(logContext: LogEvents['profile:follow']['logContext']) { const queryClient = useQueryClient() return useMutation<{uri: string}, Error, {did: string}>({ mutationFn: async ({did}) => { track('Profile:Follow', logContext) const agent = getAgent() return await agent.follow(did) }, onSuccess(data, {did}) { // ✅ Has cache invalidation resetProfilePostsQueries(queryClient, did, 1000) }, }) } ``` The follow mutation has cache invalidation but unfollow doesn't! ``` 1. User views @someone.com profile - AppView returns: {following: "at://alice/app.bsky.graph.follow/abc123"} - Cache stores this data: {queryKey: ['profile', 'did:plc:someone'], data: {..., viewer: {following: "..."}}} 2. User clicks "Unfollow" - Optimistic update: Sets viewer.following = undefined - UI shows: ✅ "Follow" button (unfollowed state) - Mutation sends: agent.deleteFollow("at://alice/app.bsky.graph.follow/abc123") - PDS: Deletes record successfully - Mutation completes, NO cache invalidation 3. User refreshes page or navigates away and back - Query checks cache: {queryKey: ['profile', 'did:plc:someone']} - Cache still has OLD data: {following: "at://alice/..."} - OR AppView query runs... 4. AppView indexing lag (50-500ms typical): - AppView hasn't processed the delete yet - Returns stale data: {following: "at://alice/..."} - Cache updated with stale data - UI shows: ❌ "Following" (looks like unfollow failed) 5. 2 seconds later, AppView finishes indexing - But user already navigated away - Cache not updated until next manual refresh - User reports: "Unfollows don't stick" ``` The fallback mechanism is **NOT the cause** but can **amplify the confusion**: **Scenario A: Normal mode (AppView indexing lag)** ``` Unfollow → Cache not invalidated → Query hits AppView → AppView stale → Shows "Following" ``` **Scenario B: Fallback mode triggered** ``` Unfollow → Cache not invalidated → Query tries AppView (fails/suspended) → Fallback triggered → Returns viewer: {} (no following field) → UI shows "Follow" ``` Ironically, **fallback mode might work better** because returning `{}` doesn't assert you're still following. The timing of the fallback mechanism's introduction (commit `2dba3544eca8500691c8485da8e7538614645fb1`) coincided with when users started noticing the bug. However: - The cache invalidation bug **existed before** the fallback - The fallback made the bug **more visible** because it added another code path - But the bug affects **both paths equally** **P0 - Fix the actual bug:** ```typescript export function useProfileUnfollowMutation(logContext: LogEvents['profile:unfollow']['logContext']) { const queryClient = useQueryClient() return useMutation<void, Error, {did: string; followUri: string}>({ mutationFn: async ({followUri}) => { track('Profile:Unfollow', logContext) const agent = getAgent() return await agent.deleteFollow(followUri) }, // Add cache invalidation: onSuccess(_, {did}) { resetProfilePostsQueries(queryClient, did, 1000) }, }) } ``` **P1 - Handle AppView lag:** ```typescript onSuccess(_, {did}) { resetProfilePostsQueries(queryClient, did, 1000) // Poll AppView to wait for indexing setTimeout(() => { queryClient.invalidateQueries({queryKey: ['profile', did]}) }, 500) } ``` **P2 - Improve fallback completeness (optional):** ```typescript // Implement expensive follow record querying in fallback async function getFollowingStatus(queryClient, userDid, targetDid) { const pdsUrl = await getPdsUrl(userDid) const follows = await listRecords(pdsUrl, userDid, 'app.bsky.graph.follow') return follows.find(r => r.value.subject === targetDid)?.uri } ``` --- **Storage Location:** User's PDS repository (records) **Collection:** `app.bsky.graph.block` **Lexicon:** `/atproto/lexicons/app/bsky/graph/block.json` Similar to follows, blocks are stored as records in your PDS: ```json { "lexicon": 1, "id": "app.bsky.graph.block", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["subject", "createdAt"], "properties": { "subject": { "type": "string", "format": "did" }, "createdAt": { "type": "string", "format": "datetime" } } } } } } ``` **Lifecycle:** ``` Block someone: at://[your-did]/app.bsky.graph.block/[tid] created with {subject: target-did} Unblock someone: at://[your-did]/app.bsky.graph.block/[tid] deleted ``` **Code:** `src/state/queries/microcosm-fallback.ts:149-177` ```typescript function buildViewerState(queryClient: QueryClient, did: string) { const blockStatus = isDidBlocked(queryClient, did) const viewer: any = {} if (blockStatus.blocked) { viewer.blocking = blockStatus.blockUri } return viewer } ``` **Code:** `src/state/queries/my-blocked-accounts.ts:64-93` ```typescript export function isDidBlocked( queryClient: QueryClient, did: string, ): {blocked: boolean; blockUri?: string} { const queryDatas = queryClient.getQueriesData< InfiniteData<AppBskyGraphGetBlocks.OutputSchema> >({ queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) continue for (const page of queryData.pages) { const block = page.blocks.find(b => b.did === did) if (block) { return { blocked: true, blockUri: block.viewer?.blocking || '', } } } } return {blocked: false} } ``` The fallback checks the block list cache **before** constructing the profile. If a user is blocked: 1. `isDidBlocked()` returns `{blocked: true, blockUri: "..."}` 2. `viewer.blocking` is set in the synthetic response 3. **Critically:** The fallback then returns a `BlockedProfile` stub instead of real data **Code:** `src/state/queries/microcosm-fallback.ts:179-196` ```typescript if (viewer.blocking) { // Return a minimal blocked profile without exposing user data return { did: profileDid, handle: profile.handle, avatar: profile.avatar, viewer, labels: [], // NO posts, NO bio, NO banner } } ``` **Safety guarantees:** ✅ **Privacy preserved:** Blocked users don't see your content even in fallback mode ✅ **Consistency:** Blocking behavior is identical in fallback and normal modes ✅ **No bypass:** Cannot use fallback to "see around" a block ✅ **Bidirectional:** If they blocked you, fallback respects that too **Code:** `src/state/queries/profile.ts:384-405` ```typescript export function useProfileBlockMutation() { const queryClient = useQueryClient() return useMutation<void, Error, {did: string}>({ mutationFn: async ({did}) => { const agent = getAgent() const result = await agent.app.bsky.graph.block.create( {repo: agent.session!.did}, {subject: did, createdAt: new Date().toISOString()}, ) return result }, onSuccess(data, {did}) { // ✅ Comprehensive cache invalidation queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) resetProfilePostsQueries(queryClient, did, 1000) }, }) } ``` Blocks work correctly because: 1. ✅ Cache invalidation is complete 2. ✅ Fallback checks cache first 3. ✅ Returns safe stub for blocked users 4. ✅ No privacy leaks The block implementation demonstrates how the fallback mechanism **should work**: - Cache-first checks - Comprehensive invalidation - Safe fallback behavior - No user-reported bugs **Recommendation:** Use blocks as the reference implementation when fixing follows and mutes. --- **Storage Location:** User's PDS repository (records) **Collection:** `app.bsky.feed.like` **Lexicon:** `/atproto/lexicons/app/bsky/feed/like.json` ``` Like a post: 1. Create record: at://[your-did]/app.bsky.feed.like/[tid] 2. Record value: {subject: {uri: post-uri, cid: post-cid}, createdAt: timestamp} 3. PDS notifies AppView 4. AppView increments like count on post Unlike a post: 1. Delete record at://[your-did]/app.bsky.feed.like/[tid] 2. AppView decrements like count ``` **Code:** `src/state/queries/microcosm-fallback.ts:413-426` ```typescript function buildSyntheticFeedPost( queryClient: QueryClient, repo: string, rkey: string, postRecord: any, ): any { // ... builds post object return { post: { // ... viewer: { // ❌ No like status // ❌ No repost status // ❌ No threadMuted status }, likeCount: engagementCounts?.like || 0, // ✅ From Constellation indexer repostCount: engagementCounts?.repost || 0, replyCount: engagementCounts?.reply || 0, } } } ``` The fallback: - ✅ Shows like **counts** (from Constellation indexer) - ❌ Doesn't show if **you** liked it (`viewer.like` is undefined) **User experience:** ``` Normal mode: [❤️ 42] ← Red heart, you liked this, 42 total likes Fallback mode: [🤍 42] ← Grey heart, doesn't show you liked it, 42 total likes ``` **Safety impact:** ⚪ None - This is a cosmetic issue only **Recommendation:** Low priority. Could implement by querying user's like records from PDS, but expensive and not critical. --- **Storage Location:** User's PDS repository (records) **Collection:** `app.bsky.feed.repost` **Lexicon:** `/atproto/lexicons/app/bsky/feed/repost.json` ``` Repost: 1. Create record: at://[your-did]/app.bsky.feed.repost/[tid] 2. Record value: {subject: {uri: post-uri, cid: post-cid}, createdAt: timestamp} Unrepost: 1. Delete the repost record ``` Same as likes: - ✅ Shows repost **counts** - ❌ Doesn't show if **you** reposted it **Safety impact:** ⚪ None - Cosmetic only --- **Storage Location:** User's PDS repository (records) **Collection:** `app.bsky.feed.post` (with embed) Quote posts are regular posts with an embedded post reference: ```json { "text": "Great point!", "embed": { "$type": "app.bsky.embed.record", "record": { "uri": "at://did:plc:xyz/app.bsky.feed.post/abc123", "cid": "bafyrei..." } }, "createdAt": "2025-11-02T..." } ``` **Quote posts work correctly in fallback mode** because: 1. The quote post itself is a regular post (fetched from PDS) 2. The embedded post is fetched recursively 3. The fallback can construct both posts **Code:** `src/state/queries/microcosm-fallback.ts:490-520` ```typescript if (embed.$type === 'app.bsky.embed.record') { // Recursively fetch the quoted post const quotedPost = await fetchPostFromPds(embed.record.uri) // Build synthetic view for quoted post // ... } ``` ✅ **Works correctly** - Quote posts display properly in fallback mode **Safety impact:** ⚪ None - Functions as designed --- **Storage Location:** Mixed - **List definition:** PDS repository (`app.bsky.graph.list`) - **List membership:** PDS repository (`app.bsky.graph.listitem`) - **List subscriptions:** User preferences (AppView) **Creating a list:** ``` 1. Create record: at://[your-did]/app.bsky.graph.list/[tid] 2. Record value: {purpose: "app.bsky.graph.defs#modlist", name: "Spammers", ...} ``` **Adding someone to a list:** ``` 1. Create record: at://[your-did]/app.bsky.graph.listitem/[tid] 2. Record value: {subject: target-did, list: list-uri} ``` **Subscribing to a list:** ``` 1. Update preferences: app.bsky.actor.putPreferences 2. Add to labelers or moderation list subscriptions ``` **List definitions:** ✅ Can be read from PDS **List membership:** ✅ Can be read from PDS **List subscriptions:** ❌ Cannot be read (preferences are AppView-only) **Impact:** - Viewing someone else's list: ✅ Works - Your own lists: ✅ Work - Knowing if you're subscribed to a moderation list: ❌ Lost in fallback **Safety impact:** ⚠️ Medium If a user subscribes to a moderation list (e.g., "Known Spammers") and fallback triggers: - The subscription information is lost - Posts from list members appear without moderation - Similar to the labeler subscription issue (see Section 2) --- **Storage Location:** User preferences (AppView only) **API:** `app.bsky.actor.getPreferences` / `app.bsky.actor.putPreferences` ``` 1. User subscribes to a labeler (e.g., CSAM filter) - Stored in preferences: {labelersPref: [{did: "did:plc:labeler"}]} 2. When viewing content: - AppView checks: Which labelers is this user subscribed to? - Queries those labeler services for labels on the content - Returns labels: [{val: "csam", src: "did:plc:labeler"}] - Client applies moderation: blur/hide/warn 3. User's subscriptions stored in AppView preferences, NOT PDS ``` 🔴 **CRITICAL SAFETY GAP** **Code:** `src/state/queries/microcosm-fallback.ts:149-177` ```typescript function buildSyntheticProfileView(/* ... */) { return { did: profileDid, handle: profile.handle, // ... labels: [], // ❌ ALWAYS EMPTY viewer: buildViewerState(queryClient, did), } } ``` **Code:** `src/state/queries/microcosm-fallback.ts:413-450` ```typescript function buildSyntheticFeedPost(/* ... */) { return { post: { uri, cid: postRecord.cid, // ... labels: [], // ❌ ALWAYS EMPTY // ... } } } ``` **The fallback NEVER:** 1. Queries user preferences to see which labelers they subscribe to 2. Queries labeler services for labels 3. Populates the `labels` array **Result:** All content appears with `labels: []` regardless of actual labels or user subscriptions. --- **Question:** If a user blocks someone, can the fallback show them as unblocked? **Answer:** No. The cache-first check prevents this. ```typescript const blockStatus = isDidBlocked(queryClient, did) if (blockStatus.blocked) { viewer.blocking = blockStatus.blockUri // Return BlockedProfile stub } ``` **Safety guarantees:** - Blocked users stay blocked - Content is not exposed - Privacy is maintained - No bypass possible **Question:** If a user mutes someone, can that person's content still appear? **Answer:** Not if the cache is populated. The cache-first check works the same as blocks. ```typescript const muteStatus = isDidMuted(queryClient, did) if (muteStatus.muted) { viewer.muted = true } ``` **Caveat:** If the mute cache is empty/stale, newly muted users might appear in fallback mode. However: - This is a UX bug, not a safety breach - The user's privacy isn't compromised (muted users don't know they're muted) - It's annoying but not dangerous **Question:** If a user unfollows someone, can they still see them as followed? **Answer:** Yes, due to the cache invalidation bug, but this is not a safety issue. **Impact:** - User confusion - UI/UX problem - Not a privacy or safety breach AT Protocol's moderation system works via **labelers** (also called moderation services): 1. **Labelers** are services that apply labels to content 2. Labels indicate: `spam`, `nsfw`, `sexual`, `graphic-media`, `csam`, `self-harm`, etc. 3. **Users subscribe** to labelers they trust 4. **Clients respect labels** by hiding, blurring, or warning **Example flow:** ``` User subscribes to "Safety Labeler" (did:plc:safety) ↓ User views post at://bob/app.bsky.feed.post/xyz ↓ AppView queries Safety Labeler: "Any labels for this post?" ↓ Labeler responds: [{val: "csam", src: "did:plc:safety"}] ↓ AppView returns post with labels: [{val: "csam", ...}] ↓ Client sees label, HIDES the post ``` ``` User subscribed to "Safety Labeler" ↓ User views suspended user's post ↓ AppView query fails → Fallback triggers ↓ Fallback fetches post from PDS directly ↓ Fallback returns: {labels: []} ❌ EMPTY ↓ Client sees no labels, SHOWS the post ``` **Post with CSAM label → Shows without warning** **Post with self-harm label → Shows without warning** **Post with graphic violence label → Shows without warning** ``` Alice is a survivor of abuse. She subscribes to: - CSAM detection labeler - Self-harm content labeler - Harassment detection labeler Bob is a suspended user who posted triggering content. Alice views Bob's profile (suspended, so fallback triggers): Normal mode: - AppView checks Alice's subscriptions - Queries labelers - Returns: {labels: [{val: "self-harm"}]} - Client HIDES the post with warning Fallback mode: - Fallback fetches from Bob's PDS - Never checks Alice's preferences - Never queries labelers - Returns: {labels: []} - Client SHOWS the post ❌❌❌ Alice is exposed to triggering content she explicitly opted out of. ``` 🔴 **HIGH SEVERITY** **Affected populations:** - Minors (CSAM protection bypassed) - Abuse survivors (triggering content shown) - Users with PTSD (graphic content shown) - Users avoiding harassment (labeled harassers shown) **Exploit potential:** - Malicious users could intentionally get suspended - Suspended users' content bypasses all moderation - Creates a perverse incentive (suspension = moderation bypass) **Scope:** - Any user subscribed to ANY labeler - Any content with ANY labels - Entire moderation system bypassed The fallback was designed for a **specific use case:** - Viewing suspended user profiles/posts - Emergency fallback when AppView is down - **Assumed context:** Temporary, rare events **Trade-offs made:** - Query speed prioritized over completeness - Avoid adding latency from labeler queries - Assumption: Most suspended users don't need viewing anyway **What was missed:** - Suspended users might have posted harmful content (that's WHY they're suspended) - Users rely on labelers for safety, not just preference - Fallback isn't as rare as expected (triggers on various errors) **Question:** Can the fallback mechanism bypass moderation labels a user has subscribed to? **Answer:** YES, completely. Not "override" but "ignore entirely." The fallback: 1. Never queries `app.bsky.actor.getPreferences` to see user subscriptions 2. Never queries labeler services 3. Always returns `labels: []` **This is equivalent to turning off all moderation.** **Intentional bypass (user choice):** ``` User: "Show me NSFW content" Settings: labelerPreferences[].visibility = "show" Result: Content shown with disclaimer ``` **Fallback bypass (architectural):** ``` Fallback: *returns labels: []* Client: "No labels? Must be safe!" Result: Content shown without ANY indication ``` The user **has no idea** their moderation is bypassed. --- **Repository:** `/Users/clint/src/blacksky-algorithms/blacksky.community/red-dwarf/` From `red-dwarf/README.md`: > Red Dwarf is a minimal AT Protocol client that reads directly from Personal Data Servers (PDS). It's designed as a fallback viewer for when AppView services are unavailable or users are suspended. **Key characteristics:** - **PDS-only:** Never queries AppView - **Read-only:** No mutations (follow, like, post) - **Explicit limitations:** README documents what doesn't work - **Educational:** Shows how AT Protocol works at the PDS level **File:** `red-dwarf/src/utils/useQuery.ts` ```typescript export function useQuery<T>( queryKey: string[], queryFn: () => Promise<T>, options?: UseQueryOptions ) { // Standard React Query wrapper // NO fallback mechanism // NO AppView integration // Direct PDS queries only return useReactQuery({ queryKey, queryFn, ...options, }) } ``` **Notable:** Red-Dwarf's `useQuery` is a **simple wrapper** with no fallback logic. It always queries PDS, never AppView. **File:** `red-dwarf/src/utils/useHydrated.ts` ```typescript export function useHydratedProfile(did: string) { // 1. Fetch identity from DID document const identity = useIdentity(did) // 2. Fetch profile record from PDS const profileRecord = useQuery( ['profile-record', did], () => fetchRecord(identity.pdsUrl, did, 'app.bsky.actor.profile', 'self') ) // 3. Return raw data with NO synthetic AppView fields return { did, handle: identity.handle, displayName: profileRecord?.displayName, description: profileRecord?.description, avatar: profileRecord?.avatar, // NO viewer state // NO follower counts // NO following status } } ``` **"Hydration" means:** - Fetch raw PDS records - Present them as-is - Don't synthesize AppView-style responses - Be honest about what's missing **Red-Dwarf:** ✅ Doesn't have this problem **Why:** Red-Dwarf has **no write operations**. There are no follow/unfollow/mute mutations to create cache invalidation bugs. From README: > Red Dwarf is read-only. You cannot post, follow, like, or perform any write operations. **Red-Dwarf:** ⚠️ Has the same issue, but it's **acceptable** **Why:** Red-Dwarf is **explicit about limitations**: From README: > **What doesn't work:** > - Moderation labels (not available from PDS) > - Following Feed (requires AppView indexing) > - Notifications (requires AppView) > - Search (requires AppView) **Key difference:** User expectations. - **Red-Dwarf:** "This is a limited PDS viewer. Some features don't work." - **Blacksky.community:** "This is a full client. Everything works." Users of Red-Dwarf know it's limited. Users of Blacksky.community expect full functionality. **Red-Dwarf:** ✅ Doesn't have this problem **Why:** Red-Dwarf **never shows follow state**. Profiles return: ```typescript { did: "...", handle: "...", displayName: "...", // NO viewer.following // NO viewer.followedBy // NO follower counts } ``` Users don't see follow buttons or follow status, so there's no confusion. | Aspect | Red-Dwarf | Blacksky.community | |--------|-----------|-------------------| | **Data source** | PDS only | AppView + PDS fallback | | **Write operations** | None | Full (follow, post, like) | | **Viewer state** | Never shown | Attempted in fallback | | **Moderation labels** | Documented as missing | Silently missing | | **User expectations** | "Limited viewer" | "Full client" | | **Cache complexity** | Simple | Complex (two sources) | | **Consistency model** | Always stale, that's OK | Tries to be fresh, fails sometimes | Red-Dwarf's README is upfront: > **What doesn't work: [bulleted list]** Blacksky.community could: - Show a banner when fallback is active - Indicate which features are limited - Set user expectations Red-Dwarf returns: ```typescript {viewer: {}} // Empty, honest ``` Blacksky.community tries: ```typescript {viewer: {muted: isDidMuted(...)}} // Might be wrong ``` **Better approach:** If you can't guarantee accuracy, don't present it as accurate. Red-Dwarf is a **separate application** with a different UI. When using it, users know they're in "limited mode." Blacksky.community could: - Switch to a "limited mode" UI when fallback is active - Remove buttons/features that won't work correctly - Visual distinction makes limitations obvious Red-Dwarf's README explains: - Why PDS-only - What trade-offs were made - How AT Protocol works Blacksky.community could benefit from similar documentation for the fallback mechanism. **Pros:** - Simpler, fewer edge cases - Clear user expectations - No cache coherence issues **Cons:** - No write operations (can't post/follow/like) - Very limited functionality - Not suitable as a daily driver **Verdict:** Red-Dwarf's approach works for a **supplementary viewer** but not for a **primary client**. Blacksky.community needs write operations and full functionality. **Better synthesis:** - Keep fallback mechanism for suspended users - Add safety guardrails (labeler queries, warnings) - Fix cache invalidation bugs - Be transparent when fallback is active --- **User reports:** > I have noticed unfollows don't stick, and even doing it multiple times doesn't work. I have also noticed the same phenomenon with unlisted mutes: you can mute someone but it seems like the record isn't being written onto my profile. **Analysis:** **Root cause:** Missing cache invalidation in `useProfileUnfollowMutation` **Evidence:** - Follow mutation has `onSuccess` with cache invalidation ✅ - Unfollow mutation has NO `onSuccess` callback ❌ - Affects both fallback and normal modes - Timing makes it worse (AppView indexing lag) **Fix:** ```typescript onSuccess(_, {did}) { resetProfilePostsQueries(queryClient, did, 1000) } ``` **Relation to fallback:** Coincidental timing, not causal **Root cause:** Incomplete cache invalidation in `useProfileMuteMutation` **Evidence:** - Mute IS written successfully to AppView ✅ - Cache invalidation only updates mute list ⚠️ - Profile cache and feed cache not invalidated ❌ - Muted user's content continues appearing in UI **Fix:** ```typescript onSuccess(_, {did}) { queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) resetProfilePostsQueries(queryClient, did, 1000) // Add this queryClient.invalidateQueries({queryKey: ['profile', did]}) // Add this } ``` **Relation to fallback:** Independent bug, exists in both modes **Timeline:** ``` Commit 2dba354 "Add fallback mechanism for suspended users" │ ├─ Fallback code added ├─ New code paths introduced ├─ More visible edge cases │ └─ Cache invalidation bugs become more noticeable │ ├─ Before: Bug exists but subtle ├─ After: Bug triggered more often └─ Users associate bug with recent change ``` **Correlation is not causation:** The fallback mechanism: - Was introduced around the time users noticed bugs ✅ - Added complexity to the codebase ✅ - Made existing bugs more visible ✅ - **Did NOT cause the bugs** ❌ **Actual causes existed before:** - Unfollow mutation never had cache invalidation - Mute mutation always had incomplete invalidation - AppView indexing lag always existed **Why fallback gets blamed:** 1. **Recency bias:** It's the recent change 2. **Complexity:** More code paths = more opportunities for bugs 3. **Correlation:** Timing of introduction matched user reports But the bugs exist **with or without** the fallback. --- **File:** `src/state/queries/profile.ts:354-364` **Change:** ```typescript export function useProfileUnfollowMutation(logContext: LogEvents['profile:unfollow']['logContext']) { const queryClient = useQueryClient() return useMutation<void, Error, {did: string; followUri: string}>({ mutationFn: async ({followUri}) => { track('Profile:Unfollow', logContext) const agent = getAgent() return await agent.deleteFollow(followUri) }, // ADD: onSuccess(_, {did}) { resetProfilePostsQueries(queryClient, did, 1000) }, }) } ``` **Impact:** ✅ Fixes "unfollows don't stick" **Risk:** Low (copying existing pattern from follow mutation) **Effort:** 5 minutes **File:** `src/state/queries/profile.ts:417-434` **Change:** ```typescript export function useProfileMuteMutation() { const queryClient = useQueryClient() return useMutation<void, Error, {did: string}>({ mutationFn: async ({did}) => { const agent = getAgent() await agent.mute(did) }, onSuccess(_, {did}) { queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) // ADD: resetProfilePostsQueries(queryClient, did, 1000) }, }) } ``` **Impact:** ✅ Fixes "mutes don't persist in UI" **Risk:** Low **Effort:** 5 minutes **File:** `src/state/queries/profile.ts` (in both mutations) **Enhancement:** ```typescript onSuccess(_, {did}) { // Immediate invalidation resetProfilePostsQueries(queryClient, did, 0) // Delayed refetch to catch AppView indexing setTimeout(() => { queryClient.invalidateQueries({queryKey: ['profile', did]}) queryClient.refetchQueries({queryKey: ['profile', did]}) }, 500) // Wait 500ms for AppView to index } ``` **Impact:** ✅ Reduces race conditions with AppView indexing **Risk:** Low **Effort:** 10 minutes **Create:** `src/components/FallbackModeBanner.tsx` ```typescript export function FallbackModeBanner() { return ( <div className="fallback-warning"> ⚠️ Limited Mode: You're viewing content directly from the source. Some safety features and moderation labels may not be available. </div> ) } ``` **Usage:** Show banner when fallback is active **Impact:** ✅ Sets user expectations about limited functionality **Risk:** Low **Effort:** 1 hour **File:** `src/state/queries/microcosm-fallback.ts` **Add:** ```typescript async function getLabelsForContent( did: string, uri: string ): Promise<ComAtprotoLabelDefs.Label[]> { try { // 1. Get user's labeler subscriptions const agent = getAgent() const prefs = await agent.app.bsky.actor.getPreferences() const labelersPref = prefs.data.preferences.find( p => p.$type === 'app.bsky.actor.defs#labelersPref' ) if (!labelersPref?.labelers?.length) { return [] } // 2. Query each labeler const labelPromises = labelersPref.labelers.map(async (labeler) => { try { const response = await fetch( `https://labeler.${labeler.did}/xrpc/com.atproto.label.queryLabels?uris=${uri}` ) const {labels} = await response.json() return labels || [] } catch { return [] } }) const labelArrays = await Promise.all(labelPromises) return labelArrays.flat() } catch { // If labeler queries fail, return empty (safe default) return [] } } ``` **Use in:** ```typescript async function fetchPostWithFallback(uri: string) { // ... existing code ... // Add label fetching: const labels = await getLabelsForContent(repo, uri) return { // ... existing fields ... labels, // ✅ Now populated } } ``` **Impact:** ✅ Restores moderation in fallback mode **Risk:** Medium (adds latency, labeler queries might fail) **Effort:** 4 hours **Trade-off:** Adds ~200-500ms latency for labeler queries. Consider: - Only query labelers if fallback is triggered (not on normal path) - Cache labeler responses - Set aggressive timeout (1s) **Create:** `docs/fallback-mechanism.md` **Content:** - How fallback works - When it triggers - What's limited - Safety considerations - How to disable it **Impact:** ✅ Helps users and developers understand the system **Effort:** 2 hours --- **No.** The evidence strongly suggests: 1. **Primary cause:** Missing cache invalidation in unfollow/unmute mutations 2. **Secondary cause:** AppView indexing lag creating race conditions 3. **Tertiary cause:** Optimistic updates being overwritten by stale data The fallback mechanism is **correctly implemented** for its intended purpose (showing profiles of suspended users). It has **excellent safety properties** for blocks and mutes. **No.** The fallback mechanism provides real value but should be improved with safety guardrails. **Immediate (fixes the reported bug):** 1. ✅ Add cache invalidation to unfollow mutation 2. ✅ Add cache invalidation to unmute mutation 3. ✅ Add AppView readiness polling for follow/unfollow operations **High Priority (safety):** 4. ⚠️ Add fallback mode warning banner when moderation labels unavailable 5. ⚠️ Query moderation preferences in fallback 6. ⚠️ Document fallback limitations --- **Document Version:** 2.0 **Date:** 2025-11-02 **Analysis:** Comprehensive review of fallback mechanism impact on social interactions and user safety
1 parent 3fcc4ac commit a85599f

File tree

4 files changed

+86
-22
lines changed

4 files changed

+86
-22
lines changed

patches/@atproto+api+0.14.21.patch renamed to patches/@atproto+api+0.16.7.patch

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
diff --git a/node_modules/@atproto/api/dist/moderation/decision.js b/node_modules/@atproto/api/dist/moderation/decision.js
2-
index aaac177..d27c0be 100644
2+
index 6dd9d4d..59df896 100644
33
--- a/node_modules/@atproto/api/dist/moderation/decision.js
44
+++ b/node_modules/@atproto/api/dist/moderation/decision.js
5-
@@ -67,6 +67,8 @@ class ModerationDecision {
5+
@@ -67,6 +67,10 @@ class ModerationDecision {
66
ui(context) {
77
const ui = new ui_1.ModerationUI();
88
for (const cause of this.causes) {
9-
+ if (cause?.label?.val === '!no-unauthenticated') continue;
9+
+ // Skip !no-unauthenticated label only if user is authenticated
10+
+ // If userDid is present in opts, user is logged in
11+
+ if (cause?.label?.val === '!no-unauthenticated' && this.opts?.userDid) continue;
1012
+
1113
if (cause.type === 'blocking' ||
1214
cause.type === 'blocked-by' ||

src/state/queries/microcosm-fallback.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,26 +118,31 @@ export async function fetchConstellationCounts(
118118
*
119119
* IMPORTANT: This determines whether fallback should be triggered.
120120
* We should NOT trigger fallback for intentional blocking/privacy errors.
121+
*
122+
* SECURITY: We distinguish between:
123+
* - AppView failures (suspended users, server errors) → trigger fallback
124+
* - Access denials (privacy settings, blocking) → respect AppView decision
121125
*/
122126
export function isAppViewError(error: any): boolean {
123127
if (!error) return false
124128

125129
const msg = error.message?.toLowerCase() || ''
126130

127-
// Do NOT trigger fallback for intentional blocking
128-
// "Requester has blocked actor" means the user intentionally blocked someone
129-
// This is NOT an AppView outage - it's privacy enforcement
131+
// Do NOT trigger fallback for intentional blocking/privacy enforcement
132+
// These are NOT AppView outages - they are access control decisions
130133
if (msg.includes('blocked actor')) return false
131134
if (msg.includes('requester has blocked')) return false
132135
if (msg.includes('blocking')) return false
136+
if (msg.includes('not available')) return false // Privacy: logged-out visibility
137+
if (msg.includes('logged out')) return false // Privacy: requires authentication
138+
if (msg.includes('requires auth')) return false // Privacy: authentication required
139+
if (msg.includes('unauthorized')) return false // Privacy: access denied
133140

134-
// Check HTTP status codes
135-
if (error.status === 400 || error.status === 404) return true
136-
137-
// Check error messages for actual AppView issues
138-
if (msg.includes('not found')) return true
141+
// Trigger fallback for actual AppView failures (suspended users, etc.)
142+
// Note: We removed blanket 400/404 handling to avoid bypassing access controls
139143
if (msg.includes('suspended')) return true
140-
if (msg.includes('could not locate')) return true
144+
if (msg.includes('could not locate record')) return true
145+
if (msg.includes('profile not found')) return true
141146

142147
return false
143148
}

src/state/queries/profile.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,6 @@ import {
99
type ComAtprotoRepoUploadBlob,
1010
type Un$Typed,
1111
} from '@atproto/api'
12-
import {
13-
keepPreviousData,
14-
prefetchQueryWithFallback,
15-
type QueryClient,
16-
useMutation,
17-
useQuery,
18-
useQueryClient,
19-
} from './useQueryWithFallback'
2012

2113
import {uploadBlob} from '#/lib/api'
2214
import {until} from '#/lib/async/until'
@@ -42,6 +34,14 @@ import {
4234
import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations'
4335
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
4436
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
37+
import {
38+
keepPreviousData,
39+
prefetchQueryWithFallback,
40+
type QueryClient,
41+
useMutation,
42+
useQuery,
43+
useQueryClient,
44+
} from './useQueryWithFallback'
4545

4646
export * from '#/state/queries/unstable-profile-cache'
4747
/**
@@ -354,11 +354,21 @@ function useProfileUnfollowMutation(
354354
logContext: LogEvents['profile:unfollow']['logContext'],
355355
) {
356356
const agent = useAgent()
357+
const queryClient = useQueryClient()
357358
return useMutation<void, Error, {did: string; followUri: string}>({
358359
mutationFn: async ({followUri}) => {
359360
logEvent('profile:unfollow', {logContext})
360361
return await agent.deleteFollow(followUri)
361362
},
363+
onSuccess(_, {did}) {
364+
// Invalidate profile and feed caches to reflect unfollow
365+
resetProfilePostsQueries(queryClient, did, 1000)
366+
367+
// Add delay to handle AppView indexing lag
368+
setTimeout(() => {
369+
queryClient.invalidateQueries({queryKey: RQKEY(did)})
370+
}, 500)
371+
},
362372
})
363373
}
364374

@@ -418,8 +428,21 @@ function useProfileMuteMutation() {
418428
mutationFn: async ({did}) => {
419429
await agent.mute(did)
420430
},
421-
onSuccess() {
431+
onSuccess(_, {did}) {
432+
// Invalidate mute list cache
422433
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
434+
435+
// Invalidate profile and feed caches to reflect mute
436+
resetProfilePostsQueries(queryClient, did, 1000)
437+
queryClient.invalidateQueries({queryKey: RQKEY(did)})
438+
439+
// Invalidate feeds that might contain muted user's posts
440+
queryClient.invalidateQueries({queryKey: ['post-feed']})
441+
442+
// Add delay to handle AppView indexing lag
443+
setTimeout(() => {
444+
queryClient.invalidateQueries({queryKey: RQKEY(did)})
445+
}, 500)
423446
},
424447
})
425448
}
@@ -431,8 +454,21 @@ function useProfileUnmuteMutation() {
431454
mutationFn: async ({did}) => {
432455
await agent.unmute(did)
433456
},
434-
onSuccess() {
457+
onSuccess(_, {did}) {
458+
// Invalidate mute list cache
435459
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
460+
461+
// Invalidate profile and feed caches to reflect unmute
462+
resetProfilePostsQueries(queryClient, did, 1000)
463+
queryClient.invalidateQueries({queryKey: RQKEY(did)})
464+
465+
// Invalidate feeds that might contain unmuted user's posts
466+
queryClient.invalidateQueries({queryKey: ['post-feed']})
467+
468+
// Add delay to handle AppView indexing lag
469+
setTimeout(() => {
470+
queryClient.invalidateQueries({queryKey: RQKEY(did)})
471+
}, 500)
436472
},
437473
})
438474
}

src/state/queries/useQueryWithFallback.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type UseQueryResult,
1212
} from '@tanstack/react-query'
1313

14+
import {useSession} from '#/state/session'
1415
import {
1516
buildSyntheticFeedPage,
1617
buildSyntheticPostView,
@@ -109,6 +110,7 @@ export function useQuery<TData = unknown, TError = Error>(
109110
} = options
110111

111112
const queryClient = useQueryClient()
113+
const {hasSession} = useSession()
112114

113115
// Wrap the original queryFn with fallback logic
114116
const wrappedQueryFn: typeof queryFn = async context => {
@@ -123,6 +125,15 @@ export function useQuery<TData = unknown, TError = Error>(
123125
throw error
124126
}
125127

128+
// SECURITY: Do NOT trigger fallback for logged-out users
129+
// This prevents bypassing AppView access controls like logged-out visibility settings
130+
if (!hasSession) {
131+
console.log(
132+
'[Fallback] Skipping fallback for logged-out user (respecting access controls)',
133+
)
134+
throw error
135+
}
136+
126137
console.log('[Fallback] Attempting PDS + Microcosm fallback:', {
127138
fallbackType,
128139
fallbackIdentifier,
@@ -210,6 +221,7 @@ export function useInfiniteQuery<
210221
} = options
211222

212223
const queryClient = useQueryClient()
224+
const {hasSession} = useSession()
213225

214226
// Wrap the original queryFn with fallback logic
215227
const wrappedQueryFn = async (
@@ -226,6 +238,15 @@ export function useInfiniteQuery<
226238
throw error
227239
}
228240

241+
// SECURITY: Do NOT trigger fallback for logged-out users
242+
// This prevents bypassing AppView access controls like logged-out visibility settings
243+
if (!hasSession) {
244+
console.log(
245+
'[Fallback] Skipping fallback for logged-out user (respecting access controls)',
246+
)
247+
throw error
248+
}
249+
229250
const pageParam = 'pageParam' in context ? context.pageParam : undefined
230251

231252
console.log(

0 commit comments

Comments
 (0)