diff --git a/patches/@atproto+api+0.14.21.patch b/patches/@atproto+api+0.16.7.patch similarity index 61% rename from patches/@atproto+api+0.14.21.patch rename to patches/@atproto+api+0.16.7.patch index c9ead6cb8ad..d3869cfb277 100644 --- a/patches/@atproto+api+0.14.21.patch +++ b/patches/@atproto+api+0.16.7.patch @@ -1,12 +1,14 @@ diff --git a/node_modules/@atproto/api/dist/moderation/decision.js b/node_modules/@atproto/api/dist/moderation/decision.js -index aaac177..d27c0be 100644 +index 6dd9d4d..59df896 100644 --- a/node_modules/@atproto/api/dist/moderation/decision.js +++ b/node_modules/@atproto/api/dist/moderation/decision.js -@@ -67,6 +67,8 @@ class ModerationDecision { +@@ -67,6 +67,10 @@ class ModerationDecision { ui(context) { const ui = new ui_1.ModerationUI(); for (const cause of this.causes) { -+ if (cause?.label?.val === '!no-unauthenticated') continue; ++ // Skip !no-unauthenticated label only if user is authenticated ++ // If userDid is present in opts, user is logged in ++ if (cause?.label?.val === '!no-unauthenticated' && this.opts?.userDid) continue; + if (cause.type === 'blocking' || cause.type === 'blocked-by' || diff --git a/src/state/queries/microcosm-fallback.ts b/src/state/queries/microcosm-fallback.ts index 6411390ff45..88e66739113 100644 --- a/src/state/queries/microcosm-fallback.ts +++ b/src/state/queries/microcosm-fallback.ts @@ -118,26 +118,31 @@ export async function fetchConstellationCounts( * * IMPORTANT: This determines whether fallback should be triggered. * We should NOT trigger fallback for intentional blocking/privacy errors. + * + * SECURITY: We distinguish between: + * - AppView failures (suspended users, server errors) → trigger fallback + * - Access denials (privacy settings, blocking) → respect AppView decision */ export function isAppViewError(error: any): boolean { if (!error) return false const msg = error.message?.toLowerCase() || '' - // Do NOT trigger fallback for intentional blocking - // "Requester has blocked actor" means the user intentionally blocked someone - // This is NOT an AppView outage - it's privacy enforcement + // Do NOT trigger fallback for intentional blocking/privacy enforcement + // These are NOT AppView outages - they are access control decisions if (msg.includes('blocked actor')) return false if (msg.includes('requester has blocked')) return false if (msg.includes('blocking')) return false + if (msg.includes('not available')) return false // Privacy: logged-out visibility + if (msg.includes('logged out')) return false // Privacy: requires authentication + if (msg.includes('requires auth')) return false // Privacy: authentication required + if (msg.includes('unauthorized')) return false // Privacy: access denied - // Check HTTP status codes - if (error.status === 400 || error.status === 404) return true - - // Check error messages for actual AppView issues - if (msg.includes('not found')) return true + // Trigger fallback for actual AppView failures (suspended users, etc.) + // Note: We removed blanket 400/404 handling to avoid bypassing access controls if (msg.includes('suspended')) return true - if (msg.includes('could not locate')) return true + if (msg.includes('could not locate record')) return true + if (msg.includes('profile not found')) return true return false } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 97388a98950..af3fcfc48b7 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -9,14 +9,6 @@ import { type ComAtprotoRepoUploadBlob, type Un$Typed, } from '@atproto/api' -import { - keepPreviousData, - prefetchQueryWithFallback, - type QueryClient, - useMutation, - useQuery, - useQueryClient, -} from './useQueryWithFallback' import {uploadBlob} from '#/lib/api' import {until} from '#/lib/async/until' @@ -42,6 +34,14 @@ import { import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' +import { + keepPreviousData, + prefetchQueryWithFallback, + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from './useQueryWithFallback' export * from '#/state/queries/unstable-profile-cache' /** @@ -354,11 +354,21 @@ function useProfileUnfollowMutation( logContext: LogEvents['profile:unfollow']['logContext'], ) { const agent = useAgent() + const queryClient = useQueryClient() return useMutation({ mutationFn: async ({followUri}) => { logEvent('profile:unfollow', {logContext}) return await agent.deleteFollow(followUri) }, + onSuccess(_, {did}) { + // Invalidate profile and feed caches to reflect unfollow + resetProfilePostsQueries(queryClient, did, 1000) + + // Add delay to handle AppView indexing lag + setTimeout(() => { + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, 500) + }, }) } @@ -418,8 +428,21 @@ function useProfileMuteMutation() { mutationFn: async ({did}) => { await agent.mute(did) }, - onSuccess() { + onSuccess(_, {did}) { + // Invalidate mute list cache queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) + + // Invalidate profile and feed caches to reflect mute + resetProfilePostsQueries(queryClient, did, 1000) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + + // Invalidate feeds that might contain muted user's posts + queryClient.invalidateQueries({queryKey: ['post-feed']}) + + // Add delay to handle AppView indexing lag + setTimeout(() => { + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, 500) }, }) } @@ -431,8 +454,21 @@ function useProfileUnmuteMutation() { mutationFn: async ({did}) => { await agent.unmute(did) }, - onSuccess() { + onSuccess(_, {did}) { + // Invalidate mute list cache queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) + + // Invalidate profile and feed caches to reflect unmute + resetProfilePostsQueries(queryClient, did, 1000) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + + // Invalidate feeds that might contain unmuted user's posts + queryClient.invalidateQueries({queryKey: ['post-feed']}) + + // Add delay to handle AppView indexing lag + setTimeout(() => { + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, 500) }, }) } diff --git a/src/state/queries/useQueryWithFallback.ts b/src/state/queries/useQueryWithFallback.ts index f9ce13aac90..f0f915de4d0 100644 --- a/src/state/queries/useQueryWithFallback.ts +++ b/src/state/queries/useQueryWithFallback.ts @@ -11,6 +11,7 @@ import { type UseQueryResult, } from '@tanstack/react-query' +import {useSession} from '#/state/session' import { buildSyntheticFeedPage, buildSyntheticPostView, @@ -109,6 +110,7 @@ export function useQuery( } = options const queryClient = useQueryClient() + const {hasSession} = useSession() // Wrap the original queryFn with fallback logic const wrappedQueryFn: typeof queryFn = async context => { @@ -123,6 +125,15 @@ export function useQuery( throw error } + // SECURITY: Do NOT trigger fallback for logged-out users + // This prevents bypassing AppView access controls like logged-out visibility settings + if (!hasSession) { + console.log( + '[Fallback] Skipping fallback for logged-out user (respecting access controls)', + ) + throw error + } + console.log('[Fallback] Attempting PDS + Microcosm fallback:', { fallbackType, fallbackIdentifier, @@ -210,6 +221,7 @@ export function useInfiniteQuery< } = options const queryClient = useQueryClient() + const {hasSession} = useSession() // Wrap the original queryFn with fallback logic const wrappedQueryFn = async ( @@ -226,6 +238,15 @@ export function useInfiniteQuery< throw error } + // SECURITY: Do NOT trigger fallback for logged-out users + // This prevents bypassing AppView access controls like logged-out visibility settings + if (!hasSession) { + console.log( + '[Fallback] Skipping fallback for logged-out user (respecting access controls)', + ) + throw error + } + const pageParam = 'pageParam' in context ? context.pageParam : undefined console.log(